引子:从一行代码开始
csharp
Object lock = new Object();
synchronized (lock) {
// 临界区
}
当多个线程执行这段代码时,JVM 并不是简单地"让它们排队"。 它会根据竞争程度,动态选择最合适的同步策略------从零开销到操作系统介入,全程自动演进。
这个过程,就是 偏向锁 → 轻量级锁 → 重量级锁 的升级链路。 而这一切,都围绕着 Java 对象头中的 Mark Word 展开。
第一步:对象刚创建 ------ 无锁状态
每个 Java 对象在内存中都有一个 对象头(Object Header),其中最关键的是 Mark Word(64 位 JVM 占 8 字节)。
初始状态下,Mark Word 存储对象的 hashCode、分代年龄等信息:
ini
| hashCode (25 bits) | age (4) | biased_lock=0 | lock=01 |
此时,对象处于 无锁(Normal) 状态。
第二步:第一个线程进入 ------ 偏向锁(Biased Locking)
假设线程 A 首次执行 synchronized(lock)
。
JVM 发现:
- 对象无锁;
- 无竞争(只有线程 A 访问);
- JVM 启用了偏向锁(JDK 8 默认开启)。
于是,JVM 尝试将线程 A 的 ID 写入 Mark Word:
scss
| threadID (54 bits) | epoch (2) | age (4) | biased_lock=1 | lock=01 |
✅ 从此以后,只要线程 A 再次进入同步块,JVM 只需比对 threadID,匹配就直接放行------无需任何 CAS、无需 OS 介入,开销几乎为零。
🎯 偏向锁的设计哲学:大多数同步代码,其实只有一个线程在反复执行。
第三步:第二个线程到来 ------ 撤销偏向,升级轻量级锁
现在,线程 B 也执行 synchronized(lock)
。
JVM 检查 Mark Word,发现:
biased_lock=1
;- 但
threadID ≠ B
→ 存在竞争!
于是,JVM 在安全点(Safepoint) 暂停线程 A,检查它是否还在同步块中:
- 如果已退出 → 直接撤销偏向,恢复无锁;
- 如果仍在执行 → 撤销偏向锁,进入轻量级锁流程。
轻量级锁怎么做?
- 线程 B 在自己的 Java 栈帧中创建一个 Lock Record;
- Lock Record 保存对象原来的 Mark Word(称为 Displaced Mark);
- CAS 尝试将对象头 Mark Word 替换为指向 Lock Record 的指针;
- 如果成功,线程 B 获得锁;
- 如果失败(比如线程 A 也在竞争),则自旋重试(默认 10 次)。
此时 Mark Word 变为:
ini
| ptr_to_LockRecord (62 bits) | lock=00 |
✅ 轻量级锁仍在用户态完成,避免昂贵的 OS 线程挂起。
第四步:高并发竞争 ------ 膨胀为重量级锁
如果线程 C 也加入竞争,且线程 B 自旋多次仍无法获得锁,JVM 会做出关键决策:
"用户态竞争成本已高于内核态挂起,是时候升级了。"
于是,JVM 执行 锁膨胀(Inflation):
- 在堆中创建一个 ObjectMonitor 对象(C++ 实现);
- 将对象头 Mark Word 改为 指向 ObjectMonitor 的指针;
- Mark Word 变为:
ObjectMonitor 的核心结构:
arduino
class ObjectMonitor {
void* _owner; // 当前持有锁的线程
ObjectWaiter* _EntryList; // 阻塞队列:等待获取锁的线程
ObjectWaiter* _WaitSet; // 调用 wait() 的线程集合
...
};
- 线程 B 成为
_owner
; - 线程 C 被封装为
ObjectWaiter
,加入_EntryList
; - JVM 调用 OS 的
futex
或pthread_mutex_lock
,将线程 C 挂起(park),不再占用 CPU。
✅ 此时,锁已从"自旋等待"变为"操作系统级阻塞",适合高并发、长临界区场景。
第五步:锁释放与线程唤醒
当线程 B 执行完临界区,执行 monitorexit
:
- JVM 将 ObjectMonitor 的
_owner = null
; - 检查
_EntryList
是否非空; - 如果有等待线程(如线程 C),从
_EntryList
中取出一个; - 调用 OS 的
futex_wake()
,唤醒线程 C; - 线程 C 被调度执行,重新竞争 ObjectMonitor → 成功获得锁。
🌟 关键点:线程不是"轮询"锁是否释放,而是被操作系统精准唤醒。
第六步:内存可见性 ------ JMM 的隐性契约
你可能以为 synchronized
只是"排队",但它还做了更重要的事:
保证释放锁前的修改,对后续获得锁的线程可见。
这是 Java 内存模型(JMM) 的核心规则之一:
对同一个 Monitor,unlock happens-before 后续的 lock。
底层如何实现?
monitorexit
时,JVM 插入 内存屏障(Memory Barrier),强制将 CPU 缓存中的修改 flush 到主内存;monitorenter
时,使当前 CPU 缓存失效,从主内存重新加载共享变量。
✅ 所以,线程 C 进入同步块后,一定能读到线程 B 修改的最新值。
第七步:CAS 的角色 ------ 轻量级锁的引擎
在整个过程中,CAS(Compare-And-Swap) 是轻量级锁的核心:
- 它是一条 CPU 原子指令,由硬件保证不可分割;
- 形式:
CAS(address, expected, new)
; - 轻量级锁通过 CAS 尝试替换 Mark Word,避免 OS 介入;
- 如果失败,就重试(自旋)------这就是 无锁并发(Lock-Free) 的思想。
⚠️ 但 CAS 不适合复合操作(如"扣库存 + 发消息"),此时仍需重量级锁或事务。
总结:一条完整的执行链路
css
线程 A 首次进入 → 偏向锁(Mark Word 记录 threadID)
↓
线程 B 竞争 → 撤销偏向 → 轻量级锁(CAS + Lock Record)
↓
线程 C 加入 → 自旋失败 → 膨胀为重量级锁(ObjectMonitor)
↓
线程 B 退出 → 唤醒 _EntryList 中的线程 C
↓
线程 C 获得锁 → 通过 JMM 看到最新内存值