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

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

背景


在 JDK1.5 之前,面对 Java 并发问题, synchronized 是一招鲜的解决方案:


  1. 普通同步方法,锁上当前实例对象


  1. 静态同步方法,锁上当前类 Class 对象


  1. 同步块,锁上括号里面配置的对象


拿同步块来举例:


public void test(){
  synchronized (object) {
    i++;
  }
}


经过 javap -v 编译后的指令如下:


微信图片_20220512123314.png


monitorenter 指令是在编译后插入到同步代码块的开始位置;monitorexit是插入到方法结束和异常的位置(实际隐藏了try-finally),每个对象都有一个 monitor 与之关联,当一个线程执行到 monitorenter 指令时,就会获得对象所对应的 monitor 的所有权,也就获得到了对象的锁


当另外一个线程执行到同步块的时候,由于它没有对应 monitor 的所有权,就会被阻塞,此时控制权只能交给操作系统,也就会从 user mode 切换到 kernel mode, 由操作系统来负责线程间的调度和线程的状态变更, 需要频繁的在这两个模式下切换(上下文转换)。这种有点竞争就找内核的行为很不好,会引起很大的开销,所以大家都叫它重量级锁,自然效率也很低,这也就给很多童鞋留下了一个根深蒂固的印象 —— synchronized关键字相比于其他同步机制性能不好


免费的 Java 并发编程小册在此


锁的演变


来到 JDK1.6,要怎样优化才能让锁变的轻量级一些? 答案就是:


轻量级锁:CPU CAS


如果 CPU 通过简单的 CAS 能处理加锁/释放锁,这样就不会有上下文的切换,较重量级锁而言自然就轻了很多。但是当竞争很激烈,CAS 尝试再多也是浪费 CPU,权衡一下,

不如升级成重量级锁,阻塞线程排队竞争,也就有了轻量级锁升级成重量级锁的过程


微信图片_20220512123422.png


程序员在追求极致的道路上是永无止境的,HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,同一个线程反复获取锁,如果还按照轻量级锁的方式获取锁(CAS),也是有一定代价的,如何让这个代价更小一些呢?


偏向锁


偏向锁实际就是锁对象潜意识「偏心」同一个线程来访问,让锁对象记住线程 ID,当线程再次获取锁时,亮出身份,如果同一个 ID 直接就获取锁就好了,是一种 load-and-test 的过程,相较 CAS 自然又轻量级了一些


可是多线程环境,也不可能只是同一个线程一直获取这个锁,其他线程也是要干活的,如果出现多个线程竞争的情况,也就有了偏向锁升级的过程


微信图片_20220512123451.png


这里可以先思考一下:偏向锁可以绕过轻量级锁,直接升级到重量级锁吗?

都是同一个锁对象,却有多种锁状态,其目的显而易见:


占用的资源越少,程序执行的速度越快


偏向锁,轻量锁,它俩都不会调用系统互斥量(Mutex Lock),只是为了提升性能,多出的两种锁的状态,这样可以在不同场景下采取最合适的策略,所以可以总结性的说:


  • 偏向锁:无竞争的情况下,只有一个线程进入临界区,采用偏向锁


  • 轻量级锁:多个线程可以交替进入临界区,采用轻量级锁


  • 重量级锁:多线程同时进入临界区,交给操作系统互斥量来处理


到这里,大家应该理解了全局大框,但仍然会有很多疑问:


  1. 锁对象是在哪存储线程 ID 才可以识别同一个线程的?


  1. 整个升级过程是如何过渡的?


想理解这些问题,需要先知道 Java 对象头的结构


认识 Java 对象头


按照常规理解,识别线程 ID 需要一组 mapping 映射关系来搞定,如果单独维护这个 mapping 关系又要考虑线程安全的问题。奥卡姆剃刀原理,Java 万物皆是对象,对象皆可用作锁,与其单独维护一个 mapping 关系,不如中心化将锁的信息维护在 Java 对象本身上


