杠精学synchronized(2)-锁优化

339 阅读7分钟

锁优化的手段

synchronized根据实践情况以及理论情况, 做了非常多的锁升级的控制, 让加锁解锁的开销不至于那么重量级, 但是这样jvm还认为做的不够, 还增加了很多锁优化的手段, 本篇就介绍一下这些手段, 有些手段还是比较有趣, 甚至说是比较难以理解的.

从偏向锁的加锁解锁过程可以看出, 当只有一个线程反复进入同步块时, 偏向锁带来的性能开销基本可以忽略, 但是当有其它线程尝试获得锁时, 就需要等到gc的safe point时, 再将偏向锁撤销为无锁状态或者升级为轻量级, 会消耗一定的性能, 所以在多线程竞争频繁的情况下, 偏向锁不仅不能提升性能, 还会导致性能下降, 于是就有了批量重偏向批量偏向锁撤销的机制.

批量重偏向和偏向锁批量撤销

  • 批量重偏向 以class为单位, 为每个class维护一个偏向锁撤销的计数器, 每一次该class的对象发生偏向锁撤销操作时, 该计数器+1, 当这个值达到重偏向阈值(默认20, 本人实际测试为17次)时, jvm就认为该class的偏向锁有问题, 会进行批量重偏向.

  • 批量撤销 当已经执行过一次批量重偏向, 但是之后又有其它线程一直使得重偏向这个线程的偏向锁撤销次数达到阈值(默认40, 本人实际测试36), 那么就会将这个锁对应的类的对象(包括后续创建的)取消偏向锁的可能.

启动时添加jvm参数-XX:+PrintFlagsFinal即可查看该阈值:

intx BiasedLockingBulkRebiasThreshold          = 20 //默认偏向锁批量重偏向阈值
intx BiasedLockingBulkRevokeThreshold          = 40 //默认偏向锁批量撤销阈值

上面说的比较概括, 下面通过代码来看看现象, 看完现象再着重解释一下.

// 批量重偏向和偏向锁批量撤销的代码演示
public static void main(String[] args) throws Exception {
    TimeUnit.SECONDS.sleep(5);
    List<Object> locks = new ArrayList<>();
    new Thread(() -> {
        for (int i = 0; i < 40; i++) {
            Object lock = new Object();
            synchronized (lock) {
                // 让这40个lock对象都偏向thread1, 并且保存至locks全局列表中
                locks.add(lock);
            }
        }
        System.out.println("已经将40个lock对象全部偏向于[" + Thread.currentThread().getName() + "], 打印一下mark word");
        System.out.println(MyClassLayOut.getMarkWord(locks.get(0)) + "\n");
        try {
            TimeUnit.SECONDS.sleep(15);
        } catch (InterruptedException e) {
        }
    }, "thread1").start();
    // 等待足够长的时间thread1操作执行完
    TimeUnit.SECONDS.sleep(3);
    // 创建线程依次去对这40个偏向thread1线程的对象撤销偏向, 并升级为轻量级锁, 看看能否达到预期
    new Thread(() -> {
        for (int i = 0; i < 40; i++) {
            Object lock = locks.get(i);
            synchronized (lock) {
                if (i >= 15 && i <= 17) {
                    System.out.println("打印[" + Thread.currentThread().getName() + "]再次锁定第(" + (i + 1) + ")个lock对象时的mark word\n" + MyClassLayOut.getMarkWord(lock) + "\n");
                }
            }
        }
        try {
            TimeUnit.SECONDS.sleep(15);
        } catch (InterruptedException e) {
        }
    }, "thread2").start();
    TimeUnit.SECONDS.sleep(8);
    new Thread(() -> {
        for (int i = 0; i < 40; i++) {
            Object lock = locks.get(i);
            synchronized (lock) {
                if (i > 33 && i < 37) {
                    Object obj = new Object();
                    System.out.println("打印[" + Thread.currentThread().getName() + "]第(" + (i + 1) + ")次导致偏向于[thread2]的偏向锁撤销后, 新建Object对象的mark word\n" + MyClassLayOut.getMarkWord(obj) + "\n");
                }
            }
        }
    }, "thread3").start();
}
运行结果:
已经将40个lock对象全部偏向于[thread1], 打印一下mark word
05 30 13 9f (00000101 00110000 00010011 10011111) (-1626132475)
fa 01 00 00 (11111010 00000001 00000000 00000000) (506)

打印[thread2]再次锁定第(16)个lock对象时的mark word
b0 f1 0f 6a (10110000 11110001 00001111 01101010) (1779429808)
97 00 00 00 (10010111 00000000 00000000 00000000) (151)

打印[thread2]再次锁定第(17)个lock对象时的mark word
05 09 47 9f (00000101 00001001 01000111 10011111) (-1622734587)
fa 01 00 00 (11111010 00000001 00000000 00000000) (506)

打印[thread2]再次锁定第(18)个lock对象时的mark word
05 09 47 9f (00000101 00001001 01000111 10011111) (-1622734587)
fa 01 00 00 (11111010 00000001 00000000 00000000) (506)

