首页 > 编程学习 > 【并发编程】 --- synchronized锁的升级过程 + JDK1.6对synchronized关键字的其他优化简介

文章目录

  • 1 JDK1.6对synchronized关键字优化概览
  • 2 synchronized锁升级过程
    • 2.1 偏向锁( Biased Locking ) --- 适用于同一个线程反复进入同步代码块的情况
      • 2.1.1 什么是偏向锁
      • 2.1.2 偏向锁加锁 + 撤销原理
      • 2.1.3 偏向锁的验证
      • 2.1.4 偏向锁的好处
      • 小结
    • 2.2 轻量级锁 (Lightweight Locking)--- 适用于线程交替进入同步方法的情况
      • 2.2.1 什么是轻量级锁
      • 2.2.2 轻量级锁加锁 + 撤销原理
      • 2.2.3 轻量级锁的验证
      • 2.2.4 轻量级锁好处
    • 2.3 自旋
      • 2.3.1 自旋锁
      • 2.3.2 适应性自旋锁
      • 2.3.3 自旋在JDK(hotspot)中的源码
  • 3 JDK1.6对synchronized关键字的其他优化简介
    • 3.1 锁消除
    • 3.2 锁粗化

源码地址:https://github.com/nieandsun/concurrent-study.git


1 JDK1.6对synchronized关键字优化概览

前面的文章《【并发编程】 — 从字节码指令的角度去理解synchronized关键字的原理》、《【并发编程】 — 从JVM源码的角度进一步去理解synchronized关键字的原理》已经介绍过在JDK1.6之前synchronized关键字的处理逻辑是只要线程想进入同步代码块就会先去调用内核函数去抢占锁对象关联的monitor的所有权。

调用内核函数就会涉及到内核态和用户态的切换问题,这会消耗大量的系统资源,降低程序运行效率。而有研究表明大多数情况下代码都是交替进行执行的交替执行就不会产生并发,自然也就不会带来并发安全问题,因此JDK1.6之前synchronized关键字的这种机制是有一定问题的 。

Doug Lea搞得Reentrantlock其实就很好的解决了该问题,可以看一下我这篇文章《【并发编程】 — Reentrantlock源码解析1:同步方法交替执行的处理逻辑》 。

应该是因为synchronized关键字是JDK原有关键字的原因吧,HotSpot虛拟机开发团队在JDK1.6这个版本上花费了大量的精力去实现各种锁优化技术,包括偏向锁( Biased Locking )、轻量级锁( Lightweight Locking )、适应性自旋(Adaptive Spinning)、锁消除( Lock Elimination)、锁粗化( Lock Coarsening )等 —》这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。


2 synchronized锁升级过程

锁的升级过程为: 无锁 —> 偏向锁 —> 轻量级锁 —> 重量级锁


2.1 偏向锁( Biased Locking ) — 适用于同一个线程反复进入同步代码块的情况


2.1.1 什么是偏向锁

偏向锁是JDK 6中的重要引进,因为HotSpot作者经过研究实践发现,在大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。

偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及ThreadID即可。

不过一旦出现多个线程竞争时必须撤销偏向锁,所以撤销偏向锁消耗的性能必须小于之前节省下来的CAS原子操作的性能消耗,不然就得不偿失了。


2.1.2 偏向锁加锁 + 撤销原理

【加锁】
当线程第一次访问同步块并获取锁时,偏向锁处理流程如下:

  • (1)虚拟机将会把对象头中的标志位设为“01”,即偏向模式。
  • (2)同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中 ,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作,偏向锁的效率高。

结合Mark Word(可以参看我上篇博客《【并发编程】 — 原来java对象的布局是可以被这样证明的!!!》)存储结构可以更好的进行理解:
在这里插入图片描述


【撤销】
偏向锁的撤销过程如下:

  • (1)偏向锁的撤销动作必须等待全局安全点(这是JVM决定的,一般将循环的末尾、方法返回前等作为安全点)
  • (2)挂起拥有偏向锁的线程,判断锁对象是否处于被锁定状态
  • (3)撤销偏向锁,恢复到无锁(标志位为 01 )或轻量级锁(标志位为 00)的状态

