• Java synchronized那点事


    前言

    请看上篇:Java 对象头那点事

    文章中的源码都有不同程度缩减,来源于openjdk8的开源代码(tag:jdk8-b120)


    锁粗化过程

    偏向锁

    ①:markword中保存的线程ID是自己且epoch等于class的epoch,则说明是偏向锁重入。
    ②:偏向锁若已禁用,进行撤销偏向锁。
    ③:偏向锁开启,都进行进行重偏向操作。
    ④:若进行了锁撤销操作或重偏向操作失败,则需要升级为轻量级锁或者进一步升级为重量级锁。

    匿名偏向

    锁对象在发送锁竞争后会升级为偏向锁,不过当不发生锁竞争时,锁对象依然会升级为偏向锁,这种情况叫匿名偏向。
    当jvm启动4s后,会默认给新建的对象加上偏向锁。


    上代码:

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

    这个包下的工具类的功能有:

             // 查看对象内部结构
             System.out.println(ClassLayout.parseInstance(bingo).toPrintable());
             // 查看对象外部信息
             System.out.println(GraphLayout.parseInstance(bingo).toPrintable());
             // 查看对象总大小
             System.out.println(GraphLayout.parseInstance(bingo).totalSize());
    

    默认JVM是开启指针压缩,可以通过vm参数开启关闭指针压缩:-XX:-UseCompressedOops


    当创建锁对象前不进行休眠4s的操作:

        @Test
        public void mark() throws InterruptedException {
            Bingo bingo = new Bingo();
            bingo.setP(1);
            bingo.setB(false);
            // 查看对象内部结构
            System.out.println(ClassLayout.parseInstance(bingo).toPrintable());
            System.out.println("\n++++++++++++++++++++++++++\n");
            synchronized (bingo) {
                // 查看对象内部结构
                System.out.println(ClassLayout.parseInstance(bingo).toPrintable());
            }
        }
    

    看我标红线的后三位的值,由于启动过快,锁直接从无锁升级成了轻量级锁。


    当创建锁对象前进行休眠4s的操作:

        @Test
        public void mark() throws InterruptedException {
            TimeUnit.SECONDS.sleep(4);
    
            Bingo bingo = new Bingo();
            bingo.setP(1);
            bingo.setB(false);
            // 查看对象内部结构
            System.out.println(ClassLayout.parseInstance(bingo).toPrintable());
            System.out.println("\n++++++++++++++++++++++++++\n");
            synchronized (bingo) {
                // 查看对象内部结构
                System.out.println(ClassLayout.parseInstance(bingo).toPrintable());
            }
        }
    

    当在程序启动4s后创建锁对象,就会默认偏向。

    重偏向

    因为偏向锁不会自动释放,因此当锁对象处于偏向锁时,另一个线程进来只能依托VM判断上一个获取偏向锁的线程是否存活、是否退出持有锁来决定是锁升级还是进行重偏向。

    锁撤销

    ①:偏向锁的撤销必须等待VM全局安全点(安全点指所有java线程都停在安全点,只有vm线程运行)。
    ②:撤销偏向锁恢复到无锁(标志位为 01)或轻量级锁(标志位为 00)的状态。
    ③:只要发生锁竞争,就会进行锁撤销。

    备注:
    当开启偏向锁时,若持有偏向锁的线程仍然存活且未退出同步代码块,锁升级为轻量级锁/重量级锁之前会进行偏向锁撤销操作。
    如果是升级为轻量级锁,撤销之后需要创建Lock Record 来保存之前的markword信息。


    批量偏向/撤销概念:
    参考1:https://www.cnblogs.com/LemonFive/p/11248248.html

    • 批量重偏向
      当一个线程同时持有同一个类的多个对象的偏向锁时(这些对象的锁竞争不激烈),执行完同步代码块后,如果另一个线程也要持有这些对象的锁,当对象数量达到一定程度时,会触发批量重偏向机制(进行过批量重偏向的对象不可再进行批量重偏向)。
    • 批量锁撤销
      当触发批量重偏向后,会触发批量撤销机制。

    阈值定义在globals.hpp中:

    可以在VM启动参数中通过-XX:BiasedLockingBulkRebiasThreshold-XX:BiasedLockingBulkRevokeThreshold 来手动设置阈值。


    偏向锁的撤销和重偏向的代码(过于复杂)在biasedLocking.cpp中:

    参考2:

    对于存在明显多线程竞争的场景下使用偏向锁是不合适的,比如生产者-消费者队列。生产者线程获得了偏向锁,消费者线程再去获得锁的时候,就涉及到这个偏向锁的撤销(revoke)操作,而这个撤销是比较昂贵的。那么怎么判断这些对象是否适合偏向锁呢?jvm采用以类为单位的做法,其内部为每个类维护一个偏向锁计数器,对其对象进行偏向锁的撤销操作进行计数。当这个值达到指定阈值的时候,jvm就认为这个类的偏向锁有问题,需要进行重偏向(rebias)。对所有属于这个类的对象进行重偏向的操作叫批量重偏向(bulk rebias),之前的做法是对heap进行遍历,后来引入epoch。当需要bulk rebias时,对这个类的epoch值加1,以后分配这个类的对象的时候mark字段里就是这个epoch值了,同时还要对当前已经获得偏向锁的对象的epoch值加1,这些锁数据记录在方法栈里。这样判断这个对象是否获得偏向锁的条件就是:mark字段后3位是101,thread字段跟当前线程相同,epoch字段跟所属类的epoch值相同。如果epoch值不一样,即使thread字段指向当前线程,也是无效的,相当于进行过了rebias,只是没有对对象的mark字段进行更新。如果这个类的revoke计数器继续增加到一个阈值,那个jvm就认为这个类不适合偏向锁了,就要进行bulk revoke。于是多了一个判断条件,要查看所属类的字段,看看是否允许对这个类使用偏向锁。

    轻量级锁

    轻量级体现在线程会尝试在自己的堆栈中创建Lock Record存储锁对象的相关信息,不需要在内核态和用户态之间进行切换,不需要操作系统进行调度。

    加锁

    拿到轻量级锁线程堆栈:

    Lock Record主要分为两部分:

    • obj
      指向锁对象本身。重入时也如此。
    • displaced header(缩写为hdr)
      第一次拿到锁时hdr存放的是encode加密后的markword,重入时存放null。

    思考:为什么锁重入时hdr存放的是null,而不是用计数器来实现呢?
    假设一个场景,当一个线程同时拿到A、B、C...N 多个锁的时候,那么线程的堆栈中,肯定有多个锁对象的Lock Record,
    如:

    synchronized(a){
        synchronized(b){
            synchronized(c){
                // do something
                synchronized(a){
                    // do something
                }
            }
        }
    }
    

    当锁a重入时,如果用计数器,还得遍历当前线程堆栈拿到第一次的Lock Record,解锁时也要遍历,效率必然低下。作为jdk底层代码必然讲究效率。
    以上纯属个人看法(欢迎交流)。

    解锁

    ①:使用遍历方式将当前线程堆栈中属于该锁对象的Lock Record 指向Null。
    ②:CAS还原markword为无锁状态。
    ③:第②步失败需要升级为重量级锁。

    优缺点

    • 优点
      在线程接替/交替执行的情况下,锁竞争比较小,可以避免成为重量级锁而引起的性能问题。

    • 缺点
      当锁竞争比较激烈、多线程同事竞争锁的时候,需要从轻量级升级为重量级,产生了额外的开销。

    源码分析

    加锁
    加锁、解锁流程的代码在InterpreterRuntime.cpp中。
    这是我从github拉下来的源码:

    可以看得出来,这部分代码并没有体现出偏向锁的逻辑,有大佬给出原因,可以参考这篇博客:https://www.jianshu.com/p/4758852cbff4


    其他大佬解析后的代码:

    点击查看代码

    解锁

    重量级锁

    重量级锁是基于monitor模型进行实现的。

    重量级锁是如何体现重量级的?
    ①:需要创建monitor,包含阻塞队列、竞争队列、继承者、锁拥有者等大量数据,会占用大量内存。
    ②:需要调用操作系统对线程进行park、unpark操作,会涉及到cpu在用户态和内核态之间切换,开销大。
    ③:monitor所运行的VM线程(内核线程)需要操作系统将那些调度,耗费时间。

    monitor的初始化

    ①:monitor并不是一下子初始化完成的。
    ②:monitor在初始化的过程中,如果有线程进来获取锁,则会进行自旋。
    ③:线程进入monitor后会被封装成一个ObjectWaiter(双向链表结构),然后park住当前线程。当有线程退出锁后会进行unpark操作(唤醒操作涉及到操作系统,会产生额外的开销)。

    ObjectWaiter的结构:

    monitor的组成

    monitor的工作流程

    阻塞队列中的线程进入_cxq、_EntryList队列的过程有着不同的策略:

    • policy == 0,头插_EntryList
    • policy == 1,尾插_EntryList
    • policy == 2,头插_cxq
    • policy == 3,尾插_cxq

    源码分析

    加锁第一阶段
    这部分代码并没有创建monitor。
    大部分工作是对锁状态做判断、安全点的检查,考虑无锁、轻量级锁的重入情况,因为锁升级为重量级锁就直接进内核态了,消耗资源太多。


    InterpreterRuntime.cpp#monitorenter源码:

    主要还是看ObjectSynchronizer::fast_enter、ObjectSynchronizer::slow_enter,这部分源码在synchronizer.cpp中。

    如果是进入fast_enter(),那么就会再进行一次偏向锁开启的判断,再进入slow_enter()的逻辑中去,那么为什么不开始就直接进行slow_enter呢?就为了判断下锁偏向和撤销吗?这部分逻辑也完全可以写到slow_enter中去。这么写的原因未知。


    加锁第二阶段
    形成monitor,用来调度竞争锁的线程。

    先看锁的膨胀过程:

    ObjectSynchronizer::omAlloc的作用:

    尝试从线程的本地omFreeList 分配。线程将首先尝试从其本地列表中分配,然后从全局列表中,只有在那些尝试失败后,线程才会尝试实例化新的监视器。线程本地空闲列表占用 加热 ListLock 并改善分配延迟,并减少共享全局列表上的一致性流量。

    总之我也没看懂,大概就是分配一个monitor给该线程用...


    加锁第三阶段
    当monitor形成之后,线程是阻塞还是拿到锁执行同步块代码,就看线程自己的运气了。

    线程进入monitor:

    果然synchronized不是公平锁,不过这也太不公平了。


    解锁第一阶段
    owner在退出持有锁的时候,会根据monitor的QMode策略,决定继承者的选取方式,选定继承者之前owner仍然会持有锁,以保证并行性。


    解锁第二阶段
    唤醒继承者,让它去尝试获取锁。

    总结

    1:无论偏向锁、轻量级锁、重量级锁,都是可重入的。所以熟悉JAVA并发包的ReentrantLock重入锁机制是有必要的。
    2:只有重量级锁需要操作系统去进行调度竞争锁的线程。
    3:偏向锁的撤销不是为了使锁降级为无锁状态,而是需要先降级再转变为轻量级锁状态。
    4:偏向锁的撤销需要等待全局安全点,且锁撤销有一定的开销。所以在多线程竞争激烈的情况下,可以实现关闭偏向锁来进行性能调优。

    想看源码的看这些文件。


    其他优化
    JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。

    ①:适应性自旋
    升级为重量级锁之前,会尝试自旋一定次数(默认10次,可通过参数-XX : PreBlockSpin来更改)来延缓进入重量级锁的过程。
    优点:若真的成功则可以避免锁升级,减少线程进入monitor从而带来的一系列开销。同时当前线程不会经历挂起-唤醒的过程,可以更快响应。
    缺点:会一直占用cpu,若自旋失败则是额外的浪费。

    ②:锁粗化
    将连在一起的加锁、解锁操作扩大范围,只进行一次性加锁、解锁操作。
    如:

         Object lock = new Object();
         List<String> list = new ArrayList();
         synchronized(lock){
             list.add("a");
         }
         synchronized(lock){
             list.add("b");
         }
         synchronized(lock){
             list.add("c");
         }
    

    优化为:

         Object lock = new Object();
         List<String> list = new ArrayList();
         synchronized(lock){
             list.add("a");
             list.add("b");
             list.add("c");
         }
    

    ③:锁消除
    若当前线程创建的对象分配在堆,但不会被其他线程使用,那么这段代码就可以不加锁。
    或者根据逃逸分析,当前线程new的对象不会被其他线程使用,那么也不需要加锁。


    其他问题
    ①:当所状态为偏向锁时,如何存储hashcode信息?
    若hashCode方法的调用是在对象已经处于偏向锁状态时调用,它的偏向状态会被立即撤销,并且锁会升级为重量级锁。

    ②:什么线程复用?
    两个线程间隔5s启动,markword中thread信息一摸一样这个现象实际上就是JVM线程复用。


    本文参考文章:
    ①: 小米信息部技术团队-synchronized 实现原理
    ②:synchronized的jvm源码加锁流程分析聊锁的意义
    ③:Java对象的内存布局
    ④:盘一盘 synchronized (二)—— 偏向锁批量重偏向与批量撤销
    ⑤:https://www.bbsmax.com/A/xl56qY9rJr/
    ⑥:Java并发编程:Synchronized底层优化(偏向锁、轻量级锁)

    感触:上网搜很难看到自己想要的内容,甚至有的文章还会起误导性作用。果然还是要好好学习,厉害的大佬比比皆是。在性能调优上哪有什么最优解,只有合适与不合适,重在选择与取舍。


    __EOF__

  • 本文作者: 竹根七
  • 本文链接: https://www.cnblogs.com/zgq7/p/16257327.html
  • 关于博主: 评论和私信会在第一时间回复。或者直接私信我。
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
  • 声援博主: 如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。
  • 相关阅读:
    C++20:constexpr、consteval和constinit
    领英-如何合并或注销重复领英帐号及利用领英高效开发客户技巧
    机车整备场数字孪生 | 图扑智慧铁路
    词法分析器的设计与实现--编译原理操作步骤,1、你的算法工作流程图; 2、你的函数流程图;3,具体代码
    什么时候用 C 而不用 C++?
    跨域问题解决之隔山隔海都不怕
    C#WPF通过串口(232协议)调用基恩士打标机进行打标
    WPF/C#:显示分组数据的两种方式
    h5开发网站-页面内容不够高时,如何定位footer始终位于页面的最底部
    参加霍格沃兹测试开发学社举办的火焰杯软件测试开发大赛是一种什么体验?
  • 原文地址:https://www.cnblogs.com/zgq7/p/16257327.html