难搞的偏向锁终于被 Java 移除了(下)

简介: 难搞的偏向锁终于被 Java 移除了(下)

偏向撤销


在真正讲解偏向撤销之前,需要和大家明确一个概念——偏向锁撤销和偏向锁释放是两码事


  1. 撤销:笼统的说就是多个线程竞争导致不能再使用偏向模式的时候,主要是告知这个锁对象不能再用偏向模式


  1. 释放:和你的常规理解一样,对应的就是 synchronized 方法的退出或 synchronized 块的结束


何为偏向撤销?


从偏向状态撤回原有的状态,也就是将 MarkWord 的第 3 位(是否偏向撤销)的值, 从 1 变回 0


如果只是一个线程获取锁,再加上「偏心」的机制,是没有理由撤销偏向的,所以偏向的撤销只能发生在有竞争的情况下


想要撤销偏向锁,还不能对持有偏向锁的线程有影响,所以就要等待持有偏向锁的线程到达一个 safepoint 安全点 (这里的安全点是 JVM 为了保证在垃圾回收的过程中引用关系不会发生变化设置的一种安全状态,在这个状态上会暂停所有线程工作), 在这个安全点会挂起获得偏向锁的线程


在这个安全点,线程可能还是处在不同状态的,先说结论(因为源码就是这么写的,可能有疑惑的地方会在后面解释)


  1. 线程不存活或者活着的线程但退出了同步块,很简单,直接撤销偏向就好了


  1. 活着的线程但仍在同步块之内,那就要升级成轻量级锁


这个和 epoch 貌似还是没啥关系,因为这还不是全部场景。偏向锁是特定场景下提升程序效率的方案,可并不代表程序员写的程序都满足这些特定场景,比如这些场景(在开启偏向锁的前提下):


  1. 一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种case下,会导致大量的偏向锁撤销操作


  1. 明知有多线程竞争(生产者/消费者队列),还要使用偏向锁,也会导致各种撤销


很显然,这两种场景肯定会导致偏向撤销的,一个偏向撤销的成本无所谓,大量偏向撤销的成本是不能忽视的。那怎么办?既不想禁用偏向锁,还不想忍受大量撤销偏向增加的成本,这种方案就是设计一个有阶梯的底线


批量重偏向(bulk rebias)


这是第一种场景的快速解决方案,以 class 为单位,为每个 class 维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器 +1,当这个值达到重偏向阈值(默认20)时:


BiasedLockingBulkRebiasThreshold = 20


JVM 就认为该class的偏向锁有问题,因此会进行批量重偏向, 它的实现方式就用到了我们上面说的 epoch

Epoch,如其含义「纪元」一样,就是一个时间戳。每个 class 对象会有一个对应的epoch字段,每个处于偏向锁状态对象mark word 中也有该字段,其初始值为创建该对象时 class 中的epoch的值(此时二者是相等的)。每次发生批量重偏向时,就将该值加1,同时遍历JVM中所有线程的栈


  1. 找到该 class 所有正处于加锁状态的偏向锁对象,将其epoch字段改为新值


  1. class 中不处于加锁状态的偏向锁对象(没被任何线程持有,但之前是被线程持有过的,这种锁对象的 markword 肯定也是有偏向的),保持 epoch 字段值不变


这样下次获得锁时,发现当前对象的epoch值和class的epoch,本着今朝不问前朝事 的原则(上一个纪元),那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过 CAS 操作将其mark word的线程 ID 改成当前线程 ID,这也算是一定程度的优化,毕竟没升级锁;

如果 epoch 都一样,说明没有发生过批量重偏向, 如果 markword 有线程ID,还有其他锁来竞争,那锁自然是要升级的(如同前面举的例子 epoch=0)


批量重偏向是第一阶梯底线,还有第二阶梯底线


批量撤销(bulk revoke)


当达到重偏向阈值后,假设该 class 计数器继续增长,当其达到批量撤销的阈值后(默认40)时,


BiasedLockingBulkRevokeThreshold = 40


JVM就认为该 class 的使用场景存在多线程竞争,会标记该 class 为不可偏向。之后对于该 class 的锁,直接走轻量级锁的逻辑


这就是第二阶梯底线,但是在第一阶梯到第二阶梯的过渡过程中,也就是在彻底禁用偏向锁之前,还给一次改过自新的机会,那就是另外一个计时器:


BiasedLockingDecayTime = 25000


  1. 如果在距离上次批量重偏向发生的 25 秒之内,并且累计撤销计数达到40,就会发生批量撤销(偏向锁彻底 game over)


  1. 如果在距离上次批量重偏向发生超过 25 秒之外,那么就会重置在 [20, 40) 内的计数, 再给次机会


大家有兴趣可以写代码测试一下临界点,观察锁对象 markword 的变化


至此,整个偏向锁的工作流程可以用一张图表示:


