Java并发编程:浅谈偏向锁、轻量级锁、自旋优化、锁膨胀、锁重入机制

一、介绍

1、

     我们知道,在java中可以通过Synchronized对对象加锁、其原理是通过对对象内部的一个叫做监视器(monitor)的来实现的,但是线程之间的切换是需要操作系统通过从用户态转成核心态来实现的,状态之间的转换需要比较长的时间,这就是为什么synchronized效率比较低的原因。Java从JDK6开始引入了“轻量级锁”和“偏向锁”来减少频繁的锁释放和锁获得所带来的性能上的消耗,所以在jdk6中,锁一共有4种状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。这些锁会随着线程的竞争而膨胀升级(不能降级)。

2、Java对象头

    Java的对象由三部分组成:①对象头②实例数据(对象的属性和其对应的值)③对其填充字节(补齐对象大小满足jvm要求对象占用空间为8bit倍数的要求)。

    其中,对象头又由三部分组成:①Mark Word②指向类的指针③数组对象(只有数组对象才有)

这也是为什么int只占4位(32bits),而Integer占12位(96bits)的原因。

在Mark word中记录了和锁有关的信息,下图是mark word的结构:

可以看到:无锁状态和偏向锁状态的锁标志位(state)都是01,区别在于前一个位的是否偏向锁数值(biased_lock),轻量级锁和重量级锁的标志位分别为00和10,当一个对象没有被加锁时,它的Mark Word记录的是它的hashcode。

二、轻量级锁

1、适用场景:多线程交替访问执行(没有竞争)。

2、轻量级锁加锁过程:

①、创建锁记录对象:每一个线程的栈帧都会有一个锁记录结构,其内部可以存储该线程锁定对象的Mark Word

②、让锁记录指向锁对象,并尝试用CAS操作替换掉对象中的Mark Word,并将Mark Word的值存到锁记录里面。

如果CAS替换成功,对象头中储存的是锁记录地址和锁状态00,表示Thread-0给该对象加锁

如果CAS替换失败,将分为以下两种情况:

第一种:其他线程已经抢先对这个对象加了锁,此时将会出现竞争,进入锁膨胀过程。

第二种:Thread-0自己执行了synchronized锁重入,那么会再添加一条锁记录(Lock Record)作为锁重入的计数。

3、锁重入&轻量级锁解锁过程:

如果有锁重入(有取值为null的锁记录):

重置锁记录,锁重入记数减一,当锁记录的值不为null时将进行下面的步骤:

①、用CAS操作把线程中原本对象的Mark Word替换掉当前对象的Mark Word(此时是锁记录地址和锁状态)。

②、如果CAS操作成功,那么整个过程走完。

如果CAS失败,说明有其他线程尝试对该对象加过锁,此时锁已经膨胀,Thread-0将在释放锁的同时,唤醒正等待的线程。

4、锁膨胀:

定义:线程在尝试对一个对象加轻量级锁的过程中,如果CAS操作无法成功则表明有其他线程已经为此对象加上了轻量级锁,此时将进行锁膨胀,将轻量级锁变成重量级锁。

锁膨胀将为存在竞争的对象申请上文提到的监视器锁(monitor),让该对象指向重量级锁的地址,膨胀后的关系如下:

当Thread-0执行完执行后解锁时,照样会进行CAS操作把Mark Word的值恢复给对象头,但是此时对象中原本属于对象头的位置已经被替换成了monitor地址,所以会按照Monitor地址找到该对象,将Owner置为null,并唤醒EntryList中正阻塞的线程Thread-1。

5、自旋优化:

也称适应性自旋,在竞争重量级锁的时候,为了不让当前线程进入阻塞状态,其会尝试使用自旋的方式来获取锁(也就是采用循环的方式来获取锁),如果在自旋的过程中,持有锁的线程退出了同步块释放了锁,这个时候当前线程就可以避免阻塞,直接加锁。

不过自旋是需要消耗CPU性能的,如果一个线程一直获取不到锁,那么它会一直自旋,一直浪费CPU资源。所以在JDK6中自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么线程会认为这次自旋成功的可能性会高一些,就会多自旋几次,反之将少自旋甚至不自旋。

由于自旋会占用CPU资源,所以在多核CPU的前提下,自旋才能发挥优势。

三、偏向锁

1、

偏向锁是对轻量级锁的优化。

轻量级锁在没有竞争时每次重入仍然需要进行CAS操作,线程只有第一次使用CAS将线程ID(ThreadID)设置到对象的Mark Word头,之后发现Mark Word里的线程ID是自己的就表示没有竞争,不用重新CAS(即以后只要不发生竞争,这个对象就归该线程所有,如果发生竞争,将升级成轻量级锁)。

2、偏向锁的获取过程:

①、确认可偏向状态:线程查看对象Mark Word中锁标志位(state)是否为01,偏向锁数值(biased_lock)是否为1.

②、如果是可偏向状态,则继续查看该对象头里的线程ID(ThreadID)是否指向当前线程:如果是:执行⑤,如果不是:执行③。

③、通过CAS操作进行竞争,如果竞争成功,则将Mark Word中的线程ID设置为当前线程ID,然后执行⑤,如果竞争失败,则执行④。

④、如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。

⑤、执行同步代码块。

3、偏向状态撤销

情况1:一个已经偏向了的对象调用了hashcode方法,会撤销其偏向状态,因为在偏向状态下,对象的Mark Word里存放的是ThreadID。

情况2:其他线程使用偏向锁对象时会将偏向锁升级为轻量级锁。

情况3:调用一些只有重量级锁才有的方法,会将偏向锁升级为重量级锁。(如wait/notify)

4、批量重偏向

如果一个对象被多个线程访问,但没有竞争(持有偏向锁的线程已不存活或者没有在执行同步代码块中的代码),这个时候该对象就有机会重新偏向新的线程,对象的重偏向将重置ThreadID。

JVM会以类(class)为单位,为每一个类建立一个偏向锁撤销计数器,每当这个类中的对象发生一次偏向撤销操作的时候,该计数器会+1,当一个类的重偏向次数达到阈值(默认是20)的时候,JVM会认为该类的偏向锁有问题,于是在给这些对象加锁的时候重新偏向至加锁线程。

5、批量撤销

当偏向锁撤销计数器超过40次以后,JVM会觉得该类根本就不该偏向,于是整个类的所有对象都会变为不可偏向的(包括新建的对象)。

四、小结

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法仅存在纳秒级别的差距。 如果线程之间存在锁竞争,会带来额外的锁撤销消耗。 只有一个线程访问同步代码块的场景。
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度。 如果始终得不到锁竞争的线程使用自旋会消耗CPU资源。 追求响应时间,同步代码块执行速度非常快。
重量级锁 线程竞争不使用自旋,不会消耗CPU资源。 未持有锁的线程会阻塞,响应时间缓慢。 追求吞吐量,同步代码块执行速度较慢。


图片来源:https://www.bilibili.com/video/BV16J411h7Rd

https://segmentfault.com/a/1190000023665056

参考:https://www.cnblogs.com/paddix/p/5405678.html

猜你喜欢

转载自blog.csdn.net/qq_41834553/article/details/112572952