从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都在误导,可能读者们的印象大多都是轻量级锁会自旋获取,自适应自旋获取。
但其实这些是错误的概念,直接上证据。
slow_enter这个方法就是jdk9源码中的轻量级锁对应实现,直接看
- 第一步: 这里判断是否为无锁状态,如果是就设置一个lock record。
- 第二步: 然后通过CAS去获取锁。
- 第三步: 如果不是无锁状态就判断是不是重入逻辑,这里很巧妙,可重入逻辑直接将新整的lock record的这个displaced_header干成null,就很好判断这是重入还是咋样
- 第四步: 这里就是拿锁失败或不是重入,就进行膨胀。
看完是不是豁然开朗,轻量级锁根本没有自旋的说法。再来看看官网文档https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html
这应该是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的三个关键点。
ObjectMonitor的三个队列
- _WaitSet(等待队列)
- 调用 wait() 的线程进入
- 只有 notify()/notifyAll() 才会唤醒
- exit() 不会操作这个队列
- _cxq(竞争队列,Contention Queue)
- 栈结构(LIFO),新线程加到头部
- 抢锁失败的线程先进这里
- 使用CAS无锁操作,高并发性能好
- _EntryList(入口队列)
- 队列结构(FIFO)
- 从cxq转移过来的线程
- exit时优先从这里唤醒
在重量级锁状态对象头完全不装任何东西只装一个指向ObjectMonitor(管程/监视器)的指针这个,我们熟知的hashcode、age对象分代年龄信息等等各种mark word中的东西全部都存在这里面了,交给cpu或者说是操作系统保管,在我们处理完同步块代码之后,准备退出/释放锁,这时底层会调用ObjectMonitor::exit命令,这个命令会干三件事情:
-
释放锁,owner = null recursions = 0,清空持有者、重入计数
-
选择唤醒策略,JVM有多种策略,默认是QMode = 0;
-
QMode = 0(默认策略)
- 如果EntryList不为空 → 唤醒EntryList的头节点
- 如果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独占锁流程、原理整理出来了会继续写,当作是巩固知识。最后感谢你有耐心看到这里,希望你能有所收获。
#学习日记##技术#