Java 对象头最多由三部分构成:


  1. MarkWord


  1. ClassMetadata Address


  1. Array Length (如果对象是数组才会有这部分


其中 Markword 是保存锁状态的关键,对象锁状态可以从偏向锁升级到轻量级锁,再升级到重量级锁,加上初始的无锁状态,可以理解为有 4 种状态。想在一个对象中表示这么多信息自然就要用存储,在 64 位操作系统中,是这样存储的(注意颜色标记),想看具体注释的可以看 hotspot(1.8) 源码文件 path/hotspot/src/share/vm/oops/markOop.hpp 第 30 行


微信图片_20220512123613.png


有了这些基本信息,接下来我们就只需要弄清楚,MarkWord 中的锁信息是怎么变化的


认识偏向锁


单纯的看上图,还是显得十分抽象,作为程序员的我们最喜欢用代码说话,贴心的 openjdk 官网提供了可以查看对象内存布局的工具 JOL (java object layout)


Maven Package


<dependency>
  <groupId>org.openjdk.jol</groupId>
  <artifactId>jol-core</artifactId>
  <version>0.14</version>
</dependency>


Gradle Package


implementation 'org.openjdk.jol:jol-core:0.14'


接下来我们就通过代码来深入了解一下偏向锁吧


注意:


上图(从左到右) 代表 高位 -> 低位

JOL 输出结果(从左到右)代表 低位 -> 高位


来看测试代码


场景1


    public static void main(String[] args) {
        Object o = new Object();
        log.info("未进入同步块,MarkWord 为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o){
            log.info(("进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }


来看输出结果:


微信图片_20220512123758.png


上面我们用到的 JOL 版本为 0.14, 带领大家快速了解一下位具体值,接下来我们就要用 0.16 版本查看输出结果,因为这个版本给了我们更友好的说明,同样的代码,来看输出结果:


微信图片_20220512123819.png


看到这个结果,你应该是有疑问的,JDK 1.6 之后默认是开启偏向锁的,为什么初始化的代码是无锁状态,进入同步块产生竞争就绕过偏向锁直接变成轻量级锁了呢?


虽然默认开启了偏向锁,但是开启 有延迟,大概 4s。原因是 JVM 内部的代码有很多地方用到了synchronized,如果直接开启偏向,产生竞争就要有锁升级,会带来额外的性能损耗,所以就有了延迟策略


微信图片_20220512123855.png


我们可以通过参数 -XX:BiasedLockingStartupDelay=0 将延迟改为0,但是不建议这么做。我们可以通过一张图来理解一下目前的情况:


场景2


那我们就代码延迟 5 秒来创建对象,来看看偏向是否生效


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


重新查看运行结果:


微信图片_20220512123940.png


这样的结果是符合我们预期的,但是结果中的 biasable 状态,在 MarkWord 表格中并不存在,其实这是一种匿名偏向状态,是对象初始化中,JVM 帮我们做的

这样当有线程进入同步块:


  1. 可偏向状态:直接就 CAS 替换 ThreadID,如果成功,就可以获取偏向锁了


  1. 不可偏向状态:就会变成轻量级锁


那问题又来了,现在锁对象有具体偏向的线程,如果新的线程过来执行同步块会偏向新的线程吗?


场景3


    public static void main(String[] args) throws InterruptedException {
        // 睡眠 5s
        Thread.sleep(5000);
        Object o = new Object();
        log.info("未进入同步块,MarkWord 为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o){
            log.info(("进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
        Thread t2 = new Thread(() -> {
            synchronized (o) {
                log.info("新线程获取锁,MarkWord为:");
                log.info(ClassLayout.parseInstance(o).toPrintable());
            }
        });
        t2.start();
        t2.join();
        log.info("主线程再次查看锁对象,MarkWord为:");
        log.info(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o){
            log.info(("主线程再次进入同步块,MarkWord 为:"));
            log.info(ClassLayout.parseInstance(o).toPrintable());
        }
    }


来看运行结果,奇怪的事情发生了:


微信图片_20220512124024.png


  • 标记1: 初始可偏向状态


  • 标记2:偏向主线程后,主线程退出同步代码块


  • 标记3: 新线程进入同步代码块,升级成了轻量级锁


  • 标记4: 新线程轻量级锁退出同步代码块,主线程查看,变为不可偏向状态


  • 标记5: 由于对象不可偏向,同场景1主线程再次进入同步块,自然就会用轻量级锁


至此,场景一二三可以总结为一张图:


微信图片_20220512124102.png


从这样的运行结果上来看,偏向锁像是“一锤子买卖”,只要偏向了某个线程,后续其他线程尝试获取锁,都会变为轻量级锁,这样的偏向非常有局限性。事实上并不是这样,如果你仔细看标记2(已偏向状态),还有个 epoch 我们没有提及,这个值就是打破这种局限性的关键,在了解 epoch 之前,我们还要了解一个概念——偏向撤销

免费的 Java 并发编程小册在此

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