从JVM对象头看synchronized锁升级

置顶: 本文章主要是一个进阶性地探索,针对大概知道锁升级流程,但是不太熟悉,印象稍显模糊无法和其他相关知识联系起来的朋友,本文中涉及到的源码均可在官网下载,主要看这个文件src/share/vm/runtime/synchronizer.cpp

免责声明: 本文章全手写,是主包查阅很多资料包括但不限于CLAUDE、GEMINI等AI,官网白皮书,马丁汤普森博客,stackoverflow,面试八股文pdf,周志明的《深入JVM》等等,谨此记录我的学习。同时主包也希望通过文章输出加深我的理解,欢迎各位Javaer讨论。

一、JVM对象头简介

在HotSpot虚拟机中,当我们new出一个对象时,它在堆内存中的完整布局分为三个部分:

  • 对象头: 极其关键,包含了对象的运行状态和类信息。
  • 实例数据: 我们定义的业务字段(比如int age,string name等等)。
  • 对其填充: JVM要求对象大小必须是8字节的整数倍,不够就拿0占位凑数,这主要是为了CPU的读取效率。

对象头通常由两部分组成(如果是数组的话就是三部分):

**1.**Klass类型指针

  • 指向该对象的方法区元数据(Class信息)。JVM就是靠这根指针确定某对象是谁的示例。
  • 在64位机器上默认开启指针压缩,占4个字节,不压缩就是8字节。

**2.**数组长度

  • 这里占4个字节,记录数组长度。

**3.**Mark Word标记字段---本文重点也是锁升级的重点

  • 在64位机器上,它占64位也就是8字节。
  • 它是一个动态复用的数据结构,JVM为了省内存,会让这64位在不同的锁状态下,存储完全不同意义的数据。下面这个表是锁状态的流转表格。
锁状态 前61 1bit(偏向标志) 2bit(锁标志)
无锁 25unused+31位HashCode【如果调用了原生的hashcode()】+1位unused+4位分代年龄 0 01
偏向锁 54位线程ID+2位Epoch+1位unused+4位分代年龄 1 01
轻量级锁 指向线程栈中Lock Record的指针62位 00
重量级锁 指向底层ObjectMonitor管程的指针62位 11
GC标记 空(主要由GC算法决定) 11

epoch用于偏向锁,JVM底层区分版本号是否一致判断是否为同一个对象,思考一下这种场景,T1线程 将 A = NULL后 TESTA A = new TESTA();此时我们知道这个A不是旧对象,但JVM必须要通过epoch去判断。具体作用在后面讲解。

二、锁升级具体细节

通过刚才的表格我们知道了锁升级过程中mark word大概是怎么变化的,那么在这一节我们就详细讲讲锁升级的细节。

无锁状态

一切安好,对象刚new出来,mark word里面存着对应信息,值得一提的是,如果我们不调用object类的hashcode方法那么对象头中就不会存储hashcode,注意一定是原生的hashcode方法。并且还有几个隐藏的坑,一些集合操作、原生tostring方法、日志框架的输出等等都可能会导致hashcode方法被调用。

偏向锁状态

在上一小节,我们强调了hashcode方法,仔细的读者不难发现在表格中除了无锁,大家都不存储hashcode,是的如果对象头一旦有了hashcode那么我们将永远进入不了偏向锁状态。但是可以进入其他锁状态,后面会慢慢讲到。

现在我们来讲讲偏向锁的一些重点知识: 批量撤销、批量重偏向、epoch。刚才我们也对epoch有了一个大概的了解,现在假设以下这个场景:

  • t1线程拿到了x对象的偏向锁并创建了100个对象供t2线程使用。
  • 这时候t1退出同步块,t2上场进行操作,它肯定需要上锁,这时候就出现了偏向锁竞争的情况。
  • t2尝试获取锁,发现偏向t1
  • 请求撤销这个锁,JVM会发起退回安全点(短暂的STW)
  • VMThread检查t1状态,在同步块内直接升级,t2用轻量级锁获取。如果不在同步块内就判断有没有批量重偏向,有的话就CAS重偏向给t2,没有的话就撤销成无锁,t2就会用轻量级锁获取
  • 每次撤销,在Klass信息中也存了一份epoch,对象中的epoch是不变的,klass中的epoch会在撤销时+1,这个epoch只占2位用来区分是否为同一对象完全够用。但是在klass中还有一个_biased_lock_revocation_count专门记录撤销了几次,在第21次想撤销时就会要求t2线程直接CAS获取偏向锁。这就叫批量重偏向
  • 这里批量重偏向也会记录epoch、_biased_lock_revocation_count,在第40次撤销时,jvm就会认为这个类完全不适合偏向锁,会强行将它升级为轻量级锁,并将之前的所有偏向锁全部撤销,给这个类打上不可偏向标记。这就是批量撤销

轻量级锁状态

讲完了偏向锁,我们该讲讲这个轻量级锁的细节,这里应该是一个重灾区,网上很多资料、博客、包括AI都在误导,可能读者们的印象大多都是轻量级锁会自旋获取,自适应自旋获取。