【加锁+撤销过程原理图】
偏向锁加锁 + 撤销原理可以用下图进行表示:
这是《Java并发编程的艺术》中对该过程的解释图,画的挺好的,这里拿来用一下☺☺☺。
在这里插入图片描述


2.1.3 偏向锁的验证

偏向锁在Java 6之后是默认启用的,但在应用程序启动几秒钟之后才激活,可以使用 -XX:BiasedLockingStartupDelay=0参数关闭延迟,如果确定应用程序中所有锁通常情况下处于竞争状态,可以通过XX:-UseBiasedLocking=false 参数关闭偏向锁。

验证程序如下:

package com.nrsc.ch1.base.jmm.syn_study.upgrade;
import org.openjdk.jol.info.ClassLayout;
public class BiasedLockingDemo {
    
    private static class MyThread extends Thread {
        //static修饰只会初始化一次
        static Object obj = new Object();

        @Override
        public void run() {
            for (int i = 0; i < 3; i++) {
                synchronized (obj) {
                    //打印锁对象的布局
                    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
                }
            }
        }
    }

    public static void main(String[] args) {
        MyThread mt = new MyThread();
        mt.start();
    }
}
  • 要想获得想要的效果,在运行时,需加上如下VM参数`
-XX:BiasedLockingStartupDelay=0
  • 运行结果如下:

在这里插入图片描述

绿色部分为Mark Word的前56位存放了线程的ThreadId 和Epoch ,可以看到这56位的值都一样
黄色部分为Mark Word的最后三位101

这个结果与2.1.2中表里描述的一致。


2.1.4 偏向锁的好处

偏向锁是在只有一个线程执行同步块时进一步提高性能,适用于一个线程反复获得同一锁的情况。偏向锁可以提高带有同步但无竞争的程序性能。

它同样是一个带有效益权衡性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问比如线程池,那偏向模式就是多余的。

在JDK5中偏向锁默认是关闭的,而到了JDK6中偏向锁已经默认开启。但在应用程序启动几秒钟之后才激活,可以使用 -XX:BiasedLockingStartupDelay=0参数关闭延迟,如果确定应用程序中所有锁通常情况下处于竞争状态,可以通过 XX:-UseBiasedLocking=false 参数关闭偏向锁。


小结

  • 偏向锁的原理:

当锁对象第一次被线程获取的时候, 虚拟机将会把其对象头中的标志位设为“101”, 即偏向模式。 同时使用CAS操作把获取到这个锁的线程的ID记录在锁对象的Mark Word之中 , 如果CAS操作成功, 持有偏向锁的线程以后每次进入这个锁相关的同步块时, 虚拟机都可以不再进行任何同步操作, 偏向锁的效率高。

  • 偏向锁的好处

偏向锁是在只有一个线程执行同步块时进一步提高性能, 适用于一个线程反复获得同一锁的情况。 偏向锁可以提高带有同步但无竞争的程序性能。


2.2 轻量级锁 (Lightweight Locking)— 适用于线程交替进入同步方法的情况


2.2.1 什么是轻量级锁

轻量级锁是JDK 6之中加入的新型锁机制,它名字中的“轻量级”是相对于使用monitor的传统锁而言的,因此传统的锁机制就称为“重量级”锁。首先需要强调一点的是:轻量级锁并不是用来代替重量级锁的。

引入轻量级锁的目的:在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级为重量级锁,所以轻量级锁的出现并非是要替代重量级锁。


2.2.2 轻量级锁加锁 + 撤销原理

【加锁】
当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:

  • (1)判断当前对象是否处于无锁状态( hashcode、 0、 01 ),如果是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录( Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),将对象的Mark Word复制到栈帧中的Lock Record中,将Lock Reocrd中的owner指向当前对象。
  • (2)JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00,执行同步操作。
  • (3)如果失败则判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态。

【撤销】
轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:

  • (1)取出在获取轻量级锁保存在Displaced Mark Word中的数据。
  • (2)用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功。
  • (3)如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要将轻量级锁膨胀升级为重量级锁。

【加锁+撤销过程原理图】
这里依旧借用《Java并发编程的艺术》中的图:☺☺☺。
在这里插入图片描述


2.2.3 轻量级锁的验证

感兴趣的自己试试吧,可能会获得意想不到的收获。。。


2.2.4 轻量级锁好处

对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。

在多线程交替执行同步块的情况下,可以避免重量级锁引起的性能消耗。


2.3 自旋


2.3.1 自旋锁

相信通过前面几篇文章的铺垫大家应该已经知道,使用monitor锁会调用内核函数对线程进行park 和unpark,即线程的park和unpark需要CPU进行用户态转和内核态的来回切换。频繁的park和unpark对CPU来说是一件负担很重的工作,这些操作会给系统的并发性能带来很大的压力。

同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间阻塞和唤醒线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们可以让线程执行一个死循环(自旋) , 这项技术就是所谓的自旋锁。—》 自旋锁在JDK 1.4.2中就已经引入 ,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK 1.6中就已经改为默认开启了。

自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好;反之,如果锁被占用的时间很长。那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,用户可以使用参数-XX : PreBlockSpin来更改 —》 但是这个自旋次数是很难判断的,因此jdk1.6引入了适应性自旋锁。


2.3.2 适应性自旋锁

在JDK1.6中还引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100次循环。

另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虛拟机就会变得越来越“聪明”了。


2.3.3 自旋在JDK(hotspot)中的源码

源码所处文件src/share/vm/runtime/objectMonitor.cpp

int ObjectMonitor::TrySpin_VaryDuration (Thread * Self) {
	//2.3.1中对应的自旋
    // Dumb, brutal spin.  Good for comparative measurements against adaptive spinning.
    int ctr = Knob_FixedSpin ;
    if (ctr != 0) {
        while (--ctr >= 0) {
            if (TryLock (Self) > 0) return 1 ;
            SpinPause () ;
        }
        return 0 ;
    }
	// 适应性自旋
	//Knob_PreSpin为自旋次数,默认为10,同时在注释中可以看到该值在20-100之内看起来更好
	//---》 下面我也给出了该变量定义的源码
    for (ctr = Knob_PreSpin + 1; --ctr >= 0 ; ) {
      if (TryLock(Self) > 0) {
        // Increase _SpinDuration ...
        // Note that we don't clamp SpinDuration precisely at SpinLimit.
        // Raising _SpurDuration to the poverty line is key.
        int x = _SpinDuration ;
        if (x < Knob_SpinLimit) {
           if (x < Knob_Poverty) x = Knob_Poverty ;
           _SpinDuration = x + Knob_BonusB ; //如果抢到锁,时间设置的比之前长一点,下次就可以不用循环那么多次了
        }
        return 1 ;
      }
      SpinPause () ;
    }
    //省略n行代码
}

自旋次数定义的源码:

static int Knob_PreSpin            = 10 ;      // 20-100 likely better

3 JDK1.6对synchronized关键字的其他优化简介


3.1 锁消除

锁消除是指虚拟机即时编译器( JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是程序员自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下还要要求同步呢?实际上有许多同步措施并不是程序员自己加入的,同步的代码在Java程序中的普遍程度也许超过了大部分读者的想象。

比如,下面这段非常简单的代码仅仅是输出3个字符串相加的结果,无论是源码字面上还是程序语义上都没有同步。

public class Demo01 {
    
    public static void main(String[] args) {
        contactString("aa", "bb", "cc");
    }
    public static String contactString(String s1, String s2, String s3) {
        return new StringBuffer().append(s1).append(s2).append(s3).toString();
    }
}

StringBuffer的append ( ) 是一个同步方法,锁就是this也就是(new StringBuilder())。虚拟机发现它的动态作用域被限制在concatString( )方法内部。也就是说, new StringBuilder()对象的引用永远不会“逃逸”到concatString ( )方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。


3.2 锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。

大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。比如如下代码:

class Demo02 {
    public static void main(String[] args) {
        StringBuffer sb = new StringBuffer();
        //StringBuffer是同步方法,
        // 其实没必要每次append都去判断锁相关的内容,可以将整个for循环搞成同步的 ---> JVM的锁粗化可能会直接帮你这样弄
        for (int i = 0; i < 100; i++) {
            sb.append("aa");
        }
        System.out.println(sb.toString());
    }
}

那什么是锁粗化,相信你肯定就明白了,给个定义如下:

JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样只需要加一次锁即可。


end

Copyright © 2010-2022 ngui.cc 版权所有 |关于我们| 联系方式| 豫B2-20100000