主题
Synchronized 底层实现:偏向锁 → 轻量锁 → 重量锁
提出问题
synchronized 是 Java 最基础的同步机制,一个关键字就解决了互斥问题。但很多用了好几年 Java 的人也不清楚:synchronized 到底快不快?为什么早期 JDK 版本里它被叫做"重量级锁"?JDK 1.6 做了哪些优化让它的性能大幅提升?所谓的"锁升级"从偏向锁到轻量锁再到重量锁,每个阶段做了什么、什么时候触发、什么时候回退不了?
在生产环境排查锁竞争导致的性能问题时,理解锁升级的底层机制会直接影响你的优化方向。比如线上发现大量线程 BLOCKED 在 synchronized 上,是应该加锁粗化、还是减小临界区、还是干脆换成 CAS?不同的根因解法完全不同。
Mark Word:锁信息的"寄存器"
synchronized 的锁信息存储在 Java 对象的**对象头(Object Header)**中,具体是在 Mark Word 这个字段里。在 64 位 JVM 中,Mark Word 占 8 个字节(64 位),其比特位在不同锁状态下有不同的含义,JVM 把同一个 64 位空间按位复用:
无锁状态: | unused:25 | hash:31 | age:4 | biased_lock:0 | 01 |
偏向锁状态: | thread:54 | epoch:2 | age:4 | biased_lock:1 | 01 |
轻量锁状态: | ptr_to_lock_record:62 | 00 |
重量锁状态: | ptr_to_monitor:62 | 10 |
GC 标记: | | 11 |最后两位是锁标志位(01=无锁/偏向锁,00=轻量锁,10=重量锁,11=GC 标记),倒数第三位是是否偏向锁标记(biased_lock)。设计上把无锁和偏向锁的标志位编码为 01,用 biased_lock 位区分,这意味着无锁对象可以直接升级为偏向锁而不改变标志位编码。
关键细节:对象头里存 hash 码和偏向锁 thread ID 是同一块空间。一旦对象调用了 Object.hashCode()(或 System.identityHashCode()),hash 码就会被计算并写入 Mark Word,此时 Mark Word 中存放 hash 码的 31 位就不再是 0。如果此时再尝试上偏向锁,thread ID(54 位)和 hash(31 位)位置冲突,偏向锁会直接失败,膨胀为轻量级锁。这意味着调用了 hashCode() 的对象的 synchronized 不会走偏向锁路径,而是直接进入轻量级锁。
锁升级四阶段
JDK 1.6 引入的锁升级机制让 synchronized 从"一把锁锁到底"变成了"按需膨胀":锁竞争越激烈,锁越重。
阶段一:偏向锁(Biased Locking)——JDK 15 已死,JDK 21 入土
设计动机:大多数情况下,锁不仅不竞争,而且始终由同一个线程持有。比如 StringBuffer.append() 这样的方法,在单线程场景下调用,加锁完全是多余的,但你不能移掉 synchronized 关键字。偏向锁就是为了解决"无竞争但必须加锁"的浪费。
执行过程:第一个线程进入同步块时,JVM 将对象头的 Mark Word 设为偏向模式,记录当前线程 ID。此后这个线程再次进入该同步块时,只需要检查 Mark Word 中的线程 ID 是否匹配,匹配则直接通过,无需任何 CAS 操作。这个检查本身只有几条 CPU 指令的开销,一次偏向锁获取 ≈ 5-10ns,比轻量级锁的 CAS 操作(约 50-100ns)快一个数量级。
锁撤销:当另一个线程尝试获取这个锁时,偏向锁需要被撤销。撤销过程需要在**全局安全点(SafePoint)**执行——也就是所有线程都暂停在安全点,STW(Stop-The-World)发生。撤销后,锁升级为轻量级锁。
一个 STW 撤销的代价有多大? 假设你有一个 16 核的服务器跑着 32 个线程,其中 16 个线程都在竞争同一个锁。每次偏向锁撤销都需要 STW,一个安全点停顿即使只有 1ms,32 个线程每人停 1ms 就是 32ms 的累计延迟。如果这个锁每秒被竞争 1000 次,光是偏向锁撤销就能吃掉 32ms × 1000 = 32 秒的 CPU 时间,而且这些停顿是全局的——所有线程都在等。
JDK 15 默认关闭,JDK 21 已移除:因为偏向锁的撤销必须停在安全点,在高并发场景下,如果大量锁被不同线程频繁竞争,偏向锁的反复撤销会导致频繁的 STW,反而严重拖慢性能。JEP 374 在 JDK 15 默认关闭偏向锁,JDK 21 通过 JEP 455 彻底移除了偏向锁的代码。所以现在(JDK 21+)已经没有偏向锁了,synchronized 的默认路径是无锁 → 轻量级锁。
还有点记忆残留:很多老项目用的 JDK 8/11 仍然有偏向锁,碰到过的一个真实案例——某支付对账服务升级 JDK 11 后,偏向锁导致 ConcurrentHashMap 内部频繁 STW,从 jstat -gcutil 看到安全点停顿占比从 0.5% 飙升到 8%,解决方式就是加 -XX:-UseBiasedLocking 关闭偏向锁。
阶段二:轻量级锁(Lightweight Locking)
设计动机:锁大部分时间没有竞争,即使有竞争,也是短时间交替持有,不会长时间阻塞。轻量级锁用 CAS + 自旋替代了线程挂起,避免用户态 ↔ 内核态切换。
执行过程:线程在进入同步块时,如果对象处于无锁状态(或偏向锁已撤销),在当前线程的栈帧中创建一个**锁记录(Lock Record)**空间,然后通过 CAS 操作尝试将对象头的 Mark Word 替换为指向这个锁记录的指针(ptr_to_lock_record)。
- CAS 成功:当前线程持有轻量级锁,继续执行
- CAS 失败:说明有其他线程正在竞争,当前线程进入自旋——原地循环尝试 CAS
自旋优化:早期的自旋是固定的(默认 10 次),JDK 1.6 引入了自适应自旋(Adaptive Spinning)——JVM 会根据上次对这个锁的自旋等待时间,动态调整自旋次数。如果上次自旋成功获取了锁,JVM 倾向于多自旋几次(上限可能到几千次);如果上次自旋失败,就减少自旋次数甚至不自旋。这种"学习效应"让自旋策略自动适应不同的锁竞争模式。
自旋不是免费的:自旋消耗 CPU 时间片。如果临界区执行时间 > 线程上下文切换时间(约 1-3μs),自旋就是浪费 CPU。所以自旋适合临界区极短(微秒级)的场景,比如 AtomicInteger.incrementAndGet() 内部的自旋,或者对一个 HashMap 快速 put/get。
阶段三:重量级锁(Heavyweight Locking)
触发条件:自旋超过阈值(或自适应自旋判定失败),或者持有轻量级锁的线程被阻塞(如调用了 Object.wait()),锁膨胀为重量级锁。
ObjectMonitor 内部结构:对象头的 Mark Word 指向一个 ObjectMonitor 对象,这是 JVM 内部的 C++ 对象,结构大致如下:
cpp
// HotSpot 源码简化示意
struct ObjectMonitor {
void* _header; // 原始 Mark Word
void* _object; // 关联的 Java 对象
void* _owner; // 当前持有锁的线程
void* _WaitSet; // wait() 等待队列(双向链表)
void* _EntryList; // 竞争锁的阻塞队列(双向链表)
int _recursions; // 重入次数
int _count; // 计数器
// ... 更多字段
};当线程获取重量级锁失败时,会进入 _EntryList 排队,调用操作系统的 pthread_mutex_lock(Linux)或对应的内核互斥量,线程从用户态陷入内核态,挂起等待。锁释放时,从 _EntryList 中唤醒一个线程,又涉及一次内核态到用户态的切换。
锁释放时的唤醒策略:ObjectMonitor 默认使用竞争模型而非公平模型。当锁释放时,它不会按 FIFO 顺序从 _EntryList 唤醒,而是采用竞争唤醒或者非公平策略——新来的线程和等待队列中的线程一起竞争,和 ReentrantLock 的 nonfair 模式类似。这样做的好处是避免了线程唤醒的成本浪费:如果持有锁的线程在释放锁后立刻又尝试获取,它大概率能直接拿到,因为当前线程还在活跃状态。
wait/notify 的锁降级陷阱:调用 Object.wait() 的线程必须持有重量级锁,否则会抛 IllegalMonitorStateException。wait() 内部会将线程从 _owner 移入 _WaitSet、释放锁,等被 notify() 唤醒后再从 _WaitSet 移回 _EntryList 重新竞争锁。注意:wait() 强制锁膨胀为重量级锁,即使之前是轻量级锁,调用 wait() 后也会触发膨胀。这是你在判断是否需要使用 wait/notify 时需要考虑的性能成本。
锁升级不可逆
锁的升级方向是单向不可逆的:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。不会从重量级锁降回轻量级锁(虽然偏向锁可以通过批量撤销进入"偏向锁禁用"状态,但这不算降级)。这意味着一旦锁经历过高竞争膨胀为重量级锁,后续即使没有竞争了,它仍然会是重量级锁,每次加锁解锁都要走内核态。
性能对比表
| 锁类型 | 加锁耗时(近似) | 主要开销 | 适用场景 | 当前状态 |
|---|---|---|---|---|
| 偏向锁 | 5-10ns | 几条 CPU 指令,无 CAS | 单线程重复获取同一锁 | JDK 8 默认开启,21 已移除 |
| 轻量级锁 | 50-200ns | CAS + 自旋,用户态 | 短时间锁交替,低竞争 | JDK 21+ 默认路径 |
| 重量级锁 | 1-10μs + 阻塞时间 | 内核态切换,线程挂起/唤醒 | 高竞争,临界区较长 | 竞争激烈时膨胀到达 |
注意:上表是"一次加锁"的近似时间,不包含临界区执行时间。重量级锁的"1-10μs"只是内核态切换的成本,如果线程被挂起再唤醒,还需要加上线程调度延迟(通常 10-100μs)。
锁消除与锁粗化:JIT 的额外优化
除了锁升级,JIT 编译器还会做两个跟 synchronized 相关的优化:
锁消除(Lock Elimination):通过逃逸分析,如果一个对象不会逃逸出当前线程,那么对它加锁是多余的,JIT 直接去掉 synchronized 块。例如:
java
public String concat(String s1, String s2) {
return new StringBuffer(s1).append(s2).toString();
}StringBuffer.append() 是 synchronized 的,但 StringBuffer 对象不会逃逸出这个方法,JIT 在编译时可以直接消除所有的锁操作。你可以用 -XX:+PrintEliminateLocks 查看哪些锁被消除了(JDK 8 支持,后续版本用其他诊断参数)。
锁粗化(Lock Coarsening):如果 JIT 检测到同一个线程对同一个对象反复加锁、解锁(比如在循环中),它会把这些操作合并成一次加锁和解锁:
java
// 粗化前 —— 每次循环迭代都加锁解锁
for (int i = 0; i < 100; i++) {
synchronized (lock) {
buffer.append(i);
}
}
// 粗化后(等效)
synchronized (lock) {
for (int i = 0; i < 100; i++) {
buffer.append(i);
}
}锁粗化的反面:如果一个同步块内包含耗时操作(文件 IO、网络请求),JIT 不会做粗化,因为它会评估临界区大小。但如果你自己手动写了一堆连续的小 synchronized 块,JIT 粗化后可能导致临界区意外变大,反而降低并发度。
生产踩坑:锁粗化 + 重量级锁 = 灾难
遇到过的一个真实案例:某交易系统,线上用 synchronized (this) 保护了一个 ArrayList 的批量写入,结果 JIT 粗化后,整个 for 循环内部的同步块被合并成了一个巨大的临界区。由于锁已经膨胀为重量级锁,每次只有一个线程能进入循环,其他线程全部在 _EntryList 排队。最终这个接口的 TP99 从 5ms 飙到 800ms。解法是:把同步块从 this 级缩小到 ArrayList 级,或者改用 ConcurrentLinkedQueue。
代码示例:锁升级可视化
通过 JOL(Java Object Layout)工具可以直观看到锁升级过程中对象头的变化:
java
// 需要引入 jol-core 依赖
import org.openjdk.jol.info.ClassLayout;
public class LockUpgradeDemo {
private static final Object lock = new Object();
public static void main(String[] args) throws Exception {
System.out.println("=== 无锁状态 ===");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
System.out.println("=== 持有锁(轻量/偏向) ===");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
// 用另一个线程模拟竞争,触发锁升级
Thread t = new Thread(() -> {
synchronized (lock) {
System.out.println("=== 竞争线程持有锁 ===");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
});
t.start();
t.join();
}
}输出结果(JDK 11,偏向锁默认开启)会显示 Mark Word 从 0x0000000000000001(无锁,后三位 001)变为 0x00007f...(偏向锁或轻量锁),再到竞争后变为持有重量级锁对应的值。
生产环境调优参数
-XX:-UseBiasedLocking:关闭偏向锁(JDK 15 之前),适合高并发锁竞争场景,避免 STW 撤销开销-XX:BiasedLockingStartupDelay=0:消除偏向锁的启动延迟(默认 JVM 启动后 4 秒才启用偏向锁),在 JDK 15 之前用于测试或短周期应用-XX:+PrintFlagsFinal:查看当前所有 JVM 参数,包括锁相关-XX:+PrintSafepointStatistics和-XX:PrintSafepointStatisticsCount=1:监控安全点停顿,用于排查偏向锁撤销导致的 STW-XX:GuaranteedSafepointInterval=0:关闭周期性安全点(JDK 8 可用,但谨慎使用,可能影响 GC 等)
线上排查 checklist
jstack <pid> | grep "BLOCKED" | wc -l—— 看 BLOCKED 线程数,如果持续 > 0 说明锁竞争严重jstat -gcutil <pid> 1000—— 看安全点停顿时间占比(STW列),如果 > 5% 且伴随大量 BLOCKED,考虑关闭偏向锁或减小锁粒度- 使用
-XX:+PrintSafepointStatistics看安全点具体原因,如果是revoke_bias就是偏向锁撤销
参考来源
- JVM 源码
src/hotspot/share/runtime/synchronizer.cpp、src/hotspot/share/runtime/objectMonitor.cpp - JDK 21 JEP 455(移除偏向锁)
- JEP 374(JDK 15 默认禁用偏向锁)