微信图片_20220512124511.png


到此,你应该对偏向锁有个基本的认识了,但是我心中的好多疑问还没有解除,咱们继续看:


HashCode 哪去了


上面场景一,无锁状态,对象头中没有 hashcode;偏向锁状态,对象头还是没有 hashcode,那我们的 hashcode 哪去了?


首先要知道,hashcode 不是创建对象就帮我们写到对象头中的,而是要经过第一次调用 Object::hashCode() 或者System::identityHashCode(Object) 才会存储在对象头中的。第一次生成的 hashcode后,该值应该是一直保持不变的,但偏向锁又是来回更改锁对象的 markword,必定会对 hashcode 的生成有影响,那怎么办呢?,我们来用代码验证:


场景一


    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);
        Object o = new Object();
        log.info("未生成 hashcode,MarkWord 为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());
        o.hashCode();
        log.info("已生成 hashcode,MarkWord 为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o){
            log.info(("进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }


来看运行结果


微信图片_20220512124604.png


结论就是:即便初始化为可偏向状态的对象,一旦调用 Object::hashCode() 或者 System::identityHashCode(Object) ,进入同步块就会直接使用轻量级锁


场景二


假如已偏向某一个线程,然后生成 hashcode,然后同一个线程又进入同步块,会发生什么呢?来看代码:


    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);
        Object o = new Object();
        log.info("未生成 hashcode,MarkWord 为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o){
            log.info(("进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
        o.hashCode();
        log.info("生成 hashcode");
        synchronized (o){
            log.info(("同一线程再次进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }


查看运行结果:


微信图片_20220512124652.png


结论就是:同场景一,会直接使用轻量级锁


场景三


那假如对象处于已偏向状态,在同步块中调用了那两个方法会发生什么呢?继续代码验证:


    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);
        Object o = new Object();
        log.info("未生成 hashcode,MarkWord 为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o){
            log.info(("进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
            o.hashCode();
            log.info("已偏向状态下,生成 hashcode,MarkWord 为:");
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }


来看运行结果:


微信图片_20220512124733.png


结论就是:如果对象处在已偏向状态,生成 hashcode 后,就会直接升级成重量级锁


最后用书中的一段话来描述 锁和hashcode 之前的关系


微信图片_20220512124809.png


调用 Object.wait() 方法会发生什么?


Object 除了提供了上述 hashcode 方法,还有 wait() 方法,这也是我们在同步块中常用的,那这会对锁产生哪些影响呢?来看代码:


    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);
        Object o = new Object();
        log.info("未生成 hashcode,MarkWord 为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o) {
            log.info(("进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
            log.info("wait 2s");
            o.wait(2000);
            log.info(("调用 wait 后,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }


查看运行结果:


微信图片_20220512124908.png


结论就是,wait 方法是互斥量(重量级锁)独有的,一旦调用该方法,就会升级成重量级锁(这个是面试可以说出的亮点内容哦)


最后再继续丰富一下锁对象变化图:


微信图片_20220512124932.png


告别偏向锁


看到这个标题你应该是有些慌,为啥要告别偏向锁,因为维护成本有些高了,来看 Open JDK 官方声明,JEP 374: Deprecate and Disable Biased Locking,相信你看上面的文字说明也深有体会


微信图片_20220512125003.png


这个说明的更新时间距离现在很近,在 JDK15 版本就已经开始了


微信图片_20220512125041.png


一句话解释就是维护成本太高


微信图片_20220512125101.png


微信图片_20220512125113.png

最终就是,JDK 15 之前,偏向锁默认是 enabled,从 15 开始,默认就是 disabled,除非显示的通过 UseBiasedLocking 开启


其中在 quarkus 上的一篇文章说明的更加直接


微信图片_20220512125132.png


偏向锁给 JVM 增加了巨大的复杂性,只有少数非常有经验的程序员才能理解整个过程,维护成本很高,大大阻碍了开发新特性的进程(换个角度理解,你掌握了,是不是就是那少数有经验的程序员了呢?哈哈)


总结


偏向锁可能就这样的走完了它的一生,有些同学可能直接发问,都被 deprecated 了,JDK都 17 了,还讲这么多干什么?


  1. java 任它发,我用 Java8,这是很多主流的状态,至少你用的版本没有被 deprecated


  1. 面试还是会被经常问到


  1. 万一哪天有更好的设计方案,“偏向锁”又以新的形式回来了呢,了解变化才能更好理解背后设计


  1. 奥卡姆剃刀原理,我们现实中的优化也一样,如果没有必要不要增加实体,如果增加的内容带来很大的成本,不如大胆的废除掉,接受一点落差


之前对于偏向锁我也只是单纯的理论认知,但是为了写这篇文章,我翻阅了很多资料,包括也重新查看 Hotspot 源码,说的这些内容也并不能完全说明偏向锁的整个流程细节,还需要大家具体实践追踪查看,这里给出源码的几个关键入口,方便大家追踪:


  1. 偏向锁入口: http://hg.openjdk.java.net/jd...


  1. 偏向撤销入口:http://hg.openjdk.java.net/jd...


  1. 偏向锁释放入口:http://hg.openjdk.java.net/jd...


文中有疑问的地方欢迎留言讨论,有错误的地方还请大家帮忙指正


灵魂追问


  1. 轻量级和重量级锁,hashcode 存在了什么位置?




相关文章
|
2月前
|
安全 Java 调度
Java编程时多线程操作单核服务器可以不加锁吗?
Java编程时多线程操作单核服务器可以不加锁吗?
40 2
|
15天前
|
Java
Java 中锁的主要类型
【10月更文挑战第10天】
|
2月前
|
存储 缓存 安全
【Java面试题汇总】多线程、JUC、锁篇(2023版)
线程和进程的区别、CAS的ABA问题、AQS、哪些地方使用了CAS、怎么保证线程安全、线程同步方式、synchronized的用法及原理、Lock、volatile、线程的六个状态、ThreadLocal、线程通信方式、创建方式、两种创建线程池的方法、线程池设置合适的线程数、线程安全的集合?ConcurrentHashMap、JUC
【Java面试题汇总】多线程、JUC、锁篇(2023版)
|
16天前
|
安全 Java 开发者
java的synchronized有几种加锁方式
Java的 `synchronized`通过上述三种加锁方式,为开发者提供了从粗粒度到细粒度的并发控制能力,满足了不同场景下的线程安全需求。合理选择加锁方式对于提升程序的并发性能和正确性至关重要,开发者应根据实际应用场景的特性和性能要求来决定使用哪种加锁策略。
10 0
|
2月前
|
算法 Java 关系型数据库
Java中到底有哪些锁
【9月更文挑战第24天】在Java中,锁主要分为乐观锁与悲观锁、自旋锁与自适应自旋锁、公平锁与非公平锁、可重入锁以及独享锁与共享锁。乐观锁适用于读多写少场景,通过版本号或CAS算法实现;悲观锁适用于写多读少场景,通过加锁保证数据一致性。自旋锁与自适应自旋锁通过循环等待减少线程挂起和恢复的开销,适用于锁持有时间短的场景。公平锁按请求顺序获取锁,适合等待敏感场景;非公平锁性能更高,适合频繁加解锁场景。可重入锁支持同一线程多次获取,避免死锁;独享锁与共享锁分别用于独占和并发读场景。
|
16天前
|
Java 应用服务中间件 测试技术
Java21虚拟线程:我的锁去哪儿了?
【10月更文挑战第8天】
23 0
|
2月前
|
Java 数据库
JAVA并发编程-一文看懂全部锁机制
曾几何时,面试官问:java都有哪些锁?小白,一脸无辜:用过的有synchronized,其他不清楚。面试官:回去等通知! 今天我们庖丁解牛说说,各种锁有什么区别、什么场景可以用,通俗直白的分析,让小白再也不怕面试官八股文拷打。
|
2月前
|
安全 Java 开发者
Java并发编程中的锁机制解析
本文深入探讨了Java中用于管理多线程同步的关键工具——锁机制。通过分析synchronized关键字和ReentrantLock类等核心概念,揭示了它们在构建线程安全应用中的重要性。同时,文章还讨论了锁机制的高级特性,如公平性、类锁和对象锁的区别,以及锁的优化技术如锁粗化和锁消除。此外,指出了在高并发环境下锁竞争可能导致的问题,并提出了减少锁持有时间和使用无锁编程等策略来优化性能的建议。最后,强调了理解和正确使用Java锁机制对于开发高效、可靠并发应用程序的重要性。
26 3
|
2月前
|
Oracle Java 关系型数据库
【颠覆性升级】JDK 22:超级构造器与区域锁,重塑Java编程的两大基石!
【9月更文挑战第6天】JDK 22的发布标志着Java编程语言在性能和灵活性方面迈出了重要的一步。超级构造器和区域锁这两大基石的引入,不仅简化了代码设计,提高了开发效率,还优化了垃圾收集器的性能,降低了应用延迟。这些改进不仅展示了Oracle在Java生态系统中的持续改进和创新精神,也为广大Java开发者提供了更多的可能性和便利。我们有理由相信,在未来的Java编程中,这些新特性将发挥越来越重要的作用,推动Java技术不断向前发展。
|
2月前
|
安全 Java API
Java线程池原理与锁机制分析
综上所述,Java线程池和锁机制是并发编程中极其重要的两个部分。线程池主要用于管理线程的生命周期和执行并发任务,而锁机制则用于保障线程安全和防止数据的并发错误。它们深入地结合在一起,成为Java高效并发编程实践中的关键要素。
28 0