synchronized锁升级过程:偏向锁→轻量级锁→重量级锁
引言
想象一下,你正在开发一个高并发的秒杀系统。上线第一天,QPS 冲到 5000,一切正常;第二天,运营搞了个大促,QPS 冲到 20000,系统突然卡死。你排查发现,罪魁祸首竟然是 synchronized——这个你用了无数次的"老朋友"。
有意思的是,synchronized 在 JDK 8 之后已经做了大量优化,它不再是一开始就上重量级锁,而是根据竞争情况动态升级。但很多同学对这套升级机制的理解还停留在"重量级锁"的层面,导致在多线程编程中要么过度设计、要么用错场景。
今天,我们就从 HotSpot 源码层面,彻底讲清楚 synchronized 的锁升级全过程,并给出可落地的实战建议。
核心概念:先来点生活化的铺垫
在讲技术之前,我们先建一个生活模型。把锁竞争比作图书馆的一个自习座位:
- 无锁(无竞争):自习室空无一人,你来了直接坐,不需要任何登记。
- 偏向锁:你在座位上放了一本书,表示"这个位置是我的"。下次你再来,看到自己的书,直接坐下,不需要任何额外操作。但如果别人想把你的书挪开,你就得"收回"这本书(撤销偏向)。
- 轻量级锁:有人想抢你的座位,你把书拿走,改成"用笔在纸上写下自己的名字"。每次坐下前,先看纸上有没有名字;如果只有自己的名字,直接坐;如果发现别人的名字,说明有竞争。
- 重量级锁:抢座位的人越来越多,纸上的名字写不下了。你干脆请了一个管理员(OS Mutex),所有人来之前必须先找管理员登记排队,管理员统一协调。
这个类比映射到 JVM 里就是:
| 概念 | 生活类比 | 技术含义 |
|------|----------|----------|
| 偏向锁 | 座位上放书 | 同一线程重入时,无需任何 CAS 操作 |
| 轻量级锁 | 纸上签名 | 通过 CAS 自旋获取锁,避免线程阻塞 |
| 重量级锁 | 管理员登记 | 通过 OS Mutex 挂起/唤醒线程,涉及用户态和内核态切换 |
源码/原理深度分析
对象头的秘密
锁的状态信息存放在 Java 对象的 Mark Word 中(64 位 JVM 下占据 8 字节)。为了让你有个直观感受,下面是 64 位 HotSpot 中 Mark Word 的布局:
|--------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|--------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | 01 |
|--------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | 01 | 可偏向 |
|--------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | 00 | 轻量级锁 |
|--------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | 10 | 重量级锁 |
|--------------------------------------------------------------------|--------------------|
| | 11 |
|--------------------------------------------------------------------|--------------------|关键点:
- biased_lock=1 且 state=01:表示可偏向状态,此时 Mark Word 里存的是线程 ID。
- state=00:轻量级锁,Mark Word 里存的是栈中锁记录的指针。
- state=10:重量级锁,Mark Word 里存的是 Monitor 对象的指针。
HotSpot 源码中的锁升级路径
在 HotSpot 的 synchronizer.cpp 中,ObjectSynchronizer::enter() 方法定义了获取锁的完整路径。我把核心逻辑简化如下:
// HotSpot 源码简化逻辑 (synchronizer.cpp)
void ObjectSynchronizer::enter(Handle obj, BasicLock* lock, TRAPS) {
if (UseBiasedLocking) {
// 1. 尝试获取偏向锁
if (!SafepointSynchronize::is_at_safepoint()) {
BiasedLocking::revoke_and_rebias(obj, false, THREAD);
}
}
// 2. 如果偏向锁获取失败,膨胀为轻量级锁
if (!Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark) == success) {
// 3. 如果 CAS 失败,说明有竞争,膨胀为重量级锁
ObjectMonitor* monitor = inflate(THREAD, obj(), inflate_cause_monitor_enter);
monitor->enter(THREAD);
}
}偏向锁的撤销与重偏向
偏向锁的核心优化思路是:假设锁始终被同一个线程获取。但一旦发生竞争,偏向锁需要被撤销(revoke)。这个操作在 HotSpot 中叫 safe point 处的批量重偏向(bulk rebias)。
// biasedLocking.cpp 中的核心逻辑
void BiasedLocking::revoke_at_safepoint(Handle obj) {
// 1. 找到持有偏向锁的线程
JavaThread* biased_thread = ...;
// 2. 如果该线程存活
if (biased_thread->is_alive()) {
// 3. 在该线程的栈中找到对应的锁记录
// 4. 将 Mark Word 更新为轻量级锁状态或不可偏向状态
}
}注意:偏向锁的撤销必须等到 安全点(SafePoint),此时所有线程都暂停在安全位置。这也是为什么偏向锁在竞争激烈时反而会成为性能瓶颈——频繁的 SafePoint 会 STW(Stop The World)。
轻量级锁的自旋
轻量级锁的获取是通过 CAS + 自旋实现的。在 JDK 8 中,自旋次数可以通过 -XX:PreBlockSpin 调整,默认是 10 次。但从 JDK 9 开始,默认改成了自适应自旋(Adaptive Spinning),JVM 会根据之前的自旋成功率动态调整。
// 自适应自旋在 rotate_while_biased 中的体现
static int SpinPause() {
// pause 指令,告诉 CPU 当前处于自旋状态
__asm__ volatile ("pause");
}重量级锁的 Monitor
当竞争过于激烈时,锁会膨胀为重量级锁。重量级锁基于 ObjectMonitor 实现,它内部维护了一个 EntryList(等待队列)和 WaitSet(条件队列)。
class ObjectMonitor {
volatile markOop _mark; // Mark Word
ObjectWaiter* _WaitSet; // 调用 wait() 的线程
ObjectWaiter* _EntryList; // 等待获取锁的线程
Thread* _owner; // 当前持有锁的线程
};当一个线程获取重量级锁失败时,它会被封装成 ObjectWaiter 放入 _EntryList,然后通过 park() 挂起。当持有锁的线程释放锁时,会从 _EntryList 中唤醒一个线程。
实战代码:从理论到落地
示例 1:观察偏向锁的获取与撤销
import org.openjdk.jol.info.ClassLayout;
/**
* 使用 JOL (Java Object Layout) 观察对象头中的锁状态
* 运行参数:-XX:BiasedLockingStartupDelay=0
*/
public class BiasedLockDemo {
private static class LockObject {
// 空对象,仅用于观察锁状态
}
public static void main(String[] args) throws InterruptedException {
LockObject obj = new LockObject();
// 打印初始状态(应该处于无锁可偏向状态)
System.out.println("初始状态(无锁可偏向):");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
// 模拟线程 A 获取锁
Thread threadA = new Thread(() -> {
synchronized (obj) {
// 偏向锁:Mark Word 中会记录 Thread A 的 ID
System.out.println("线程 A 持有锁(偏向锁):");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
});
threadA.start();
threadA.join();
// 主线程尝试获取锁,此时会触发偏向锁撤销
synchronized (obj) {
System.out.println("主线程获取锁(偏向锁被撤销,升级为轻量级锁):");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
}运行结果分析:
- 第一次打印:
biased_lock=1,状态为可偏向。
- 线程 A 持有锁后:Mark Word 中记录了线程 A 的 ID。
- 主线程获取锁后:偏向锁被撤销,升级为轻量级锁(
state=00)。
注意:JOL 需要额外依赖,Maven 中引入:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>示例 2:轻量级锁的竞争与自旋
import org.openjdk.jol.info.ClassLayout;
/**
* 验证轻量级锁的自旋行为
* 运行参数:-XX:+UseSpinning -XX:PreBlockSpin=10
*/
public class LightweightLockDemo {
private static final Object LOCK = new Object();
public static void main(String[] args) throws InterruptedException {
// 预热阶段:让 JIT 完成编译
for (int i = 0; i < 10000; i++) {
synchronized (LOCK) {
// 空操作
}
}
// 模拟两个线程竞争,但竞争时间很短(自旋能成功)
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
System.out.println("线程1 持有锁(轻量级锁):");
System.out.println(ClassLayout.parseInstance(LOCK).toPrintable());
try {
// 持有锁 100ms,模拟短时间占用
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread t2 = new Thread(() -> {
try {
// 稍微延迟,确保 t1 先拿到锁
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (LOCK) {
System.out.println("线程2 通过自旋获取到锁(未膨胀为重量级锁):");
System.out.println(ClassLayout.parseInstance(LOCK).toPrintable());
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}关键点:
- 当 t2 尝试获取锁时,如果 t1 还在持有锁,t2 会先自旋等待。
- 如果 t1 在自旋次数内释放锁,t2 通过 CAS 成功获取锁,锁不会膨胀。
- 如果 t1 持有锁时间过长,自旋失败,锁会膨胀为重量级锁。
示例 3:重量级锁的线程阻塞与唤醒
/**
* 验证重量级锁的线程阻塞行为
* 运行参数:-XX:-UseSpinning 禁用自旋,强制膨胀
*/
public class HeavyweightLockDemo {
private static final Object LOCK = new Object();
public static void main(String[] args) {
// 线程1:持有锁很长时间
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
System.out.println("线程1 持有锁,模拟长时间占用(3秒)...");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
// 线程2:尝试获取锁,会阻塞
Thread t2 = new Thread(() -> {
long start = System.currentTimeMillis();
synchronized (LOCK) {
long cost = System.currentTimeMillis() - start;
System.out.println("线程2 获取锁成功,等待耗时: " + cost + "ms (重量级锁)");
}
});
t1.start();
// 确保 t1 先拿到锁
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
t2.start();
// 观察线程状态
try {
Thread.sleep(100);
System.out.println("线程2 状态: " + t2.getState()); // 应该是 BLOCKED
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}运行结果:
线程1 持有锁,模拟长时间占用(3秒)...
线程2 状态: BLOCKED
线程2 获取锁成功,等待耗时: 2999ms (重量级锁)线程2 的状态是 BLOCKED,说明它已经进入重量级锁的 EntryList,被 OS 挂起,等待线程1释放锁后被唤醒。
方案对比:synchronized vs ReentrantLock vs StampedLock
理解了 synchronized 的锁升级,我们把它和 Java 中的其他锁方案做个对比:
| 维度 | synchronized | ReentrantLock | StampedLock |
|------|--------------|---------------|-------------|
| 锁升级 | 支持(偏向→轻量→重量) | 不支持(直接是 AQS 队列) | 不支持(乐观读/写锁切换) |
| 可中断性 | 不支持 | 支持 | 不支持 |
| 公平性 | 非公平 | 可配置公平/非公平 | 非公平 |
| 性能(低竞争) | 最好(偏向锁) | 较好(CAS) | 好(乐观读) |
| 性能(高竞争) | 差(OS 阻塞) | 较好(AQS 队列) | 读多写少时最好 |
| 适用场景 | 简单同步块、低竞争 | 复杂并发控制、需要超时中断 | 读多写少、读写比例悬殊 |
最佳实践建议:
- 如果只是简单的同步方法/块,直接用
synchronized,JVM 会帮你优化。
- 如果需要可中断、超时、公平锁等特性,选
ReentrantLock。
- 如果是读多写少的场景(如缓存),优先考虑
StampedLock的乐观读。
最佳实践与避坑指南
四个常见坑
坑 1:偏向锁延迟启动
// JVM 默认启动后 4 秒才启用偏向锁
// 如果系统启动前 4 秒就有大量锁竞争,偏向锁优化失效
// 可以通过 -XX:BiasedLockingStartupDelay=0 关闭延迟坑 2:偏向锁撤销的 STW 风险
// 大量线程交替获取锁时,偏向锁会反复撤销和重偏向
// 每次撤销都需要 SafePoint(STW)
// 解决方案:-XX:-UseBiasedLocking 直接关闭偏向锁坑 3:轻量级锁自旋的 CPU 浪费
// 如果锁持有时间较长,自旋会浪费 CPU
// 自适应自旋会根据历史调整,但仍有风险
// 方案:合理设计临界区大小,避免在锁内做耗时操作坑 4:重量级锁的线程阻塞恢复
// 重量级锁的唤醒需要 OS 在用户态和内核态之间切换
// 频繁的阻塞/唤醒会导致系统调用开销巨大
// 方案:使用 LockSupport.park()/unpark() 时,注意减少切换频率三个核心实践建议
- 锁粒度要小:缩小临界区范围,减少锁持有时间。比如用局部变量代替成员变量,避免在锁内执行 I/O 操作。
- 锁粒度要准:如果多个线程操作的是不同对象,用不同锁实例,避免伪共享。
- 善用 JVM 参数调优:
# 关闭偏向锁(适合高竞争场景)
-XX:-UseBiasedLocking
# 调整自旋次数(JDK8 及以前)
-XX:PreBlockSpin=20
# 开启偏向锁批量重偏向日志
-XX:+TraceBiasedLocking总结
回到开头秒杀系统的场景。如果你发现 synchronized 变成了瓶颈,先别急着换锁,用 JOL 看一下当前锁状态:
- 如果锁一直处于偏向锁状态,说明竞争极低,
synchronized的性能是最好的。
- 如果锁在轻量级锁和重量级锁之间反复横跳,说明临界区代码太重,优先优化代码而不是换锁。
- 如果确实需要更细粒度的控制(超时、中断、公平性),再考虑
ReentrantLock。
锁升级是 JVM 为了平衡"性能"和"公平性"做的巧妙折中。理解这套机制,能帮你在多线程编程中做出更明智的选择。
最后留个思考题:JDK 15 之后,JEP 374 默认禁用了偏向锁(JDK 15 中 UseBiasedLocking 默认关闭)。你觉得为什么官方会做出这个决定?在什么场景下偏向锁反而成了累赘?欢迎在评论区讨论。