Java 锁膨胀机制深度解析:从偏向锁到重量级锁的进化之路
在 Java 并发编程中,synchronized 关键字曾是性能低下的代名词。然而,从 JDK 1.6 开始,HotSpot 虚拟机对 synchronized 进行了大刀阔斧的优化,引入了偏向锁(Biased Locking) 、**轻量级锁(Lightweight Locking)和重量级锁(Heavyweight Locking)**的分级机制。
这种机制的核心思想是:"根据竞争程度,动态调整锁的粒度" 。在无竞争时尽量使用低成本操作,在有竞争时再升级为高成本但功能强大的锁。这一过程被称为锁膨胀(Lock Escalation)。
本文将深入剖析锁膨胀的全过程,揭示 JVM 如何在不同阶段进行极致优化。
一、基石:对象头(Object Header)与 Mark Word
要理解锁膨胀,首先必须理解 Java 对象在内存中的布局。每个 Java 对象在堆内存中都包含一个对象头(Object Header)。
在 64 位 JVM 开启压缩指针(默认开启)的情况下,对象头通常包含两部分:
- Mark Word(标记字) :存储对象的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有记录等。它是锁升级的关键载体。
- Klass Pointer(类型指针):指向方法区中类的元数据。
Mark Word 的结构(64 位,压缩指针):
| 大小 (bit) | 内容 | 说明 |
|---|---|---|
| 25 | Hash Code | 对象哈希值(未计算时为 0) |
| 4 | GC 分代年龄 | 对象在新生代存活的次数 |
| 1 | 偏向锁标志 | 0: 非偏向, 1: 偏向 |
| 2 | 锁标志 | 00: 无锁, 01: 偏向锁, 10: 轻量级锁, 11: 重量级锁 |
| ... | ... | ... |
关键点 :锁的状态信息直接存储在对象头的 Mark Word 中。锁的升级过程,本质上就是修改 Mark Word 内容的过程。
注意:从 JDK 15 开始,偏向锁被标记为废弃(Deprecated),JDK 18 中默认禁用。但在理解 JVM 历史演进和底层原理时,偏向锁依然是不可或缺的一环。下文将以经典模型为主,并在最后说明新版本的变化。
二、第一阶段:偏向锁(Biased Locking)------ "假设没有竞争"
2.1 核心思想
场景 :绝大多数锁在大部分时间内都是由同一个线程 重复获取的(例如单线程执行同步代码块,或线程封闭的对象)。
优化策略 :如果一个线程获得了锁,那么它就"偏向"于该线程。当该线程再次进入同步块时,无需进行任何原子操作(CAS),只需检查 Mark Word 中记录的线程 ID 是否为自己即可。
2.2 实现细节
- 初始状态 :对象创建时,Mark Word 的锁标志位为
01,偏向锁标志位为1(可偏向),但此时尚未绑定具体线程。 - 首次获取 :
- 线程 A 进入同步块。
- JVM 发现是可偏向锁,通过 CAS 操作 将线程 A 的 ID 写入 Mark Word。
- CAS 成功,线程 A 获得锁。
- 重入 :
- 线程 A 再次进入。
- JVM 检查 Mark Word 中的线程 ID,发现就是自己。
- 直接通过,无任何额外开销。
- 偏向撤销(Revoke) :
- 如果线程 B 试图获取该锁(发生竞争)。
- JVM 会暂停拥有锁的线程 A(SafePoint),检查线程 A 是否还在使用该锁。
- 若线程 A 已退出或未使用,直接将 Mark Word 重置为无锁状态或轻量级锁状态。
- 若线程 A 仍在使用,则触发偏向锁撤销 ,升级为轻量级锁。
性能收益:在无竞争的单线程场景下,偏向锁消除了所有同步原语(CAS、互斥量)的开销,性能接近非同步代码。
三、第二阶段:轻量级锁(Lightweight Locking)------ "自旋避免阻塞"
3.1 核心思想
场景 :存在轻微的竞争,但线程持有锁的时间很短。
优化策略:不使用操作系统层面的互斥量(Mutex),而是让用户态的线程通过**自旋(Spinning)**等待锁释放。因为线程切换(用户态->内核态)的开销远大于短时间自旋的 CPU 消耗。
3.2 实现细节:锁记录(Lock Record)
轻量级锁依赖栈帧中的锁记录(Displaced Mark Word)。
- 锁膨胀触发 :
- 偏向锁被撤销,或者对象一开始就不可偏向。
- 线程尝试获取锁。
- 压栈 :
- JVM 在当前线程的栈帧中分配一块空间,称为锁记录。
- 将对象头中的 Mark Word 复制一份到锁记录中(称为 Displaced Mark Word)。
- CAS 替换 :
- 线程尝试使用 CAS 操作,将对象头的 Mark Word 替换为指向当前栈帧中锁记录的指针。
- 锁标志位变为
00(轻量级锁)。
- 结果判断 :
- 成功:线程获得锁,执行同步代码。
- 失败 :说明有其他线程竞争。
- 自旋:线程不会立即阻塞,而是循环检测对象头的 Mark Word 是否恢复(即锁是否被释放)。
- 自适应自旋:JDK 1.6+ 引入了自适应自旋。自旋时间不再固定,而是根据上一次在同一把锁上的自旋时间及锁拥有者的状态动态调整。如果上次自旋成功了,这次就多转几圈;如果失败了,就少转几圈。
- 升级阈值 :
- 如果自旋超过一定次数(默认 10 次,可通过
-XX:PreBlockSpin调整,新版由 JVM 自适应决定)仍未获取锁,或者竞争线程数超过 1 个(多于一人争抢),锁将膨胀为重量级锁。
- 如果自旋超过一定次数(默认 10 次,可通过
性能收益:避免了用户态到内核态的切换,适合锁占用时间极短的场景。
四、第三阶段:重量级锁(Heavyweight Locking)------ "最后的防线"
4.1 核心思想
场景 :竞争激烈,锁持有时间长,自旋浪费大量 CPU 资源。
优化策略 :使用操作系统底层的互斥量(Mutex)。线程获取不到锁时,直接挂起(阻塞),进入等待队列,让出 CPU 给其他线程。
4.2 实现细节
- 锁膨胀 :
- 对象头的锁标志位变为
10。 - Mark Word 中存储指向 ObjectMonitor(C++ 实现的监视器)的指针。
- 对象头的锁标志位变为
- 争夺过程 :
- 线程尝试获取锁失败后,被封装成
ObjectWaiter节点,放入 EntryList(等待队列)。 - 线程状态从
RUNNABLE变为BLOCKED,操作系统挂起该线程。 - 当锁持有者释放锁时,JVM 会从 EntryList 中唤醒一个线程(或根据策略唤醒多个),使其重新竞争。
- 线程尝试获取锁失败后,被封装成
- 开销 :
- 涉及用户态与内核态的切换,上下文切换成本高。
- 但保证了在高竞争下不会空转浪费 CPU。
五、锁的降级?不存在的!
这是一个常见的误区:锁只能升级,不能降级。
- 升级路径:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
- 为何不降级?
- 如果在运行过程中将重量级锁降级为轻量级锁,需要保证全局没有其他线程在竞争,这在多线程环境下极难安全地判断和维护。
- 为了简化实现并保证安全性,一旦锁膨胀为重量级锁,直到对象被垃圾回收之前,它将一直保持重量级锁状态(即使后续没有竞争了)。
六、新版本的变化:偏向锁的退场
虽然上述"三级锁"模型是经典的面试考点和原理基础,但在实际生产环境的新版本 JDK 中,情况有所变化:
- JDK 15 :偏向锁被标记为
Deprecated。 - JDK 18 :偏向锁默认禁用 (可以通过
-XX:+UseBiasedLocking强制开启,但不推荐)。
原因:
- 现代应用架构变化:现代应用多为多线程并发,真正的"单线程重复获取锁"场景变少。
- 撤销开销大:一旦发生竞争,偏向锁的撤销(Revoke)需要 Stop-The-World(STW),暂停所有线程来扫描栈帧,这个开销在某些高并发场景下反而比直接使用轻量级锁更大。
- 简化逻辑:移除偏向锁可以简化 JVM 内部复杂的锁状态机逻辑。
现状 :在现代 JDK 中,锁的升级路径通常简化为:无锁 -> 轻量级锁(自旋) -> 重量级锁。
七、总结与启示
JVM 的锁膨胀机制体现了计算机科学中经典的**"以空间换时间"和"分级处理"**思想:
| 锁状态 | 适用场景 | 核心优化手段 | 代价 |
|---|---|---|---|
| 偏向锁 | 单线程重入 | 记录线程 ID,无 CAS,无同步开销 | 撤销时需 STW |
| 轻量级锁 | 低竞争,短时持有 | CAS + 自旋(避免内核切换) | 占用栈空间,自旋耗 CPU |
| 重量级锁 | 高竞争,长时持有 | 操作系统 Mutex,线程阻塞挂起 | 用户态/内核态切换开销大 |
给开发者的建议:
- 减少锁粒度:尽量缩小同步代码块的范围,缩短持锁时间,让锁停留在轻量级阶段。
- 避免过度优化:不要手动强行指定锁类型,信任 JVM 的动态调整能力(除非你有极其特殊的性能分析数据)。
- 关注新版特性 :如果使用 JDK 15+,不必再纠结偏向锁的调优,应更多关注
CompletableFuture、StampedLock或ReentrantReadWriteLock等更高级的并发工具。 - 排查死锁 :重量级锁一旦形成等待队列,极易引发死锁,务必使用
jstack等工具定期监控。
理解锁膨胀机制,不仅是为了应对面试,更是为了在编写高并发代码时,心中有一幅清晰的"性能地图",知道每一行 synchronized 背后,JVM 正在为你做什么。