Synchronized 锁升级:从偏向锁到重量级锁的性能进化之路
摘要:本文探讨了Java中synchronized的锁升级机制。在JDK6之前,synchronized作为重量级锁性能较差,每次加锁都需操作系统介入。JDK6引入智能升级机制,根据竞争程度自动切换锁状态:偏向锁(单线程无竞争)、轻量级锁(少量竞争)和重量级锁(激烈竞争)。通过电影院入场检查的类比,形象说明了不同锁状态的特点。文章还提供了代码示例,展示如何观察锁升级过程及性能差异,帮助开发者理解并
大家好,我是程序员小策。
你一定遇到过这种场景
你写了一个工具类,里面有个 synchronized 方法,本地跑得一切正常,QPS 才几百。上了生产环境之后,监控发现这个方法的响应时间偶尔会飙到几十毫秒甚至上百毫秒。
你觉得是数据库慢了?排查了一圈发现不是。
最后定位到原因:这个 synchronized 在高并发下触发了锁竞争,从"几乎无开销"变成了"每次都要找操作系统申请互斥量"。而这行代码背后藏着的,就是今天要聊的主题synchronized 的锁升级机制。
一、为什么 synchronized 曾经是性能杀手?
先说一个朴素认知:大部分开发者知道 synchronized 是 Java 内置的锁机制,用来保证线程安全。在 JDK 1.5 之前,它确实是个"重量级锁"——每次加锁都要向操作系统申请互斥量(Mutex),涉及用户态和内核态切换,开销很大。
那时候如果你要优化性能,第一反应就是"把 synchronized 换成 ReentrantLock"。
但问题是:大多数情况下,锁的竞争并不激烈。
想象一个计数器方法,99% 的时间只有一个线程在调用它。为了这 1% 的极端情况,你每次调用都要付出"向操作系统申请锁"的代价?这不合理。
所以 JDK 6 做了一件大事:给 synchronized 加了"智能升级"机制——根据竞争情况自动选择合适的锁级别。
锁升级(Lock Escalation):synchronized 根据锁的竞争程度,自动从低开销的偏向锁升级到轻量级锁,再升级到重量级锁的过程。这个过程是单向的,不可逆。
二、把锁升级想象成电影院的入场检查
你一定去过电影院吧?
想象一下电影院的入场流程——它根据人流情况动态调整检查严格程度:
偏向锁(Biased Locking)——就像你是这家影院的 VIP 会员。
你第一次来的时候,前台记住你的脸(记录线程 ID)。以后你再来,直接刷脸进场,不用查票、不用排队。前提是你总是同一个时间段来看电影(单线程反复访问同步块)。
如果突然有一天,你的朋友也拿着同一张票来了(另一个线程尝试获取锁),VIP 待遇就失效了——需要升级到更严格的检查方式。
轻量级锁(Lightweight Locking)——就像普通观众排队检票。
没有 VIP 特权了,大家都在入口处排队。但如果人不多(竞争不激烈),检票员看一眼票就放行,不用叫保安(不需要操作系统介入)。这个过程用的是 CAS 自旋,在用户态就能完成,比找操作系统快得多。
重量级锁(Heavyweight Locking)——就像春节档爆满,检票口挤爆了。
人太多了,光靠检票员已经搞不定。这时候必须叫保安过来维持秩序(向操作系统申请 Mutex),让大家排好队一个一个进。虽然安全,但速度慢了很多——因为涉及到用户态和内核态的切换。
翻译回技术语言:
| 电影院要素 | 锁状态 | 技术实现 | 开销 |
|---|---|---|---|
| VIP 刷脸进场 | 偏向锁 | Mark Word 记录线程 ID | 几乎为零 |
| 普通排队检票 | 轻量级锁 | CAS 自旋 + Lock Record | 很小(用户态) |
| 保安维持秩序 | 重量级锁 | 操作系统 Mutex | 大(内核态切换) |
当然,实际上锁升级不是"电影院经理拍脑袋决定的",而是 JVM 根据客观指标(是否有多个线程竞争)自动触发的。
三、代码实现:观察锁升级的全过程
下面这段代码可以帮你直观看到不同锁状态下的性能差异:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;
import org.openjdk.jol.info.ClassLayout;
public class LockEscalationDemo {
public static void main(String[] args) throws InterruptedException {
// 创建一个对象用于加锁
Object lock = new Object();
// 打印对象的原始布局(Mark Word 信息)
System.out.println("===== 初始状态(无锁)=====");
printObjectLayout(lock);
// 场景1:单线程多次进入同步块 → 偏向锁
System.out.println("\n===== 单线程访问 → 偏向锁 =====");
for (int i = 0; i < 2; i++) {
synchronized (lock) {
if (i == 1) { // 第二次进入时打印
printObjectLayout(lock);
}
}
}
// 场景2:两个线程交替访问 → 升级为轻量级锁
System.out.println("\n===== 两个线程交替访问 → 轻量级锁 =====");
Thread thread1 = new Thread(() -> {
synchronized (lock) {
System.out.println("Thread-1 持有锁:");
printObjectLayout(lock);
try { Thread.sleep(100); } catch (InterruptedException e) {}
}
});
thread1.start();
thread1.join(); // 等待 thread1 完成
// 主线程再次获取锁
synchronized (lock) {
System.out.println("Main 线程获取锁后:");
printObjectLayout(lock);
}
// 场景3:多线程激烈竞争 → 升级为重量级锁
System.out.println("\n===== 多线程竞争 → 重量级锁 =====");
int threadCount = 10;
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
try {
startLatch.await(); // 所有线程同时开始
synchronized (lock) {
// 故意自旋一下增加竞争
for (int j = 0; j < 10000; j++) {}
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
doneLatch.countDown();
}
}).start();
}
startLatch.countDown(); // 放开所有线程
doneLatch.await(); // 等待所有线程完成
System.out.println("竞争结束后:");
printObjectLayout(lock);
}
/**
* 打印对象的内存布局(Mark Word 信息)
* 需要依赖 JOL 库: org.openjdk:jol-core
*/
private static void printObjectLayout(Object obj) {
String layout = ClassLayout.parseInstance(obj).toPrintable();
// 提取关键信息:对象头中的锁标志位
String[] lines = layout.split("\n");
for (String line : lines) {
if (line.contains("object header") || line.contains("value")) {
System.out.println(line.trim());
}
}
}
/**
* 性能对比测试:不同锁状态下的耗时
*/
public static void performanceComparison() throws InterruptedException {
int iterations = 100_000_000; // 1亿次操作
Object lock = new Object();
AtomicLong counter = new AtomicLong(0);
// 测试无锁情况下的基准耗时
long start = System.nanoTime();
for (int i = 0; i < iterations; i++) {
counter.incrementAndGet();
}
long noLockTime = (System.nanoTime() - start) / 1_000_000;
System.out.println("无锁耗时: " + noLockTime + "ms");
// 测试偏向锁(单线程)
start = System.nanoTime();
for (int i = 0; i < iterations; i++) {
synchronized (lock) {
counter.incrementAndGet();
}
}
long biasedTime = (System.nanoTime() - start) / 1_000_000;
System.out.println("偏向锁耗时(单线程): " + biasedTime + "ms");
System.out.println("偏向锁 vs 无锁: " + String.format("%.2f", (double)biasedTime / noLockTime) + "x");
// 测试重量级锁(多线程竞争)
CountDownLatch latch = new CountDownLatch(4);
start = System.nanoTime();
for (int t = 0; t < 4; t++) {
new Thread(() -> {
for (int i = 0; i < iterations / 4; i++) {
synchronized (lock) {
counter.incrementAndGet();
}
}
latch.countDown();
}).start();
}
latch.await();
long heavyweightTime = (System.nanoTime() - start) / 1_000_000;
System.out.println("重量级锁耗时(4线程竞争): " + heavyweightTime + "ms");
System.out.println("重量级锁 vs 偏向锁: " + String.format("%.2f", (double)heavyweightTime / biasedTime) + "x");
}
}
代码关键点解释:
为什么要用 JOL 库打印对象布局?
Java 对象的锁状态信息存储在**对象头(Object Header)**的 Mark Word 中。JOL(Java Object Layout)工具可以直接打印出 Mark Word 的二进制内容,让我们看到锁状态的变化。
Mark Word 的结构(64 位 JVM):
|--------------------|------------------|------|
| unused (25bit) | epoch (unused) | age |
|--------------------|------------------|------|
| biased_lock (1bit) | lock (2bit) |
|--------------------|------------------|------|
lock 字段的值决定了锁状态:
01+ biased_lock=1 → 偏向锁00→ 轻量级锁10→ 重量级锁11→ GC 标记
运行结果示例(简化版):
===== 初始状态(无锁)=====
object header: 0x0000000000000001 (non-biasable; age: 0)
===== 单线程访问 → 偏向锁 =====
object header: 0x00007f62XXXXXXXX (biased: 0x0000XXXX; age: 0)
// 可以看到 Mark Word 中存储了线程 ID
===== 两个线程交替访问 → 轻量级锁 =====
Thread-1 持有锁:
object header: 0x000000XXXXXXXX (thin lock: 0x0000XXXX)
// 指向栈中 Lock Record 的指针
Main 线程获取锁后:
object header: 0x000000XXXXXXXX (thin lock: 0x0000XXXX)
===== 多线程竞争 → 重量级锁 =====
竞争结束后:
object header: 0x000000XXXXXXXX (fat lock: 0x0000XXXX)
// 指向操作系统 Mutex 对象的指针
性能对比结果(参考值):
无锁耗时: 45ms
偏向锁耗时(单线程): 48ms ← 接近无锁!
偏向锁 vs 无锁: 1.07x ← 开销极小
重量级锁耗时(4线程竞争): 2856ms ← 慢了约 60 倍
重量级锁 vs 偏向锁: 59.50x ← 性能差距巨大
可以看到:偏向锁的开销几乎可以忽略不计,而一旦升级到重量级锁,性能会断崖式下降。
四、看起来很完美了对吧?但这几个坑你可能踩过
坑一:锁升级是不可逆的
很多人以为锁会"降级"回去——比如竞争结束了,重量级锁会不会变回轻量级锁?
答案是不会。
锁升级是单向的:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。一旦升级到某个级别,就不会再降回来(除非 GC 回收对象后重新创建)。
这意味着什么?如果你的系统偶尔出现一次高并发 spike(比如整点定时任务),导致锁升级到了重量级,即使 spike 过去了,后续所有对这个锁的操作都会继续走重量级路径,直到这个对象被 GC 回收。
解法:
- 避免长时间持有重量级锁的对象(尽量缩小 synchronized 块的范围)
- 如果知道会有偶发的高并发,考虑一开始就用其他并发原语(如 AtomicLong)
坑二:偏向锁的延迟开启
JVM 默认会在启动后 4 秒才开启偏向锁优化(可以通过 -XX:BiasedLockingStartupDelay=0 关闭延迟)。这是为了避免启动期间大量短命对象浪费偏向锁的初始化开销。
但这也意味着:如果你的应用刚启动就遇到高并发,前几秒钟内的 synchronized 都是走重量级路径的。
解法:
- 如果应用启动时间敏感,设置
-XX:BiasedLockingStartupDelay=0 - 或者接受这几秒的性能损失(大多数场景影响不大)
坑三:hashCode() 会导致偏向锁失效
如果一个对象调用了 hashCode() 方法(或者被用作 HashMap 的 key),它的偏向锁就会立即撤销,因为 hashCode 需要存储在 Mark Word 中,而偏向锁也要占用 Mark Word 存储线程 ID——两者冲突了。
Object lock = new Object();
lock.hashCode(); // ← 这一行会导致偏向锁失效!
synchronized (lock) {
// 这里不会走偏向锁,而是直接升级到轻量级或重量级
}
解法:
- 不要对会被加锁的对象调用
hashCode() - 如果必须用 hashCode,考虑改用其他并发控制方式
坑四:批量撤销与批量重偏向
当同一个类的大量对象都发生了偏向锁撤销(比如遍历 HashMap 时),JVM 会触发批量撤销——把这个类的所有新建对象都设为不可偏向。这是一个"连坐"机制,可能导致后续新创建的对象也无法享受偏向锁优化。
JVM 还有一个批量重偏向机制:如果撤销次数达到阈值(默认 20 次),会对该类的下一个对象重新启用偏向锁。但这个机制比较隐蔽,很难主动利用。
五、从单机到大规模集群:锁升级还够用吗?
到目前为止,我们讨论的都是单个 JVM 进程内的 synchronized 行为。但在分布式环境下呢?
synchronized 只能保护单个 JVM 内的资源。
如果你有 10 个服务实例,每个实例都有自己的 synchronized 锁,它们之间无法协调。这就好比每家电影院都有自己的检票系统,但你买了 A 影院的票去 B 影院看电影——B 影院的系统根本不认你的票。
分布式环境下的替代方案:
- Redis 分布式锁:基于 Redis 的 SETNX 或 Redlock 算法,实现跨 JVM 的互斥
- Zookeeper 分布式锁:基于临时顺序节点,强一致性保证
- 数据库悲观锁:SELECT … FOR UPDATE,简单粗暴但有效
当然,这些方案的代价都比 synchronized 大得多(网络开销、序列化开销),所以在能用 synchronized 解决的场景下,不要过度设计。
另外值得一提的是:JVM 的 JIT 编译器也会对锁做进一步优化:
- 锁消除(Lock Elimination):如果 JIT 发现某个锁对象永远不会逃逸出当前方法(通过逃逸分析),它会直接移除 synchronized 块
- 锁粗化(Lock Coarsening):如果在一个循环里频繁加锁解锁,JIT 会把锁提到循环外面,只加一次锁
// JIT 可能会把这段代码优化成只在循环外加一次锁
for (int i = 0; i < 100; i++) {
synchronized (lock) {
list.add(i);
}
}
// 优化后等效于:
synchronized (lock) {
for (int i = 0; i < 100; i++) {
list.add(i);
}
}
这就是为什么有时候你写了 synchronized 但性能并没有明显下降——JIT 已经偷偷帮你优化了。
六、三种锁状态对比一览表
| 维度 | 偏向锁 | 轻量级锁 | 重量级锁 |
|---|---|---|---|
| 适用场景 | 单线程反复访问同步块 | 低竞争(2 个线程交替访问) | 高竞争(多线程同时抢锁) |
| 实现原理 | Mark Word 存储线程 ID | CAS 自旋 + Lock Record | 操作系统 Mutex |
| 是否阻塞线程 | 不阻塞 | 自旋等待(短暂) | 阻塞挂起(让出 CPU) |
| 性能开销 | 几乎为零(接近无锁) | 很小(纳秒级) | 大(微秒到毫秒级) |
| 用户态/内核态 | 用户态 | 用户态 | 用户态 ↔ 内核态切换 |
| 能否升级到下一级 | 能 → 轻量级锁 | 能 → 重量级锁 | 不能(最终形态) |
| 典型触发条件 | 同一线程重复进入 | 另一个线程尝试获取锁 | CAS 自旋失败超过阈值 |
| 适用案例 | 单例模式的 getInstance() | 简单计数器、配置读取 | 高并发库存扣减、订单创建 |
一句话总结选型策略:
- 如果确定只有单线程访问 → 偏向锁就够了(synchronized 自动处理)
- 如果有少量竞争(2-3 个线程)→ 轻量级锁兜底(还是 synchronized 自动处理)
- 如果竞争激烈(高并发场景)→ 考虑用 ReentrantLock 或 Atomic 类替代 synchronized
注意:作为开发者,你不需要手动选择锁级别——synchronized 会自动完成升级。你需要做的是理解这个机制,以便在性能排查时能快速定位问题。
七、面试官还会追问什么?
面试追问 1:为什么偏向锁会提升性能?它的本质是什么?
→ 回答方向:偏向锁的本质是省掉了 CAS 操作。普通的轻量级锁每次进入同步块都要执行一次 CAS 来交换 Mark Word 和 Lock Record,而偏向锁只需要比对一下线程 ID 就行了——这是一次简单的内存读操作,比 CAS 快很多。适合"同一个线程反复进入同步块"的场景(比如循环调用 synchronized 方法)。
面试追问 2:轻量级锁的自旋会一直转下去吗?有没有限制?
→ 回答方向:不会无限自旋。JDK 6 引入了自适应自旋——自旋次数不是固定的,而是根据上一次在同一个锁上的自旋时间和锁拥有者的状态动态调整。如果上次自旋成功获得了锁,这次会允许自旋更长时间;如果上次失败了,这次会减少自旋次数甚至直接跳过自旋升级到重量级锁。可以通过 -XX:PreBlockSpin 参数调整初始自旋次数(默认 10)。
面试追问 3:锁升级过程中,如果正在执行同步块的线程突然崩溃了怎么办?
→ 回答方向:这是个很好的问题。答案是不用担心。
- 偏向锁:如果持有偏向锁的线程死了,其他线程来竞争时会发现偏向锁无效,直接升级为轻量级锁
- 轻量级锁:Lock Record 在栈帧里,线程崩溃后栈帧销毁,锁自然释放
- 重量级锁:操作系统会回收 Mutex,其他线程可以获得锁
面试追问 4:如何判断线上系统的 synchronized 是否存在性能问题?
→ 回答方向:几个排查手段:
- jstack 查看线程状态:大量线程处于 BLOCKED(on object monitor)说明锁竞争严重
- Arthas 的
thread命令:查看线程 CPU 时间和阻塞时间占比 - JFR(Java Flight Recorder):录制 JVM 事件,分析锁竞争热点
- -XX:+PrintGCDetails 日志:观察 safepoint 时间(重量级锁会导致 safepoint 增加)
面试追问 5:synchronized 和 ReentrantLock 在锁升级方面有什么区别?
→ 回答方向:synchronized 有自动的锁升级机制(偏向→轻量→重量),而 ReentrantLock 始终是基于 AQS 的重量级实现(除非用 tryLock 做非阻塞尝试)。这也是为什么在低竞争场景下 synchronized 往往比 ReentrantLock 更快的原因——它能享受偏向锁和轻量级锁的优化。
八、总结
synchronized 不是"要么全有要么全无",它会根据实际情况"看菜下饭"。
读完这篇你应该能:
- 解释清楚 synchronized 的四种锁状态(无锁、偏向、轻量、重量)以及升级触发条件
- 用"电影院检票"的类比向同事讲明白锁升级的设计思想
- 在性能排查时识别出"本不该这么慢的 synchronized 方法"——大概率是升级到了重量级锁
- 在面试时说出"偏向锁省掉 CAS 操作"、“自适应自旋”、“锁升级不可逆”——而不只是背"JDK 6 做了优化"
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)