打印[thread3]第(35)次导致偏向于[thread2]的偏向锁撤销后, 新建Object对象的mark word
05 01 00 00 (00000101 00000001 00000000 00000000) (261)
00 00 00 00 (00000000 00000000 00000000 00000000) (0)

打印[thread3]第(36)次导致偏向于[thread2]的偏向锁撤销后, 新建Object对象的mark word
01 00 00 00 (00000001 00000000 00000000 00000000) (1)
00 00 00 00 (00000000 00000000 00000000 00000000) (0)

打印[thread3]第(37)次导致偏向于[thread2]的偏向锁撤销后, 新建Object对象的mark word
01 00 00 00 (00000001 00000000 00000000 00000000) (1)
00 00 00 00 (00000000 00000000 00000000 00000000) (0)

分析一下输出结果:

thread1: 在thread1线程的run方法中, 循环构造了40个Object对象, 并全部进行synchronized, 致使40个Object对象将会全部偏向于thread1, 并将这40个Object对象全部add到locks列表中去.

thread2: 在thread2线程的run方法中, 依次遍历locks列表, 拿到每个Object对象, 并对其进行synchronized, 从锁升级的理论来讲, 这个操作将使得locks这个列表中的40个Object对象全部升级为轻量级锁状态, 但是事实上并没有, 发现前16个Object对象被synchronized之后, 确实升级为了轻量级锁(状态00), 但是第17个Object对象以后, 进入synchronized之后, 对象锁状态变成了偏向锁状态(101), 偏向线程也改变为了thread2, 明显不是thread1的偏向线程, 甚至epoch字段也变成了01.

thread3: 在thread3的线程中, 又依次对locks列表遍历, 拿到每个Object对象, 并对其进行synchronized. 在前35次synchronized中, 创建一个Object对象, 发现该对象的初始状态都是可偏向状态(101, 线程id为0, epoch为01), 但是从第36次开始, 新建的Object对象就不再是可偏向状态了, 而是无锁状态(001).

上述验证表明: 确实是存在批量重偏向偏向锁批量撤销的.

原理

以class为单位, 为每个class维护一个偏向锁撤销计数器, 每一次该class的对象发生偏向锁撤销时, 该计数器+1, 当这个值达到重偏向阈值(默认20, 本人测试为17)时, jvm就认为该class的偏向锁有问题, 因此会进行批量重偏向.

每个class对象在c语言中都会有一个对应的epoch字段, 每个处于偏向锁状态对象的mark word中也会有该字段. 其初始值为创建该对象时class中的epoch值. 每次发生批量重偏向时, 就将epoch+1, 同时遍历jvm中所有线程的栈, 找到该class正处于加锁状态的偏向锁(未出代码块, 且代码块所用的锁是该class的对象, 且当前状态为偏向锁[101]状态), 将其epoch字段改为新值. 下次获得锁时, 发现当前对象的epoch值和class的epoch不等, 那就算当前已经偏向了其它线程, 也不会执行撤销操作, 而是直接通过cas操作将其mark word的ThreadID改成当前线程id(前提是当前线程是重偏向的那个线程, 否则将会是轻量级锁, 见下方代码演示).

当达到重偏向阈值(默认20)后, 假设该class的计数器继续增长, 当其达到批量撤销的阈值(默认40)后, jvm就认为该class的使用场景存在多线程竞争, 会标记该class为不可偏向, 之后对于该class创建的新对象, 也会是无锁状态(001), 后续对之的加锁也将会直接是轻量级锁.

// 演示正处于同步块中的偏向锁是否会改变epoch
public static void main(String[] args) throws Exception {
    TimeUnit.SECONDS.sleep(5);
    List<Object> locks = new ArrayList<>();
    new Thread(() -> {
        for (int i = 0; i < 40; i++) {
            Object lock = new Object();
            synchronized (lock) {
                // 让这40个lock对象都偏向thread1, 并且保存至locks全局列表中
                locks.add(lock);
            }
        }
        System.out.println("已经将40个lock对象全部偏向于[" + Thread.currentThread().getName() + "], 打印一下mark word");
        System.out.println(MyClassLayOut.getMarkWord(locks.get(0)) + "\n");
        synchronized (locks.get(38)) {
            try {
                System.out.println("延长第39个偏向锁的偏向时间, 代码块睡眠40秒保证发生批量重偏向时该对象还处于同步块中\n");
                TimeUnit.SECONDS.sleep(40);
            } catch (InterruptedException e) {
            }
        }
        try {
            TimeUnit.SECONDS.sleep(40);
        } catch (InterruptedException e) {
        }
    }, "thread1").start();
    // 等待足够长的时间thread1操作执行完
    TimeUnit.SECONDS.sleep(3);
    // 制造批量重偏向, 然后在批量重偏向之前和之后, 分别打印第39个被延长偏向同步块的对象的对象头
    new Thread(() -> {
        for (int i = 0; i < 37; i++) {
            Object lock = locks.get(i);
            synchronized (lock) {
                if (i == 14) {
                    System.out.println("打印批量重偏向之前第39个lock对象的mark word\n" + MyClassLayOut.getMarkWord(locks.get(38)) + "\n");
                }
                if (i == 36) {
                    System.out.println("打印批量重偏向之后第39个lock对象的mark word\n" + MyClassLayOut.getMarkWord(locks.get(38)) + "\n");
                }
            }
        }
        try {
            TimeUnit.SECONDS.sleep(15);
        } catch (InterruptedException e) {
        }
    }, "thread2").start();
    // 等待45秒确保上面的40秒睡眠结束, 否则无论如何也将会升级为重量级锁
    TimeUnit.SECONDS.sleep(45);
    new Thread(()->{
        synchronized (locks.get(38)){
            System.out.println("打印第39个lock对象被thread3加锁后的mark word\n" + MyClassLayOut.getMarkWord(locks.get(38)) + "\n");
        }
    }, "thread3").start();
}
运行结果:
已经将40个lock对象全部偏向于[thread1], 打印一下mark word
05 a0 80 fe (00000101 10100000 10000000 11111110) (-25124859)
ab 01 00 00 (10101011 00000001 00000000 00000000) (427)

