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++ 对象,结构大致如下:
// 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 块。例如:
public String concat(String s1, String s2) {
return new StringBuilder(s1).append(s2).toString();
}StringBuilder.append() 是 synchronized 的,但 StringBuilder 对象不会逃逸出这个方法,JIT 在编译时可以直接消除所有的锁操作。你可以用 -XX:+PrintEliminateLocks 查看哪些锁被消除了(JDK 8 支持,后续版本用其他诊断参数)。
锁粗化(Lock Coarsening):如果 JIT 检测到同一个线程对同一个对象反复加锁、解锁(比如在循环中),它会把这些操作合并成一次加锁和解锁:
// 粗化前 —— 每次循环迭代都加锁解锁
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)工具可以直观看到锁升级过程中对象头的变化:
// 需要引入 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就是偏向锁撤销
面试话术
"synchronized 在 JDK 1.6 之后经历了锁优化,从偏向锁到轻量锁再到重量锁,逐步升级、不可逆。偏向锁适用于单线程重复获取锁的场景,但撤销需要在安全点执行,所以在高竞争场景下反而有害,这也是 JDK 15 默认关闭、JDK 21 移除它的原因。轻量级锁用 CAS + 自旋避免线程挂起,适合短时间锁交替的场景。重量级锁使用操作系统互斥量,线程会阻塞,开销最大。实际调优时,我会用
-XX:-UseBiasedLocking关闭偏向锁来减少高竞争场景下的 STW,并通过jstack观察 BLOCKED 线程数量来判断锁竞争是否严重,再决定是减小锁粒度还是改用 CAS 或读写锁。"
参考:JVM 源码
src/hotspot/share/runtime/synchronizer.cpp、src/hotspot/share/runtime/objectMonitor.cpp、JDK 21 JEP 455(移除偏向锁)、JEP 374(JDK 15 默认禁用偏向锁)