JDK 8 synchronized 设计与锁状态转换
从 Mark Word、Lock Record 与 ObjectMonitor 出发,系统理解 JDK 8 HotSpot 中偏向锁、轻量级锁、重量级锁的获取、撤销、膨胀和释放过程。
一、先给出整体结论
synchronized 是 Java 语言提供的内置同步机制。它在语义上负责三件事:
- 同一时刻只允许满足条件的线程进入临界区;
- 支持同一线程重复获取同一把锁,也就是可重入;
- 解锁 happens-before 后续对同一 Monitor 的加锁,保证相关共享数据的可见性和有序性。
在 JDK 8 HotSpot 中,为了避免所有同步操作都直接阻塞线程,JVM 会根据竞争情况使用不同实现:
偏向锁:同一个线程反复进入,尽量避免原子操作
轻量级锁:线程交替执行或短暂竞争,使用栈上 Lock Record 和 CAS
重量级锁:竞争较强或必须使用 Monitor 能力时,通过 ObjectMonitor 管理等待线程
通常所说的“无锁、偏向锁、轻量级锁、重量级锁”,是对对象头及其关联同步结构的概括,不是 Java 语言规范定义的四种锁类型。
图中信息较多,可点击图片查看原图。
需要先纠正一个常见说法:
“锁只能升级,不能降级”适合帮助理解竞争路径,但并不严谨。
轻量级锁正常释放后会通过 CAS 恢复对象原来的 Mark Word;已经膨胀的 ObjectMonitor 也可能在后续安全点被 JVM 回收。更准确的说法是:持锁竞争过程中通常不会为了当前请求立即从重量级锁退回轻量级锁,但对象状态并非终身只能单向变化。
二、synchronized 在字节码中的入口
同步代码块通常编译为:
monitorenter
临界区代码
monitorexit
编译器会为正常返回和异常退出路径生成对应的 monitorexit,保证退出同步块时释放锁。
同步方法不会在方法体中出现这两个指令,而是在方法访问标志中使用:
ACC_SYNCHRONIZED
JVM 在调用和返回同步方法时完成 Monitor 的进入与退出。
无论入口是哪一种,最终都围绕同一个锁对象工作:
| 写法 | 实际锁对象 |
|---|---|
| 实例同步方法 | 当前实例 this |
| 静态同步方法 | 当前类对应的 Class 对象 |
| 同步代码块 | synchronized (...) 中指定的对象 |
三、对象头与 Mark Word
普通 Java 对象的对象头主要包含:
Mark Word
Klass Pointer
数组对象还会额外保存数组长度。synchronized 的关键状态主要编码在 Mark Word 中。
以 JDK 8、64 位 HotSpot 为例,Mark Word 会复用同一段空间:
可点击图片查看完整尺寸。
统一按最低 3 位阅读
为了避免一会儿看 2 位、一会儿看 3 位,本文后续统一展示 Mark Word 的最低 3 位。但要先区分“展示宽度”和“字段含义”:
- 真正的锁标志始终是最低 2 位;
- 只有普通未锁定和偏向布局会把倒数第 3 位解释成“偏向位”;
- 轻量级锁、重量级锁保存的是对齐后的指针,本文把其最低 3 位也完整写出,但倒数第 3 位不再表示偏向。
| 最低 3 位 | 最低 2 位锁标志 | 状态 | 倒数第 3 位如何解释 |
|---|---|---|---|
001 | 01 | 普通未锁定 | 偏向位为 0 |
101 | 01 | 可偏向或已偏向 | 偏向位为 1 |
000 | 00 | 轻量级锁 | 对齐后的 Lock Record 指针低位,不是偏向位 |
010 | 10 | 重量级锁 | 对齐后的 ObjectMonitor 指针低位,不是偏向位 |
011 | 11 | GC 标记 | 标记状态的一部分,不是偏向位 |
因此 101 应当拆开读成:
偏向位 1 | 锁标志 01
它不是一个三位的“锁标志”。本文使用三位只是为了让所有状态在图表中对齐、便于比较。
其中偏向状态可以继续区分:
匿名偏向:线程指针为空,允许第一个线程认领
已偏向:线程指针指向获得偏向的 JavaThread
Mark Word 是一块被复用的空间。例如普通未锁定对象可以直接保存 identity hash;偏向对象需要保存线程指针,因此二者不能同时使用同一布局。
epoch 到底是什么
epoch 可以理解为某个类的“偏向版本号”,它不是时间戳,也不是对象年龄。
偏向锁不只依赖对象自身。支持偏向的类会在类的 prototype header 中保存一个当前 epoch;每个偏向对象的 Mark Word 中也会记录对象认领时使用的 epoch。线程进入偏向对象时,会比较两者:
对象 epoch == 类当前 epoch
→ 这份偏向记录仍属于当前版本,继续检查偏向线程
对象 epoch != 类当前 epoch
→ 这份偏向记录已经过期,可以进入重偏向或撤销流程
它主要服务于批量重偏向。当一批对象从线程 A 整体转交给线程 B 使用时,JVM 不必立即逐个改写所有对象头,而是先提升类的 epoch。旧对象之后被访问时,会因为 epoch 不匹配而按新版本重新处理。
所以需要把两个容易混淆的字段分开:
| 字段 | 含义 |
|---|---|
age | 对象的 GC 年龄,用于分代回收 |
epoch | 类的偏向版本,用于判断对象上的偏向记录是否过期 |
四、JDK 8 为什么需要三种锁实现
不同竞争场景适合不同成本的同步方式。
只有一个线程反复进入
如果一个对象长期只被同一个线程加锁,每次都执行 CAS 仍然是额外成本。偏向锁把对象与线程关联起来,后续同一线程进入时主要检查对象头是否仍偏向自己。
多线程交替执行,但没有同时竞争
例如线程 A 使用完后线程 B 才进入。此时没有必要挂起线程,可以通过栈上 Lock Record 和 CAS 完成加锁、解锁。
多线程同时争抢或需要等待队列
当 CAS 快速路径无法解决竞争,或者调用 wait() 需要条件等待集合时,JVM 使用 ObjectMonitor 管理 Owner、竞争线程和等待线程。
所以这三种实现的目标不是简单比较“谁更高级”,而是:
根据实际竞争强度,在原子操作、CPU 自旋、线程阻塞和唤醒成本之间做权衡。
五、对象初始状态:普通未锁定还是匿名偏向
JDK 8 默认启用偏向锁,但默认存在启动延迟:
-XX:+UseBiasedLocking
-XX:BiasedLockingStartupDelay=4000
因此需要区分对象创建时机:
偏向锁尚未启用或被关闭
新对象通常使用普通未锁定布局:
[identity hash | age | 0 | 01]
线程第一次进入同步块时,直接尝试建立轻量级锁。
偏向锁已经启用
支持偏向的类创建新对象时,对象通常处于匿名偏向状态:
[0 | epoch | age | 1 | 01]
因此严格来说,不应把这条路径描述为:
新对象先是无锁,然后第一次进入才“升级”为可偏向状态
更准确的说法是:对象创建时便根据类的 prototype header 获得普通未锁定或匿名偏向的 Mark Word。
六、偏向锁的获取与重入
线程进入一个匿名偏向对象时,会尝试通过 CAS 把自己的 JavaThread*、类当前的 epoch 等信息写入 Mark Word。
成功后,对象变成:
[当前 JavaThread* | epoch | age | 1 | 01]
同一个线程以后再次进入时,主要检查:
- Mark Word 是否仍是偏向模式;
- 偏向线程是否是当前线程;
- 对象 epoch 是否仍与类的 prototype header 匹配。
全部满足时可直接进入,不需要再次通过 CAS 抢占锁。
偏向锁退出为什么不清除线程信息
偏向锁针对的就是“同一个线程还会再次进入”这一场景。同步块结束后,Mark Word 通常仍保留对原线程的偏向:
进入前:偏向线程 A
执行中:偏向线程 A
退出后:仍偏向线程 A
它不是表示线程 A 永远占用临界区,而是表示下一次线程 A 再进入时可以走低成本路径。
偏向锁如何实现可重入
对象头记录了偏向线程,但不通过一个公共计数器记录每次进入。JVM 可以结合当前线程栈上的锁记录识别同步嵌套。对同一偏向线程而言,重复进入不需要竞争性地修改对象头。
七、其他线程访问偏向对象时发生什么
线程 B 遇到偏向线程 A 的对象时,不能简单地直接覆盖线程指针。JVM 需要先判断原偏向是否仍然有效。
可能出现以下路径。
1. epoch 已过期
类发生批量重偏向后,旧对象的 epoch 可能落后于类的 epoch。线程 B 可以尝试通过 CAS 将对象重新偏向自己。
2. 原线程已经不再持有这把锁
JVM 可以撤销原偏向,使对象恢复为普通未锁定状态,或者在允许的情况下重新偏向新线程。
3. 原线程仍在同步块中
JVM 需要检查原线程栈,撤销偏向并重建合法的锁状态。如果此时存在实际竞争,后续会进入轻量级竞争或直接膨胀为 ObjectMonitor。
因此:
另一个线程访问偏向对象,不等于必然直接升级为重量级锁。是否重偏向、撤销为普通状态、转换为轻量级锁或膨胀,需要结合 epoch、类级启发式统计和原线程是否仍持锁判断。
八、批量重偏向与批量撤销
如果某个类的大量对象不断被不同线程使用,逐个在安全点撤销偏向的成本会很高。HotSpot 会对同一类的偏向撤销次数做启发式统计。
批量重偏向
当撤销达到一定阈值时,JVM 可以提升类 prototype header 中的 epoch。旧对象不需要立刻逐个修改;之后线程发现对象 epoch 过期时,再尝试将它偏向当前线程。
适合这种模式:
一批对象先由线程 A 使用
之后整体交给线程 B 使用
但同一时刻竞争并不强
批量撤销
如果该类持续表现出多线程竞争,偏向锁已经不再适合,JVM 可以撤销该类的偏向能力。之后新对象不再默认可偏向,已有对象也会按正常锁路径处理。
这也是为什么偏向锁不能只按“对象自身的四级升级”理解:它还存在以类为粒度的 prototype header、epoch 和批量启发式机制。
九、普通未锁定到轻量级锁
当对象处于普通未锁定状态,线程进入同步块时会在当前栈帧创建锁记录,HotSpot 源码中对应 BasicLock,解释器栈中通常由 BasicObjectLock 将对象引用和 BasicLock 组织在一起。
概念流程如下:
1. 在线程栈创建 Lock Record
2. 将对象原 Mark Word 保存到 displaced header
3. CAS 修改对象 Mark Word
4. 让 Mark Word 指向当前 Lock Record
CAS 成功后:
对象 Mark Word → 线程栈 Lock Record
Lock Record → 保存原 Mark Word
对象头低两位为:
00
这就是通常所说的轻量级锁或栈锁。
为什么要保存 displaced header
轻量级锁占用了对象原来的 Mark Word。解锁时需要把原 Mark Word 恢复回对象头,因此先把它保存到 Lock Record。
十、轻量级锁的可重入
同一个线程再次获取已经由自己栈锁定的对象时,JVM 会识别对象头中的 Lock Record 指针属于当前线程栈。
此时新的 Lock Record 会使用特殊值表示递归进入,例如 displaced header 置空,而不是再次覆盖对象头。
退出时:
递归层 Lock Record:只退出当前递归层
最外层 Lock Record:负责真正恢复对象头
这解释了为什么 synchronized 天然支持可重入。
十一、轻量级锁如何释放
最外层同步块退出时,线程使用 CAS 尝试把 Lock Record 中保存的 displaced header 恢复到对象 Mark Word:
期望值:对象头仍指向当前 Lock Record
新值:进入同步块前保存的原 Mark Word
CAS 成功:
轻量级锁正常释放
对象恢复普通未锁定状态
CAS 失败通常意味着对象已在竞争过程中膨胀,不能再直接把原对象头覆盖回去,需要走 ObjectMonitor 的退出逻辑。
这就是“锁只能升级不能降级”表述不够准确的直接例子:没有发生膨胀的轻量级锁,退出后会恢复原 Mark Word。
十二、轻量级锁何时膨胀
线程尝试用 CAS 建立轻量级锁失败,说明对象头已经发生变化。可能原因包括:
- 另一个线程持有轻量级锁;
- 对象已经膨胀为 ObjectMonitor;
- 竞争期间其他线程正在处理锁状态;
- 必须执行依赖 ObjectMonitor 的操作。
JVM 可以先进行短暂自旋,希望持锁线程很快退出。自旋避免了线程立即挂起和恢复,但会消耗 CPU,因此只适合临界区较短的情况。
当快速路径不能解决问题时,JVM 会执行 Monitor inflation:
创建或取得 ObjectMonitor
复制并保存原 Mark Word
将对象头改为指向 ObjectMonitor
由 ObjectMonitor 负责后续竞争
对象头低两位变成:
10
十三、重量级锁与 ObjectMonitor
重量级状态下,对象 Mark Word 指向一个 ObjectMonitor。理解它时重点关注以下字段或逻辑角色:
_owner 当前持有 Monitor 的线程或相关锁记录
_recursions 重入次数
_cxq 新到达竞争线程形成的竞争队列
_EntryList 等待重新竞争 Owner 的线程
_WaitSet 调用 wait 后等待通知的线程
_header 膨胀前保存的对象 Mark Word
概念结构:
对象 Mark Word
│
▼
ObjectMonitor
├─ Owner
├─ Recursions
├─ cxq / EntryList
├─ WaitSet
└─ displaced header
竞争线程可能先自旋尝试获得 Owner。仍然失败时,线程会进入等待结构并被挂起;持有线程退出后,Monitor 选择或唤醒后继线程继续竞争。
“重量级”的成本主要来自:
- 维护竞争和等待队列;
- 线程阻塞、唤醒与调度;
- 高竞争下的上下文切换;
- 对 Monitor 状态的原子协调。
它并不意味着每次操作都必然立刻执行一次昂贵的系统调用,HotSpot 仍包含快速路径和自适应自旋等优化。
十四、wait、notify 为什么会涉及重量级 Monitor
wait() 的语义不是普通锁竞争:
确认当前线程持有 Monitor
完整释放锁及其重入层数
进入 WaitSet
等待 notify、notifyAll、中断或超时
重新参与锁竞争
恢复原重入状态后返回
这需要 Owner、WaitSet、EntryList、重入次数等完整 Monitor 能力,因此 JDK 8 HotSpot 执行 wait() 时会确保对象已经膨胀为 ObjectMonitor。
notify() 和 notifyAll() 存在一个细节:如果对象当前只是由调用线程栈锁定,那么它不可能已经拥有 WaitSet,HotSpot 可以直接返回而不做无意义的膨胀;否则会取得或膨胀 ObjectMonitor,再处理等待线程。
notify() 只是把等待线程从“等待条件”推进到“可以重新竞争锁”的阶段,并不会让它绕过当前 Owner 立即执行。
十五、identityHashCode 对锁状态的影响
普通未锁定对象可以把 identity hash 保存在 Mark Word 中,但偏向锁的 Mark Word 需要保存 JavaThread 指针。
因此,对偏向对象计算:
System.identityHashCode(obj);
通常会导致偏向撤销,使对象头能够保存 hash。
如果对象正在使用轻量级锁,原 Mark Word 已经保存在当前线程栈的 Lock Record 中。为了稳定保存 identity hash,并让其他线程可靠读取,HotSpot 的相关路径可能把对象膨胀为 ObjectMonitor,再把 hash 保存在 Monitor 保存的 header 中。
所以观察锁升级实验时,不要在不知情的情况下调用可能计算 identity hash 的方法,否则实验本身会改变对象头。
十六、完整状态转换表
| 当前状态 | 触发条件 | 可能结果 |
|---|---|---|
普通未锁定 001 | 首次进入同步块 | CAS 成功后成为轻量级锁 |
匿名偏向 101 | 第一个线程进入 | CAS 写入线程指针,成为已偏向状态 |
已偏向 101 | 原线程再次进入 | 保持偏向,低成本重入 |
已偏向 101 | 新线程访问且 epoch 过期 | 尝试重偏向或撤销 |
已偏向 101 | 新线程访问,原线程未持锁 | 撤销为普通状态,或按策略重偏向 |
已偏向 101 | 新线程访问,原线程仍持锁 | 撤销偏向,转轻量级或膨胀 |
轻量级 00 | 当前线程重入 | 新增递归 Lock Record,保持轻量级 |
轻量级 00 | 无竞争退出 | CAS 恢复原 Mark Word |
轻量级 00 | 竞争持续或恢复对象头失败 | 膨胀为 ObjectMonitor |
| 任意适用状态 | wait() 等需要完整 Monitor 语义 | 膨胀为 ObjectMonitor |
| 偏向/轻量级 | 计算 identity hash 等特殊场景 | 撤销偏向或膨胀 |
重量级 10 | Owner 退出 | 唤醒/选择后继;对象仍可保持膨胀 |
| 空闲 ObjectMonitor | 后续安全点清理 | JVM 可能执行 Monitor deflation |
十七、三条典型执行路径
路径一:同一个线程反复进入
匿名偏向
→ CAS 偏向线程 A
→ A 执行同步块
→ A 退出但保留偏向
→ A 再次进入,快速命中
路径二:线程交替使用,没有重叠竞争
偏向关闭或偏向已撤销时:
普通未锁定
→ A 建立轻量级锁
→ A CAS 恢复对象头
→ B 建立轻量级锁
→ B CAS 恢复对象头
这种场景没有必要让线程进入阻塞等待。
路径三:多线程同时竞争
A 持有轻量级锁
→ B CAS 失败并短暂自旋
→ A 未及时释放
→ 对象膨胀为 ObjectMonitor
→ B 进入竞争/等待结构
→ A 退出并推进后继竞争
十八、JOL 验证时要注意什么
可以使用 JOL 查看对象布局:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
示例:
Object lock = new Object();
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
实验时要控制以下变量:
- 明确使用 JDK 8 HotSpot;
- 记录是否开启
UseBiasedLocking; - 记录
BiasedLockingStartupDelay; - 避免日志、调试器或工具意外计算 identity hash;
- JOL 输出会受 32/64 位、压缩指针和 GC 配置影响;
- 线程已经终止不代表任何场景都一定重偏向,仍要看 epoch 和撤销策略。
为了避免默认 4 秒延迟影响实验,可以显式设置:
-XX:+UseBiasedLocking
-XX:BiasedLockingStartupDelay=0
关闭偏向锁、直接观察轻量级路径:
-XX:-UseBiasedLocking
十九、常见误区
误区一:新对象一定先是无锁,再升级为偏向锁
不准确。偏向能力启用后,支持偏向的类可以让新对象直接使用匿名偏向的 prototype header。
误区二:第二个线程出现就一定升级为重量级锁
不准确。它可能触发重偏向、偏向撤销、轻量级锁,也可能在存在实际竞争时膨胀。
误区三:轻量级锁就是一直自旋
不准确。轻量级锁的核心是栈上 Lock Record 与对象头 CAS;自旋是竞争时可能采用的等待优化,不是轻量级锁的完整定义。
误区四:重量级锁完全由操作系统 Mutex 等同实现
过度简化。ObjectMonitor 是 HotSpot 的 JVM 级同步结构,内部会使用 CAS、自旋、队列、park/unpark 等机制;线程最终阻塞和唤醒会依赖操作系统能力,但两者不能直接画等号。
误区五:锁一旦膨胀,这个对象终身都是重量级锁
不准确。当前竞争阶段通常不会立即降级,但空闲 Monitor 后续可能由 JVM 在安全点执行 deflation。
误区六:这套流程适用于所有 JDK
不准确。本文讨论的是 JDK 8 HotSpot。偏向锁后来被默认禁用,现代 HotSpot 的锁实现也持续演进,因此理解具体机制时必须先限定版本。
二十、参考资料
- JDK 8 HotSpot:markOop.hpp
- JDK 8 HotSpot:synchronizer.cpp
- JDK 8 HotSpot:biasedLocking.cpp
- JDK 8 HotSpot:objectMonitor.hpp
- 参考文章:浅析 synchronized 锁升级的原理与实现
评论与讨论
回复 :
留下你的想法