但其实这些是错误的概念,直接上证据。

alt

slow_enter这个方法就是jdk9源码中的轻量级锁对应实现,直接看

  • 第一步: 这里判断是否为无锁状态,如果是就设置一个lock record。
  • 第二步: 然后通过CAS去获取锁。
  • 第三步: 如果不是无锁状态就判断是不是重入逻辑,这里很巧妙,可重入逻辑直接将新整的lock record的这个displaced_header干成null,就很好判断这是重入还是咋样
  • 第四步: 这里就是拿锁失败或不是重入,就进行膨胀。

看完是不是豁然开朗,轻量级锁根本没有自旋的说法。再来看看官网文档https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html

alt

这应该是jdk6的一个虚拟机选项说明文档,我从stackoverflow上面直接跳过来的,可以明显看到usespinning这个参数的描述是在进入操作系统线程同步代码之前,启用 Java 监视器上的简单循环。而-XX:PreBlockSpin=10这个参数(也就是网上大多博客、AI说的设置默认自旋次数的参数)它确实是这个功能,不过它需要开启下面那个参数才能生效,而下面这个参数完全是为了monitor服务的,也就是我们常说的互斥锁、重量级锁。

说完这个我们还得讲讲它最重要的数据结构---lock record,它是放在执行当前方法的栈帧中的,内部结构非常简单

  • Displaced Mark Word(备份的 Mark Word)
    • 顾名思义,这是一个“替身”。
    • 在轻量级锁竞争时,线程会把对象头里原本的 Mark Word(里面可能存着 HashCode、分代年龄等重要数据)原封不动地拷贝到这个字段里保存起来。
  • Object Reference(对象指针/Owner 指针)
    • 这是一个指向堆内存中那个被锁住的对象的指针。告诉 JVM:“我这个锁记录,锁的是那个对象”。

重量级锁状态

讲完轻量级锁,现在来看看重量级锁。这就是重量级锁自适应自旋的证据。自适应自旋就不需要我多讲了吧。顺带一嘴,如果轻量级锁CAS抢锁失败,不止会膨胀,还会引起一个t1线程拿着这个对象的轻量级锁运行完同步块之后想CAS释放锁会CAS失败的问题,这也算是一个比较经典的问题了大家可以思考一下。我们主要讲讲monitor的三个关键点。

alt

ObjectMonitor的三个队列

  1. _WaitSet(等待队列)
  • 调用 wait() 的线程进入
  • 只有 notify()/notifyAll() 才会唤醒
  • exit() 不会操作这个队列
  1. _cxq(竞争队列,Contention Queue)
  • 栈结构(LIFO),新线程加到头部
  • 抢锁失败的线程先进这里
  • 使用CAS无锁操作,高并发性能好
  1. _EntryList(入口队列)
  • 队列结构(FIFO)
  • 从cxq转移过来的线程
  • exit时优先从这里唤醒

在重量级锁状态对象头完全不装任何东西只装一个指向ObjectMonitor(管程/监视器)的指针这个,我们熟知的hashcode、age对象分代年龄信息等等各种mark word中的东西全部都存在这里面了,交给cpu或者说是操作系统保管,在我们处理完同步块代码之后,准备退出/释放锁,这时底层会调用ObjectMonitor::exit命令,这个命令会干三件事情:

  • 释放锁,owner = null recursions = 0,清空持有者、重入计数

  • 选择唤醒策略,JVM有多种策略,默认是QMode = 0;

    • QMode = 0(默认策略)

      1. 如果EntryList不为空 → 唤醒EntryList的头节点
      2. 如果EntryList为空,cxq不为空 → 把cxq整体转移到EntryList,然后唤醒头节点

      QMode = 2

      把cxq的线程插入到EntryList的头部(LIFO,后进先出)

      QMode = 3

      把cxq的线程追加到EntryList的尾部(FIFO,先进先出)

      QMode = 4

      直接从cxq唤醒(不转移到EntryList)

  • 看得出来所有的cxq线程都会转移到入口队列中。

  • 当然除了这个命令叫醒线程去继承锁,也有这种可能,owner = null,unpark(t1),因为这两步不是原子性的,所以外面t2直接在这两步中间来了一个CAS抢锁,发现owner = null,于是直接加锁了,t1就抢不到了只好继续睡。

总结

第一次写博客文章,感觉写得不太好。有些想说的可能没讲出来,讲得也比较乱,不过也算是锻炼了,后面将AQS独占锁流程、原理整理出来了会继续写,当作是巩固知识。最后感谢你有耐心看到这里,希望你能有所收获。

#学习日记##技术#
全部评论

相关推荐

03-15 10:59
已编辑
美团_后端开发(实习员工)
爱写代码的菜code...:哎,自己当时拿到字节offer的时候也在感叹终于拿到了,自己当时最想去的企业就是字节,结果还是阴差阳错去了鹅厂。祝uu一切顺利!!!
点赞 评论 收藏
分享
昨天 23:54
黑龙江大学 Java
想潜水的小师弟拒绝内...:含金量堪比小学春游
点赞 评论 收藏
分享
评论
1
收藏
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务