• 高效并发:Synchornized的锁优化详解


    高效并发是从JDK 5升级到JDK 6后一项重要的改进项,HotSpot虚拟机开发团队在这个版本上花费了大量的资源去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(LockElimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(BiasedLocking)等,这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程的执行效率

    JDK 6之前,Synchornized关键字无论什么情况都会去使用ObjectMonitor对象来实现多线程。
    JDK 6之后,Synchornized具有无锁 -> 偏向锁 -> 轻量级锁(采用自适应自旋) -> 重量级锁的优化过程

    我个人认为:除了重量级锁,其它的状态基本都是只通过锁对象的Mark Word以及CAS来帮助实现的,提升了效率。而重量级锁会利用ObjectMoniter对象去与OS层面的mutex信号量做映射,线程切换的时候也会造成更多的消耗(详细见后文)。

    了解锁优化之前,必须先了解HotSpot虚拟机的对象头的内存布局,它有助于我们去了解其中的原理

    1. HotSpot虚拟机的对象头的内存布局

    在不同的锁状态下,Mark word会存储不同的信息,这也是为了节约内存常用的设计

    • 存储自身的运行时数据:哈希码(HashCode)、GC分代年龄(Generational GC Age) 、锁标志相关信息等。
    • 另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度。

    32位的MarkWord

    在这里插入图片描述

    64位的MarkWord

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Mz2UTsgn-1659171078017)(D:\note\笔记仓库\图片\image-20220730164140304.png)]

    2. 偏向锁

    偏向锁也是JDK 6中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语, 进一步提高程序的运行性能。默认开启

    偏向锁的定义:这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

    偏向锁工作原理

    • 锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。

      如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。

    • 一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束,等待全局安全点时(不执行字节码指令的时候),开始撤销偏向锁,根据锁对象目前是否处于被锁定的状态发生以下两种情况:

      • 撤销偏向锁后标志位恢复到未锁定
      • 变为轻量级锁定(标志位为“00”)的状态

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DqIk7V4o-1659171078018)(D:\note\笔记仓库\图片\image-20220730143728703.png)]

    • 后续的同步操作就按照马上介绍的轻量级锁那样去执行。

    举一反三:当锁进入偏向状态时,存储hash码的位置被覆盖了,那对象的hash码存储到哪儿的?

    关于hash:在Java语言里面一个对象如果计算过哈希码,就应该一直保持该值不变,否则很多依赖对象哈希码的API都可能存在出错风险。绝大多数对象哈希码来源的**Object::hashCode()**方法,返回的是对象的一致性哈希码。

    如何保持hash码一致不变?

    它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。

    因此,由于偏向锁的产生需要利用存储hash码的位置,所以当一个对象计算过hash码后会造成下面两种情况:

    • 当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了

    • 当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁

      这里说的计算请求应来自于对Object::hashCode()或者System::identityHashCode(Object)方法的 调用,如果重写了对象的hashCode()方法,计算哈希码时并不会产生这里所说的请求

    在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态(标志位为“01”)下的Mark Word,其中自然可以存储原来的哈希码。

    偏向锁可以提高带有同步但无竞争的程序性能。但如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。

    3. 自旋锁与自适应自旋

    自旋锁:如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

    JDK 6中默认开启,轻量级锁状态下会使用自适应自旋锁去尝试获取锁,所以我们先介绍自旋锁与自适应自旋的概念。

    自旋锁的缺点:

    • 如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费。

    解决方案:在 JDK 6中对自旋锁的优化,引入了自适应的自旋。

    • 自适应自旋:自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。

      如果对于同一个锁对象,自旋等待刚刚成功获得过锁。那么虚拟机会将自旋次数设置为更大。如果通过自旋很少获得锁,那么会减少自旋次数甚至不开启自旋以避免浪费处理器资源。

      有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,性能就会越来越好

    4. 轻量级锁

    重量级锁:在传统的锁机制中,每次发生线程切换时,需要从用户态切换到核心态,然后发出中断处理并作出保护现场、恢复现场的一些操作,代价及其昂贵,因此传统的使用操作系统互斥量来实现的锁机制被称为”重量级锁“

    轻量级锁的工作流程

    • 在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word)

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZxWbvdPE-1659171078019)(D:\note\笔记仓库\图片\image-20220730141941147.png)]

    • 然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针

      • 更新成功:如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态如下图所示

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5YUvt4pf-1659171078020)(D:\note\笔记仓库\图片\image-20220730142035196.png)]

      • 更新失败:如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。
        • 当前线程重复进入:虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了
        • 其它线程抢占:对象的Mark Word已经被修改,此时轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态
    • 解锁过程同样通过CAS来实现:如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。

      • 假如能够成功替换,那整个同步过程就顺利完成了
      • 如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。

    5. 重量级锁

    可以先看看moniter的实现原理,有助于帮助理解

    Moniter的实现原理

    [Java面试常见问题:Monitor对象是什么? - 知乎 (zhihu.com)]

    使用了monitor的对象的就是重量级锁,因为monitor的实现依赖于底层操作系统的mutex互斥原语,而操作系统实现线程之间的切换的时候需要从用户态转到内核态,这个转成过程开销比较大。

    对于monitor的引用是也是存放在对象的Mark Word中的

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WnvlZ5P5-1659171078021)(D:\note\笔记仓库\图片\image-20220730163712431.png)]

    下面两种方式就是重量级锁:

    • 同步方法的时候

      Jvm采用的是 ACC_synchoronized 标记符来实现的同步,这个是因为jvm在调用方法时会验证方法是不是有ACC_synchoronized 的标记符。如果设置了该标志,执行线程会线获取到monitor对象,然后执行方法,在该方法的运行期间,其他线程是无法获取到monitor对象的,只有当拥有monitor对象的线程执行完任务了才能获取,释放了monitor对象才能进入到代码块。

    • 同步代码块的时候

      对于同步代码块,是由 monitorentermonitorexit指令来实现的同步。monitorenter是获取monitor的所有权,mointorexit是释放monitor的所有权。

      monitorenter的执行原理

      • 获取到monitor的所有权,进入数+1
      • 如果该线程已经拥有了此次方法的monitor,又重新进入到monitor。进入数+1这个就是锁的重入
      • 其他线程进入到阻塞状态,直到monitor的进入数为0时,才会重新获取到monitor的所有权
        monitorexit则表示该线程必须释放montor的所有权。并把进入数减去1直到为0线程退出monitor。

    6. 各种锁的优缺点以及使用场景

    优点缺点适用场景
    偏向锁加锁和解锁不需要额外的消耗,和非同步块只有纳秒级别的差距如果存在线程竞争,回带来额外的锁撤销消耗使用于一个线程访问同步块
    轻量级锁竞争的线程不会阻塞,提高来线程的响应速度如果始终得不到锁竞争的线程,是使用自旋消耗CPU追求响应 同步块执行速度非常快
    重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量,同步块执行速度较长

    7. 一张图读懂synchornized锁膨胀的过程

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wsMVMuMi-1659171078021)(D:\note\笔记仓库\图片\image-20220730161926576.png)]
    参考资料:《Java并发编程的艺术》《深入理解JAVA虚拟机》

  • 相关阅读:
    for...in 与 for...of 的用法与区别
    C#截取某一范围的图
    内存卡视频误删怎么恢复?4个方法,找回视频!
    视频集中存储/直播点播平台EasyDSS点播文件分类功能新升级
    Typescript中类的使用
    js 代码中的 “use strict“; 是什么意思 ?
    vue3+vite3+vant搭建移动端简易模版
    SpringBoot 实现 excel 全自由导入导出,性能强的离谱,用起来还特优雅
    大三0基础 java学习求助?
    webpack 自定义loader与插件
  • 原文地址:https://blog.csdn.net/qq_53578500/article/details/126073402