延长第39个偏向锁的偏向时间, 代码块睡眠40秒保证发生批量重偏向时该对象还处于同步块中

打印批量重偏向之前第39个lock对象的mark word
05 a0 80 fe (00000101 10100000 10000000 11111110) (-25124859)
ab 01 00 00 (10101011 00000001 00000000 00000000) (427)

打印批量重偏向之后第39个lock对象的mark word
05 a1 80 fe (00000101 10100001 10000000 11111110) (-25124603)
ab 01 00 00 (10101011 00000001 00000000 00000000) (427)

打印第39个lock对象被[thread3]加锁后的mark word
70 ee 2f 7f (01110000 11101110 00101111 01111111) (2133847664)
69 00 00 00 (01101001 00000000 00000000 00000000) (105)

第39个对象一开始是偏向于thread1, 将其偏向锁所在同步块时间延长, 在thread2运行过程中, 发生批量重偏向前后, 发现该对象的epoch确实发生了变化, 从00变成了01, 并且在thread1释放偏向锁后, 让thread3进行加锁, 发现升级为了轻量级锁, 而在前面演示批量重偏向的代码中, 我们使用thread2进行加锁的结果是偏向锁, 这是因为thread2是批量重偏向决定偏向的线程. 由此证明了上述的第二条原理.

存在的意义

批量重偏向: 一个线程创建了大量对象, 并执行了初始的同步操作, 后来一个线程也来将这些对象作为锁对象进行操作, 这样会导致大量的偏向锁撤销, 这是很影响性能的, 所以jvm会引入"认为该class的对象全都偏向错了"的理由, 使得该类的所有对象重新偏向另一个线程.

偏向锁批量撤销: 在明显的多线程交互执行, 甚至说是明显的激烈竞争情况下, 对该类的对象继续保持新建对象可偏向, 明显是不合适的, 因为未来非常可能新的对象也是一直在被撤销偏向, 因此索性将该类的对象/新建对象全部撤销偏向许可, 防止不断地撤销偏向影响性能.

总结

  1. 批量重偏向和批量撤销是针对类的优化, 和对象无关.
  2. 偏向锁批量重偏向一次之后, 再也不会偏向了.
  3. 当某个类已经触发偏向锁批量撤销机制以后, jvm会默认当前类产生了严重的问题, 剥夺了该类的新建对象使用偏向锁的权利.

自旋优化

重量级锁竞争的时候, 将还会使用自旋优化. 如果当前线程自旋成功(即使这时候持锁线程已经退出了同步块, 释放了锁), 这时当前线程就可以避免阻塞.

  • 自旋会占用cpu时间, 单核cpu自旋就是浪费, 多核cpu自旋才能发挥优势.

  • 在java6之后自旋是自适应的, 比如对象刚刚的一次自旋操作成功过, 那么认为这次自旋成功的可能性会很高, 就多自旋几次, 反制就少自旋甚至不自旋, 比较智能.

  • java7之后不能控制是否开启自旋功能.

注意: 自旋的目的是为了减少线程挂起的次数, 尽量避免直接挂起线程(挂起操作涉及系统调用, 存在用户态和内核态的切换, 这才是重量级锁最大的开销)

误区: 轻量级锁会自旋

轻量级锁是不会自旋的, 轻量级锁的出现分为两种情况:

  1. 锁对象处于已偏向状态(101, 线程id不为空, 但是并不在同步块中), 将会判断该情况, 进行升级为轻量级锁.

  2. 锁对象处于无锁状态(001), 尝试cas, 将mark word设置为lock record指针, 一旦这个cas失败, 将会直接进入重量级锁的升级过程.

以上两种情况都是分别判断, 并不是通过自旋实现的.

源码证明: image.png