关于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 + "	" + 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 + "	" + ClassLayout.parseInstance(d).toPrintable());

                synchronized (d) {
                    System.out.println(i + "	" + ClassLayout.parseInstance(d).toPrintable());
                }
                System.out.println(i + "	" + 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 + "	" + ClassLayout.parseInstance(d).toPrintable());
                synchronized (d) {
                    System.out.println(i + "	" + ClassLayout.parseInstance(d).toPrintable());
                }
                System.out.println(i + "	" + 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

原文地址:https://www.cnblogs.com/shen-smile/p/14012164.html