synchronized实际上有三种实现状态,在锁升级过程中,会依次从偏向锁、轻量级锁到重量级锁演进。下面会介绍这三种锁状态的实现方式。
零、Mark Word(后简称MW)
Java的synchronized基本上都是围绕这个Mark Word实现的,Mark Word保存在对象头中,用来标记当前的锁状态,具体结构如下所示。JavaThread*表示线程的指针,LockRecord*表示指向LockRecord栈中锁记录的指针,ObjectMonitor*表示指向ObjectMonitor监视器的指针。需要注意的是,物理内存中,低位在右,高位在左,下面表格按照这个方向制作。
| 锁状态 | bit 63~10 (高位数据区) | bit 9~8 (重偏向时间戳) | bit 7 (未使用) | bit 6~3 (分代年龄) | bit 2 (偏向标志) | bit 1~0 (锁标志) |
|---|---|---|---|---|---|---|
| 无锁 |
bit 63~41:全0留空 bit 40~10:hashCode | 0 | 0 | 分代年龄 | 0 (未偏向) |
01 (偏向锁 或无锁) |
| 偏向锁 |
JavaThread* 所属线程的指针 | 存放批量重偏向的批次值 | 0 | 分代年龄 | 1 (已偏向) |
01 (偏向锁 或无锁) |
| 轻量级锁 |
LockRecord* 栈中锁记录的指针 |
00 (轻量级锁) | ||||
| 重量级锁 |
ObjectMonitor* ObjectMonitor锁监视器的指针 |
10 (重量级锁) | ||||
通过MW判断锁状态的伪代码(0b前缀表示二进制):
// 1. 原子读取整个 Mark Word
mark = object->mark();
// 2. 判断是否为偏向锁(检查最低 3 位是否等于 101)
if ((mark & 0b111) == 0b101) {
// 偏向锁路径:比较线程指针
if (mark.thread == current_thread) {
// 重入,直接进入
} else {
// 竞争,触发偏向撤销(STW)
}
}
// 3. 判断是否为轻量级锁(检查最低 2 位是否等于 00)
else if ((mark & 0b11) == 0b00) {
// 轻量级锁路径:CAS 自旋
}
// 4. 判断是否为重量级锁(检查最低 2 位是否等于 10)
else if ((mark & 0b11) == 0b10) {
// 重量级锁路径:阻塞等待
}
// 5. 剩下的情况:lock=01 且 biased_lock=0(无锁)
else {
// 无锁路径:尝试 CAS 抢占
}
一、偏向锁
偏向锁的实现方式,就是通过CAS方式将synchronized对象头的MW修改为当前线程的指针,线程重入该锁时,不需要再执行CAS,只需要比较MW是否为当前线程ID。对于长期只有一个线程获取的锁对象来说,这种偏向锁的性能消耗是非常低的。
具体过程如下,在初始状态时,锁对象的头MW标志位biased_lock为0未偏向,lock标志位为01偏向锁(101表示可偏向),满足伪代码的else条件,使用CAS将高位数据区置为线程指针,同时将biased_lock置位1。后续该线程再进入该锁时,只要判断Mark Word后三位为标记为101,则直接比较高位是否指向当前线程,是则获取锁成功。需要注意的是,这个MD操作就算线程释放锁也不会置为无锁状态(这就是为什么叫偏向的锁),从而不管是重入锁还是释放锁后重新进入都只需要比较MD,带来较小的性能消耗。
同时,对于当前线程来说,会在当前栈帧(或栈顶)中压入一个 Lock Record [ Lock Record_1: {obj=锁对象, Displaced=null} ]。第二次、第三次进入同一个 synchronized 块时(锁重入),JVM 不会再去修改对象头(因为 ID 已经匹配)。但在栈上,JVM 会继续压入新的 Lock Record,每个新记录依然是 obj 指向锁对象,Displaced Mark Word = null。这些记录的唯一作用就是“重入计数器”,每次退出同步代码块弹出一个,保证获取多少次就释放多少次,只要还拥有一个Lock Record就认为线程仍然持有该锁,这是为什么MD足已判断当前偏向锁为线程持有仍然需要压入Lock Record的原因。后续出现竞争时就可以通过这个判断是否线程还持有锁。
| Lock Record 字段 | 存储内容 | 说明 |
|---|---|---|
obj(锁对象引用) | 指向 锁对象 的指针 | 标记这个锁记录属于哪个对象。 |
Displaced Mark Word(置换标记字) | null(空) | 这是关键区别! 偏向锁不保存原 Mark Word 副本,因为释放时不需要还原对象头(对象头里的线程 ID 留着不擦除)。 |
通过上面的伪代码不难看出,假设出现多个线程竞争,则若继续使用偏向锁不断改变偏向的线程,需要频繁STW,这带来的消耗比CAS自旋还高,所以如果出现竞争,则偏向锁就会升级为轻量级锁。
二、偏向锁升级为轻量级锁过程
第一阶段:检测到竞争(T2 入场)
假设T1先获取了偏向锁,现在T2也试图获取该对象的偏向锁,首先T2 执行 monitorenter,读取MW检查最低三位,发现最低 3 位是 101,判定当前处于偏向锁状态,然后对比线程指针发现指向 T1,而不是当前线程 T2。
触发撤销:JVM 判定发生了锁竞争,不再允许 T2 进行 CAS 自旋(因为在偏向锁协议下,对象头没有自旋计数器和状态位),而是立即进入 “偏向锁撤销(Revocation)” 流程,若T1仍然使用该锁对象则在撤销后升级为轻量级锁,若 T1 已退出,则将对象头恢复为无锁状态(biased_lock=0, lock=01),后续 T2 可以重新尝试获取偏向锁(相当于一次全新的偏向锁获取流程)。
第二阶段:请求全局安全点(STW)
这是偏向锁性能开销最大的来源。为了保证线程栈帧的稳定性,JVM 必须暂停所有应用线程(包括 T1 和 T2),到达一个全局安全点(Safe Point)。为什么必须 STW? 因为 JVM 需要遍历 T1 的调用栈,查找 obj 对应的锁记录。如果不停顿 T1,T1 的栈帧在不断变化(方法入栈出栈),遍历结果就不准确。
第三阶段:遍历 T1 栈帧(查找 Lock Record)
在 STW 期间,JVM 拿着锁对象 obj 的引用,去线程 T1 的栈中执行全栈扫描,遍历每一个栈帧检查每个栈帧中的 Lock Record(锁记录)列表,查看是否有某个 Lock Record 的 obj 字段指向了当前这个 obj 对象。
此时会出现两种结果(升级为轻量级锁走的是情况 2):
情况 1(T1 已退出,不升级):如果在 T1 栈中找不到匹配的 Lock Record,说明 T1 早已执行完同步块,只是对象头残留了 T1 的 ID(偏向锁特性)。此时 JVM 会将对象头恢复为无锁状态(biased_lock=0, lock=01,清空线程指针),然后唤醒 T2,让 T2 重新走“无锁抢锁”流程。
情况 2(T1 仍持有,升级):如果在 T1 栈中找到了匹配的 Lock Record,说明 T1 依然在执行同步代码块,锁确实被占用。此时,JVM 执行升级操作。
第四阶段:膨胀为轻量级锁(核心转换)
当确认 T1 仍持有锁后,JVM 在 STW 期间执行以下原子转换,在保留T1持有锁的情况下将锁结构从偏向锁转为轻量级锁。
1.填充 T1 的 Lock Record
将 T1 栈中那个匹配的 Lock Record 的 Displaced Mark Word字段(偏向锁时为空),填入当前对象头中原本的 Mark Word 的副本(即包含 T1 线程指针和 age/epoch 的那串原始位数据)。在轻量级锁升级为重量级锁时,MD依然没有位置保留这部分内容,所以会转移保存在 ObjectMonitor 的 _header 字段中。
2.修改 Mark Word转换为轻量级锁结构
将对象头的MD整体覆写最低 2 位设置为 00(轻量级锁标志)。高位(2~63 位):存储指向 T1 栈中那个 Lock Record 的指针(62 位地址)。此时,biased_lock 位(bit 2)已经被指针覆盖,不再独立存在。虽然对象头变成了轻量级锁,但 Lock Record 属于 T1,所以 JVM 依然认为 T1 是锁的持有者。T1 对此毫无感知,它继续执行同步块内的代码。
3.退出 STW 所有线程恢复运行。
第五阶段:T2 自旋等待(轻量级锁特性)
STW 结束后,线程 T2 再次读取锁对象的MD,发现最低两位是 00(轻量级锁),且高位指向了 T1 的栈帧。T2 知道锁被 T1 占用,于是进入轻量级锁的自旋 重试)流程,在自己的栈帧中创建 Lock Record,然后循环执行 CAS,尝试将对象头的指针改为指向自己的 Lock Record。在 T1释放之前,CAS 一直失败,T2 会自旋(消耗 CPU)等待 T1 释放。直到 T1 退出同步块,执行轻量级锁释放逻辑将对象头 CAS 还原为无锁状态,T2 才有机会抢到锁。
三、轻量级锁升级为重量级锁过程
第一阶段:触发膨胀决策(自适应自旋失败)
T2 在自旋过程中,JVM 的自适应自旋机制动态调整自旋次数,如果自旋次数达到阈值(或持有者 T1 长时间未释放锁,如发生 GC 或 I/O 阻塞),JVM 判定 “继续自旋的成功概率极低,且浪费 CPU 资源”,则触发膨胀(Inflate)指令:JVM 不再允许 T2 继续自旋,而是进入 ObjectSynchronizer::inflate() 方法,正式启动升级流程。
第二阶段:分配 ObjectMonitor 并 CAS 抢占(原子替换)
这是升级过程中最关键的竞态步骤,JVM 必须保证仅有一个线程成功执行膨胀。
执行 CAS 替换对象头:T2尝试通过CAS操作,将对象头的Mark Word从指向T1栈的指针,修改为一个特殊的中转值 INFLATING。
如果 CAS 成功:当前线程(T2)成为“膨胀执行者”,进入第三阶段(作为锁升级的唯一打工人)。
如果 CAS 失败:
1. 持有者 T1 已释放锁,对象回到无锁状态
这是完全可能发生的。T1 可能执行速度极快,在 T2 发起膨胀的瞬间已经完成了同步块,并通过 CAS 将 Mark Word 成功还原为无锁状态(01)。
此时,T2 的 CAS 会因 Mark Word 已不是“指向 T1 栈”而失败。处理逻辑很清晰:锁竞争已经消失,T2 无需再膨胀,而是直接尝试用 CAS 获取这个无锁对象即可,然后重新走一遍轻量级锁的加锁流程。
2. 其他线程已成功将锁膨胀为重量级锁
这是另一个常见情况。如果另一个线程 T3 抢先一步完成了膨胀,Mark Word 已经被改为重量级锁状态(10),指向一个 ObjectMonitor 对象。T2 的 CAS 会因此失败,此时 T2 会退出膨胀,直接使用这个现成的 ObjectMonitor,进入ObjectMonitor的_cxq。
3. 其他线程正在执行膨胀(INFLATING 状态)
如果 T3 抢先将 Mark Word 改为了正在膨胀状态(INFLATING),T2 的 CAS 同样会失败。这时 T2 会自旋等待,不断重新读取 Mark Word,直到T3 完成膨胀后变为 10(重量级),然后进入ObjectMonitor的_cxq。
第三阶段:迁移锁持有权(T1 的栈信息 → Monitor)
T2线程在堆中申请一块 C++ 对象内存分配给 ObjectMonitor 对象,并初始化其核心字段:_owner = null(暂未拥有者),_recursions = 0(重入计数归零),_EntryList = null(阻塞队列为空),_WaitSet = null(调用了Object.wait()的等待队列为空)
CAS 成功后,执行者(假设是 T2 抢到了 CAS)需要将锁的持有信息迁移到 ObjectMonitor ,其中就包括将锁重入次数从T1的栈Lock Record计数统计出来赋值给_recursions 。所以T2会通过MD指向的T1的锁记录主动遍历 T1 的栈帧(注意:此时对象头已被改为 INFLATING,如果 T1 恰好执行 monitorexit,会看到 INFLATING 并自旋等待,因此 T1 的栈是稳定的,T2 可以安全读取)。T2 统计 T1 栈中所有指向该锁对象的 Lock Record 数量,得到重入次数 count。
然后填充 ObjectMonitor:将 _owner 设置为 T1,将 _recursions 设置为 count(重入次数),将Lock Record中保存的原 Mark Word 中的 hashCode/age 等保存到 _header 字段,并将 _EntryList 初始化为空队列。至此,ObjectMonitor初始化完成。
最后,T2 执行最后一次 CAS,将对象头的MD从 INFLATING 修改为指向该 ObjectMonitor 的指针(lock=10),然后T2通过 ObjectMonitor::enter() 进入_cxq 去排队。
第四阶段:自旋线程的迁移(T2、T3 进入 _cxq)
当 T2 将对象头从 INFLATING 改为 10 后,所有自旋等待的线程都会从自旋中醒来:
| 线程 | 醒来后的动作 |
|---|---|
| T3(竞争线程)获取 | 重新读取 Mark Word,发现是 10(重量级锁)。立即退出 inflate(),复用这个现成的 ObjectMonitor,然后调用 ObjectMonitor::enter() 进入重量级锁竞争流程。 |
| T1(持有者)释放 | 重新读取 Mark Word,发现是 10(重量级锁)。放弃轻量级锁释放逻辑,转而调用 ObjectMonitor::exit(),若重入计数减一后为0则将 _owner 置为 null,并唤醒 _cxq/_EntryList 中的等待线程。 |
四、重量级锁的运作机制
只有当前持有锁的线程(_owner)才能操作 _EntryList。这是一个非常严格的规则,确保了队列操作的独占性和安全性。
1.线程进入_cxq队列
当一个线程(比如线程T2)尝试获取已被占用的锁时会被封装成一个 ObjectWaiter 节点。这个节点会被优先放入 _cxq(竞争队列) 中-。_cxq 是一个无锁的、LIFO(后进先出) 的单向链表,允许大量线程通过CAS操作快速、并发地进入,减少了竞争。放入 _cxq 的线程会被挂起(park),请注意,线程并不直接进入 _EntryList,_EntryList 中的线程都是从 _cxq “转移”过来的。这个“转移”动作,发生在持有锁的线程(比如线程A)释放锁时。当线程A执行完同步代码,准备释放锁时,它会检查 _EntryList 是否为空。如果为空,但 _cxq 不为空,线程A就会将整个 _cxq 队列转移到 _EntryList 中。
2._cxq转移到_EntryList
这是一个由锁的持有者线程执行的操作。_EntryList 是一个双向链表,并且为了保证操作的简单和快速,新转移来的节点通常被设计为以LIFO(后进先出)的方式插入到 _EntryList 的头部。
_cxq 与 _EntryList 的区别
| 特性 | _cxq (竞争队列) | _EntryList (入口列表) |
|---|---|---|
| 数据结构 | 无锁单向链表 (LIFO) | 双向链表- |
| 并发控制 | 无锁并发 (CAS) | 仅锁持有者可操作 |
| 线程状态 | 刚到达,已挂起 | 等待被唤醒的挂起线程- |
| 主要目的 | 快速入队,减少竞争- | 有序出队,便于持有者管理 |
3.锁释放时如何唤醒线程
当持有锁的线程A完全释放锁(_recursions 减为0)后,需要唤醒下一个等待线程。这个唤醒顺序由 JVM 参数 QMode 控制。核心逻辑是优先从 _EntryList 中获取线程进行唤醒。
常见的策略有:
默认(QMode=0):如果 _EntryList 不为空,则从 _EntryList 中取出一个线程唤醒-。如果 _EntryList 为空,则将 _cxq 中的线程全部转移到 _EntryList,再从其中唤醒一个。
QMode=1:将 _cxq 中的线程转移到 _EntryList,并唤醒 _EntryList 中的线程。
QMode=2:直接唤醒 _cxq 中的线程,不经过 _EntryList。
其他:存在更多复杂的策略,甚至可以将 _EntryList 中的线程转移到 _cxq-。
五、synchronized不同锁机制的核心区别
synchronized锁升级顺序为 偏向锁 -> 轻量级锁 -> 重量级锁,我们都知道性能消耗是随着锁升级越来越大的。通过上面的解析不难看出,偏向锁在没有锁竞争时,每次进入锁(包括重入或者释放或重新进入),都只需要比较MD的值,这是性能最好的。轻量级锁需要并发线程不断地自旋尝试CAS,多次自旋CAS需要消耗一些性能,但是始终是用户态运行,线程也始终没有挂掉,所以次之。而重量级锁,真正有了线程上下文切换的过程,线程竞争时直接挂起等待,后面又要被唤起,这带来的内核调度性能消耗是最大的。

355

被折叠的 条评论
为什么被折叠?



