关于synchronized批量重偏向和批量撤销的一个小实验
前段时间学习synchronized的时候做过一个关于批量重偏向和批量撤销的小实验,感觉挺有意思的,所以想分享一下。虽然是比较底层的东西,但是结论可以通过做实验看出来,就挺有意思。
我们都知道synchronized分为偏向锁、轻量级锁和重量级锁这三种,这个实验主要是和偏向锁相关的。关于偏向锁,我们又知道,偏向锁在偏向了某一个线程之后,不会主动释放锁,只有出现竞争了才会执行偏向锁撤销。
先说结论吧,开启偏向锁时,在「规定的时间」内,如果偏向锁撤销的次数达到20次,就会执行批量重偏向,如果撤销次数达到了40次,就会触发批量撤销。批量重偏向和批量撤销都可以理解为虚拟机的一种优化机制,我理解主要是出于性能上的考虑。
当一个锁对象类的撤销次数达到20次时,虚拟机会认为这个锁不适合再偏向于原线程,于是会在偏向锁撤销达到20次时让这一类锁尝试偏向于其他线程。
当一个锁对象类的撤销次数达到40次时,虚拟机会认为这个锁根本就不适合作为偏向锁使用,因此会将类的偏向标记关闭,之后现存对象加锁时会升级为轻量级锁,锁定中的偏向锁对象会被撤销,新创建的对象默认为无锁状态。
下面先说明一下实验之前要准备的东西。首先,我们得开启偏向锁,关闭偏向锁的延迟启动。由于只有看到对象头的锁标志位才能判断锁的类型,因此还需要用到OpenJDK提供的JOL(Java Object Layout)包。由于下面讲解涉及到对象头的mark word,这里贴一张mark word的说明图。epoch可以理解为锁对象的年龄标记,利用JOL查看对象头时主要关注锁标志位即可。
锁对象mark word的低三位为001时,表示无锁且不可偏向,若为101则表示匿名偏向或偏向锁定状态(取决于是否有Thread ID)。锁对象类也有锁标志位的概念,作用和锁对象类似,我理解只是作用范围的区别。锁对象类若为不可偏向,所有新创建的对象都是不可偏向的。
实验代码包括39个锁对象和3个线程:T1、T2、T3,分三次执行加锁解锁操作,因此锁对象的状态的变化也分为三个阶段。
第一阶段是T1线程执行。T1线程执行后,由于是第一次加锁,因此所有对象都偏向于T1。
此时从对象头mark word可以看出,所有对象都处于偏向锁定状态(偏向于T1)。
05 70 51 19 (00000101 01110000 01010001 00011001) (424767493)
00 00 00 00 (00000000 00000000 00000000 00000000) (0)
第二阶段是T2线程执行。T2线程执行后,0~18号对象会执行偏向锁撤销,锁对象状态变化为:偏向锁->轻量级锁->无锁。偏向锁撤销执行到19号对象,也就是第20个锁对象时,会触发批量重偏向,此时19~38号对象会批量重偏向于T2。实际上此时只会修改类对象的epoch和处于加锁中的锁对象的epoch(也就是说不会重偏向处于使用中的锁对象),其他未处于加锁中的锁对象的重偏向则发生于下一次加锁时,判断条件是类对象epoch和锁对象epoch是否一致,不一致则会执行重偏向。T2退出同步代码后的最终结果就是0~18号对象变为无锁状态,19~38号对象偏向于T2,偏向锁撤销次数为20次。
此时从对象头mark word可以看出,0~18号对象处于无锁状态,19~38号对象则处于偏向锁定状态(偏向于T2)。
0~18号对象:
01 00 00 00 (00000001 00000000 00000000 00000000) (1)
00 00 00 00 (00000000 00000000 00000000 00000000) (0)
19~38号对象:
05 99 51 19 (00000101 10011001 01010001 00011001) (424777989)
00 00 00 00 (00000000 00000000 00000000 00000000) (0)
第三阶段是T3线程执行。此时0~18已经处于无锁状态,只能加轻量级锁。19~38号对象则有所不同,这20个对象执行时会逐个执行偏向锁撤销,到第38号对象时刚好又执行了20次,此时总的撤销次数到达40次,于是触发批量撤销。批量撤销会将类的偏向标记关闭,之后现存对象加锁时会升级为轻量级锁,锁定中的偏向锁对象会被撤销,新创建的对象默认为无锁状态。
此时从对象头mark word可以看出,0~37号对象处于无锁状态,38号对象也处于无锁状态(升级成轻量级锁后又解锁了)。
01 00 00 00 (00000001 00000000 00000000 00000000) (1)
00 00 00 00 (00000000 00000000 00000000 00000000) (0)
以上步骤是常规步骤,如果把「sleep 30s」部分的注释代码放开,事情就不一样了。
虚拟机的偏向锁实现里有两个很关键的东西:BiasedLockingDecayTime和revocation_count。
BiasedLockingDecayTime是开启一次新的批量重偏向距离上一次批量重偏向之后的延迟时间,默认为25000ms,这就是上面讲到的「规定的时间」。revocation_count是撤销计数器,会记录偏向锁撤销的次数。也就是说,在执行一次批量重偏向之后,经过了较长的一段时间(>=BiasedLockingDecayTime)之后,撤销计数器才超过阈值,则会重置撤销计数器。而是否执行批量重偏向和批量撤销正是依赖于撤销计数器的,sleep之后计数器被清零,本次不执行批量撤销,因此后续也就有机会继续执行批量重偏向。
根据以上知识可知,等待一段时间后撤销计数器会清零,因此不会再执行批量撤销,而是变成再次执行批量重偏向。此时T3加锁的过程就和上面有所不同了,0~18号对象已经变为无锁,因此这部分只能加轻量级锁。关键是19~38号对象,从19号对象开始又会执行偏向锁撤销,到38号对象时刚好20次,这就绕回常规情况下T2执行时的场景了,T2执行时19号对象是不是从偏向T1变成了偏向T2?所以这里从38号对象开始往后的其他对象都会从T2重新偏向T3。
这里的特性用虚拟机里面的话讲叫做「启发式更新」,我理解这样做主要是出于性能上的考虑。假如偏向锁只是偶尔会发生轮流加锁的这种竞争,虚拟机是允许的,20次以内随便你怎么玩,可以一直帮你执行偏向锁撤销。如果25秒内撤销次数超过20次了,还友情提供一次批量重偏向。但是假如线程间竞争很多,频繁执行偏向锁撤销和批量重偏向则可能会比较损耗性能,因此「规定的时间」内连续撤销超过一定次数(默认40次)虚拟机就不让你偏向了,这就是批量撤销的意义所在。
大概就这些。
实验代码:
import org.openjdk.jol.info.ClassLayout; import java.util.Vector; import java.util.concurrent.locks.LockSupport; /** * -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 -XX:+PrintCommandLineFlags */ public class TestBiased { static Thread T1, T2, T3; public static void main(String[] args) throws InterruptedException { test(); } private static void test() throws InterruptedException { Vector<Doge> list = new Vector<>(); int loopNumber = 39; T1 = new Thread(() -> { for (int i = 0; i < loopNumber; i++) { Doge d = new Doge(); list.add(d); synchronized (d) { System.out.println(i + "\t" + ClassLayout.parseInstance(d).toPrintable()); } } LockSupport.unpark(T2); }, "T1"); T1.start(); T2 = new Thread(() -> { LockSupport.park(); System.out.println("===============> "); for (int i = 0; i < loopNumber; i++) { Doge d = list.get(i); System.out.println(i + "\t" + ClassLayout.parseInstance(d).toPrintable()); synchronized (d) { System.out.println(i + "\t" + ClassLayout.parseInstance(d).toPrintable()); } System.out.println(i + "\t" + ClassLayout.parseInstance(d).toPrintable()); } //sleep 30s /*try { Thread.sleep(30000); } catch (InterruptedException e) { e.printStackTrace(); }*/ LockSupport.unpark(T3); }, "T2"); T2.start(); T3 = new Thread(() -> { LockSupport.park(); System.out.println("===============> "); for (int i = 0; i < loopNumber; i++) { Doge d = list.get(i); System.out.println(i + "\t" + ClassLayout.parseInstance(d).toPrintable()); synchronized (d) { System.out.println(i + "\t" + ClassLayout.parseInstance(d).toPrintable()); } System.out.println(i + "\t" + ClassLayout.parseInstance(d).toPrintable()); } }, "T3"); T3.start(); T3.join(); System.out.println(ClassLayout.parseInstance(new Doge()).toPrintable()); } } class Doge { }
参考资料:
https://www.bilibili.com/video/BV1jE411j7uX?p=85
https://blog.csdn.net/qq_36434742/article/details/106854061
https://github.com/farmerjohngit/myblog/issues/12