Lesson 12 · 并发编程
synchronized 锁升级:偏向锁 → 轻量级锁 → 重量级锁
从一道面试题开始
面试官微微一笑,抛出了这个经典问题:
90% 的候选人会脱口而出:"synchronized 是重量级锁,ReentrantLock 是轻量级锁"。面试官心里已经给你打了标签——停留在 JDK 5 的认知。
事实上,从 JDK 6 开始,JVM 团队对 synchronized 做了翻天覆地的优化。引入了偏向锁、轻量级锁、锁膨胀等机制,让 synchronized 在大多数场景下的性能已经不输甚至优于 ReentrantLock。HotSpot 虚拟机甚至为此重写了大半个 monitor 模块。
这篇文章,我们就来拆解 synchronized 背后的 锁升级 机制——从一把"笨锁"进化成"智能锁"的全过程。读完之后,你会理解:
- 对象头里到底藏了什么?
- 为什么同一个 synchronized 关键字,性能差异可以高达 10 倍?
- 偏向锁为什么在 JDK 15 被废弃、JDK 18 被彻底移除?
发车之前,先问自己三个问题:
- Mark Word 有多少位?偏向锁时里面存的是什么?
- 锁能升级,能降级吗?
- ObjectMonitor 的 EntryList 和 WaitSet 有什么区别?
带着这些问题,我们从最底层的对象头开始。
对象头结构:锁信息藏在哪里?
在 HotSpot 虚拟机中,每一个 Java 对象在内存中都由三部分组成:对象头(Object Header)、实例数据(Instance Data)和对齐填充(Padding)。锁的所有状态信息,都存储在对象头的 Mark Word 里。
对象头的组成:
// 64 位 JVM 下的对象头结构
┌─────────────────────────────────────┐
│ Mark Word(8 字节 = 64 位) │ ← 锁状态、哈希码、GC 分代年龄
├─────────────────────────────────────┤
│ Klass Pointer(4/8 字节) │ ← 指向类元数据(开启压缩后 4 字节)
├─────────────────────────────────────┤
│ 数组长度(仅数组对象,4 字节) │ ← 如果是普通对象则没有这部分
└─────────────────────────────────────┘
重点在 Mark Word。它是一个 64 位的位图,不同锁状态下存储的内容完全不同:
注意一个细节:偏向锁和无锁的标志位都是 01,它们的区分靠第 3 低位(biased_lock 标志)。这个设计节省了标志位的空间。
偏向锁 (Biased Locking):一个人的独角戏
HotSpot 团队在大量真实应用中发现了一个关键观察:大多数锁在整个生命周期中只被同一个线程访问。比如一个 StringBuffer 对象几乎总是在同一个线程里被 append,一个连接池的内部状态通常由单个管理线程操作。
基于这个观察,偏向锁的设计思路非常直接:既然只有一个线程来,那就把锁"偏向"给它,后续访问连 CAS 都不需要做。
偏向锁的获取流程:
// 第一次进入 synchronized 块
if (markWord.bias_pattern == 1 && markWord.threadId == 0) {
// 对象可偏向,且还没有偏向任何线程
CAS(markWord.threadId, 0, currentThreadId); // CAS 写入当前线程 ID
return; // 加锁成功,几乎无开销
}
// 后续进入 synchronized 块
if (markWord.threadId == currentThreadId) {
return; // 直接放行!不需要任何原子操作
}
// 其他线程来竞争 → 偏向锁撤销 → 升级为轻量级锁
revokeBias();
关键优势在于 零 CAS 开销。传统加锁每次进入同步块都需要 CAS 操作,偏向锁只需要第一次 CAS,之后就是普通的内存读比较——这比 CAS 快一个数量级。
偏向锁的设计初衷是好的,但它带来了巨大的代码复杂度。HotSpot 中与偏向锁相关的代码超过 2 万行,涉及 GC、类加载、锁撤销等多个子系统的协调。
JEP 374(JDK 15)正式废弃了偏向锁,默认关闭(-XX:-UseBiasedLocking)。JDK 18 之后彻底移除。原因是:
- 现代应用更多是多线程并发,"只有一个线程访问"的场景变少
- 偏向锁撤销涉及 SafePoint,可能导致 STW 暂停
- 维护成本高,收益递减——轻量级锁本身已经很快了
面试时要说:偏向锁在 JDK 15+ 已被废弃,JDK 18 彻底移除,现代 JVM 从轻量级锁起步。
轻量级锁 (Lightweight Lock):短暂竞争的最优解
当偏向锁遇到竞争(另一个线程也来加锁),或者 JDK 15+ 直接跳过偏向锁阶段,锁就会进入轻量级锁状态。
轻量级锁的设计假设是:锁的竞争是短暂的——线程 A 马上就会释放,线程 B 只需要稍微"自旋"等一下就拿到了。这种情况下,不值得动用操作系统级别的线程挂起(这涉及用户态到内核态的切换,代价高昂)。
核心机制是 Lock Record(锁记录),它位于线程栈帧中:
// 每个线程栈帧中都有若干 Lock Record
// 用于在轻量级锁阶段保存 Mark Word 的备份
struct BasicLock {
volatile markOop displaced_header; // 保存对象原始的 Mark Word
};
// 加锁流程:
// 1. 在当前线程栈帧中创建一个 Lock Record
// 2. 把对象的 Mark Word 复制到 Lock Record 的 displaced_header
// 3. CAS 尝试将 Mark Word 替换为指向 Lock Record 的指针
// 4. CAS 成功 → 拿到锁(锁标志位变为 00)
// 5. CAS 失败 → 存在竞争 → 可能膨胀为重量级锁
轻量级锁的完整获取与释放序列:
线程交替执行(竞争短暂),同步块执行时间短。
性能比重量级锁高约 5 倍(无用户态/内核态切换)。
锁膨胀 (Lock Inflation):从轻到重的临界点
当轻量级锁的 CAS 操作失败——意味着有另一个线程也在尝试获取同一把锁,竞争已经从"短暂"变成了"持续"。此时 JVM 会执行锁膨胀,将轻量级锁升级为重量级锁。
膨胀过程不可逆(在绝大多数 JDK 版本中),这就是"锁升级"中"升级"的含义——一旦变重,就回不去了。
膨胀的核心步骤:
ObjectMonitor* inflate(Thread* self, oop obj) {
// 1. 为对象创建一个 ObjectMonitor(如果不存在)
ObjectMonitor* monitor = new ObjectMonitor();
// 2. 将对象的 Mark Word 从"指向 Lock Record"改为"指向 ObjectMonitor"
// 锁标志位从 00 变为 10
obj->set_mark(markOop::encode(monitor));
// 3. 将之前持有轻量级锁的线程放入 ObjectMonitor 的 Owner 字段
monitor->set_owner(lockRecordOwner);
// 4. 将当前竞争失败的线程放入 EntryList(阻塞队列)
monitor->enter(self); // 线程被 park(),进入内核态阻塞
return monitor;
}
理论上,当竞争消失后,重量级锁可以"降级"回轻量级锁以恢复性能。但实际上,HotSpot 的实现中锁降级非常有限。原因:
- 降级逻辑复杂——需要确认 EntryList 为空、没有线程在 wait() 等
- 降级本身有开销——CAS 修改 Mark Word、清理 ObjectMonitor
- 经验数据表明:竞争激烈过的锁,未来大概率还会竞争
JDK 8u20 之后引入了有限的锁降级,但只在安全点(SafePoint)批量进行,不是实时降级。
重量级锁 (Heavyweight Lock):ObjectMonitor 全解析
重量级锁的底层实现是 HotSpot 的 ObjectMonitor 对象。它管理着三组线程队列,并通过操作系统的 Mutex 和 Condition Variable 来实现线程的阻塞与唤醒。
来看 synchronized 编译后的字节码——每个 synchronized 块会被翻译为 monitorenter 和 monitorexit 指令:
// Java 源码
synchronized (obj) {
count++;
}
// 编译后的字节码(javap -c 输出)
0: aload_1 // 加载 obj 到操作数栈
1: dup // 复制一份引用(用于 finally 中的 monitorexit)
2: monitorenter // 尝试获取 monitor 锁
3: aload_0
4: dup
5: getfield #2 // 读取 count
8: iconst_1
9: iadd
10: putfield #2 // 写回 count++
13: aload_1
14: monitorexit // 正常退出,释放 monitor 锁
15: goto 23
18: astore_2 // 异常处理入口
19: aload_1
20: monitorexit // 异常退出,也要释放锁!
21: athrow // 重新抛出异常
23: return
注意字节码里有两个 monitorexit:一个是正常路径(第 14 行),一个是异常路径(第 20 行,由编译器自动生成的 finally 块)。这就是为什么 synchronized 能保证即使抛出异常,锁也一定会被释放。
| 操作 | 行为 | 涉及队列 |
|---|---|---|
| 加锁(monitorenter) | 如果 _owner 为空则获取;否则 park 线程 | cxq → EntryList |
| 解锁(monitorexit) | 释放 _owner,从 EntryList 唤醒一个线程 | EntryList 出队 |
| wait() | 释放 _owner,当前线程进入 WaitSet | → WaitSet |
| notify() | 从 WaitSet 移一个线程到 EntryList | WaitSet → EntryList |
| notifyAll() | 将 WaitSet 所有线程移到 EntryList | WaitSet → EntryList |
锁升级全景图:状态机总览
现在把前几站的内容串联起来,看完整的锁升级状态机:
无锁 → 偏向锁:第一次加锁,CAS 写入线程 ID
偏向锁 → 轻量级锁:另一个线程尝试加锁(撤销偏向)
轻量级锁 → 重量级锁:CAS 替换 Mark Word 失败(检测到真实竞争)
总结与面试应对
最后,把 synchronized 和 ReentrantLock 做一个全面对比——这是面试中的高频追问:
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM 内置(字节码指令) | JDK 层面(java.util.concurrent) |
| 锁释放 | 自动释放(字节码保证) | 必须手动 unlock()(通常在 finally 中) |
| 可中断 | 不可中断 | lockInterruptibly() 支持中断 |
| 超时获取 | 不支持 | tryLock(timeout) 支持 |
| 公平锁 | 不支持(非公平) | new ReentrantLock(true) 支持 |
| 条件变量 | 一个(Object.wait/notify) | 多个(Condition.await/signal) |
| JDK 6+ 性能 | 锁升级后接近 ReentrantLock | 基于 AQS + CAS |
| 可读性 | 简洁,关键字语法 | 稍复杂,需要 try-finally |
面试官问:"synchronized 和 ReentrantLock 有什么区别?"
建议回答结构:
- 先纠正误区:JDK 6 之后 synchronized 不再是重量级锁,引入了偏向锁、轻量级锁、锁膨胀机制
- 讲锁升级:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,升级不可逆
- 讲功能差异:ReentrantLock 提供可中断、超时、公平锁、多条件变量等高级功能
- 讲使用建议:优先用 synchronized(简洁 + 自动释放 + JVM 持续优化);需要高级功能时用 ReentrantLock
- 加分项:提到 JDK 15 废弃偏向锁(JEP 374),说明你关注技术演进
JDK 15+ 移除偏向锁后的影响:
// JDK 6 ~ 14:完整的锁升级链路
// 无锁 → 偏向锁 → 轻量级锁 → 重量级锁
// JDK 15(JEP 374):偏向锁默认关闭
// -XX:-UseBiasedLocking 成为默认值
// 实际升级链路变为:无锁 → 轻量级锁 → 重量级锁
// JDK 18+:偏向锁代码彻底移除
// 不再支持 -XX:+UseBiasedLocking
// 如果仍在使用偏向锁优化,需要评估是否迁移到 ReentrantLock
// 实践建议:
// ① 新项目直接用 JDK 17+,不需要关心偏向锁
// ② 老项目升级到 JDK 17 时,跑一遍 JMH 基准测试
// ③ 如果发现锁竞争密集型代码性能下降,考虑换成 ReentrantLock
核心知识点回顾
- Mark Word:64 位位图,根据锁状态存储不同内容(hashCode / 线程 ID / Lock Record 指针 / ObjectMonitor 指针)
- 偏向锁:将线程 ID 写入 Mark Word,后续零开销进入;JDK 15 废弃,JDK 18 移除
- 轻量级锁:Lock Record + CAS,适用于短暂竞争;比重量级锁快约 5 倍
- 锁膨胀:CAS 失败触发,轻量级锁升级为重量级锁,创建 ObjectMonitor
- ObjectMonitor:Owner + EntryList + WaitSet + cxq,管理阻塞和等待线程
- 锁升级单向:绝大多数 JDK 版本中膨胀不可逆
- 选型建议:优先 synchronized;需要可中断/超时/公平/多条件时用 ReentrantLock