Java synchronized 深度解析——偏向锁、轻量级锁、重量级锁升级全过程
Java synchronized 深度解析——偏向锁、轻量级锁、重量级锁升级全过程
适读人群:有一定Java并发基础的后端开发者 | 阅读时长:约18分钟 | 核心价值:彻底搞清楚synchronized的锁升级机制,不再靠背面试题
从一次线上事故说起
那是2021年的一个周五下午,我们的订单服务突然开始报警,P99延迟从正常的80ms飙升到了2300ms。我盯着监控大屏,看着那条延迟曲线像火箭一样蹿上去,后背一阵发凉。
当时我们的并发量大概在4000 QPS左右,服务是4核8G的Pod,跑着8个线程处理订单。我第一反应是数据库慢查询,但DBA那边反馈DB很正常。接着看GC日志,也没有Full GC。最后我拿着Arthas的thread -b命令一顿排查,发现一堆线程都在等一个synchronized方法——OrderService#processOrder。
问题找到了:那个方法里有个统计逻辑,用了一个类级别的synchronized锁,而且里面有个调用链路很深的外部HTTP请求,平均耗时150ms。4000 QPS打进来,8个线程全部堵在这个锁上,彻底成了串行。
那次事故之后,我花了整整两周时间重新研究synchronized。我发现自己以前对它的理解太浅了——就停留在"加锁解锁"的层面,根本没搞清楚JVM底层是怎么做的。今天这篇文章,就是把我这两年研究和实战的积累,完整地分享出来。
synchronized 的三种形态
先把基础打牢。synchronized在Java里有三种用法:
// 1. 修饰实例方法:锁是 this 对象
public synchronized void instanceMethod() { ... }
// 2. 修饰静态方法:锁是 Class 对象
public static synchronized void staticMethod() { ... }
// 3. 修饰代码块:锁是括号内的对象
public void blockMethod() {
synchronized (this) { ... }
synchronized (SomeClass.class) { ... }
synchronized (lockObject) { ... }
}这三种形式本质上都是对象监视器(Monitor)的获取与释放。JVM在字节码层面通过monitorenter和monitorexit指令来实现。
用javap -c反编译一下就能看到:
public void blockMethod();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter // 获取锁
4: aload_1
5: monitorexit // 释放锁(正常路径)
6: goto 14
9: aload_1
10: monitorexit // 释放锁(异常路径,确保锁一定被释放)
11: athrow
14: return注意这里有两个monitorexit,这就是synchronized能保证锁一定被释放的原因——即使方法抛异常也没事。
对象头:锁信息存在哪里
要理解锁升级,必须先搞清楚对象头(Object Header)的结构。
Java对象在内存里的布局是这样的:
- 对象头(Header):包含Mark Word和类型指针
- 实例数据(Instance Data):对象字段
- 对齐填充(Padding):补齐到8字节倍数
Mark Word在64位JVM下是8字节(64bit),它的内容会随着锁状态变化:
| 锁状态 | 低2bit标志位 | 其他bit含义 |
|---|---|---|
| 无锁 | 01 | hashCode(31bit) + 分代年龄(4bit) |
| 偏向锁 | 01 | 线程ID(54bit) + epoch(2bit) + 分代年龄(4bit) + 偏向标志(1bit=1) |
| 轻量级锁 | 00 | 指向栈帧中Lock Record的指针 |
| 重量级锁 | 10 | 指向Monitor对象的指针 |
| GC标记 | 11 | 空 |
这个表非常重要,整个锁升级过程就是Mark Word内容不断变化的过程。
锁升级的完整过程
第一阶段:偏向锁(Biased Locking)
偏向锁背后的核心假设是:大多数情况下,一个锁只会被同一个线程反复获取,不存在竞争。
这个假设在很多单线程循环中成立。比如:
List<String> list = new ArrayList<>(); // ArrayList 内部有 synchronized 方法
for (int i = 0; i < 10000; i++) {
list.add("item" + i); // 循环中只有一个线程操作
}偏向锁获取过程:
- 第一次有线程获取锁时,JVM用CAS把线程ID写入Mark Word,偏向标志位置1
- 该线程后续再次获取锁时,只需检查Mark Word里的线程ID是否等于当前线程ID
- 如果等于,不需要任何原子操作,直接进入同步块——这就是"偏向"的含义
// 模拟偏向锁场景
public class BiasedLockDemo {
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
// JVM启动后默认有偏向锁延迟(约4秒),可用参数关闭
// -XX:BiasedLockingStartupDelay=0
Thread.sleep(5000); // 等待偏向锁生效
// 单线程反复获取同一个锁
for (int i = 0; i < 1000; i++) {
synchronized (lock) {
// 此时使用偏向锁,性能极高
doSomething();
}
}
}
}偏向锁撤销(Revocation):
当另一个线程来竞争这个锁时,偏向锁需要撤销。撤销是个相对昂贵的操作:
- 等待全局安全点(STW,所有线程暂停)
- 检查持有偏向锁的线程是否还在同步块中
- 如果在:升级为轻量级锁
- 如果不在:恢复为无锁状态,让竞争线程重新竞争
这里有个重要的参数:当偏向锁撤销次数超过阈值(默认20次),JVM会进行批量重偏向;超过40次,会进行批量撤销,禁用该类的偏向锁。
注意: JDK 15起偏向锁被标记为废弃(Deprecated),JDK 21已彻底移除。原因是现代JVM启动时有大量竞争,偏向锁反而带来额外开销。如果你在用JDK 17+,可以直接忽略偏向锁这部分。
第二阶段:轻量级锁(Lightweight Locking)
当有两个线程交替(不是同时)竞争同一把锁时,会使用轻量级锁。
轻量级锁获取过程:
- 线程在自己的栈帧中创建一个Lock Record(锁记录)
- Lock Record包含两个字段:displaced mark word(存储对象原始Mark Word)和owner指针
- 用CAS把对象的Mark Word替换成指向Lock Record的指针
- 如果CAS成功:获取轻量级锁成功
- 如果CAS失败:说明有竞争,进入自旋等待
栈帧中的 Lock Record:
+------------------+
| displaced mark | <- 保存原始 Mark Word
+------------------+
| owner ptr | <- 指向对象
+------------------+
对象 Mark Word:
+------------------+
| ptr to LockRecord| <- 指向栈帧
| 00 (轻量级锁标志)|
+------------------+轻量级锁释放过程:
- 用CAS把displaced mark word替换回对象的Mark Word
- 如果CAS成功:释放成功
- 如果CAS失败:说明锁已经被膨胀为重量级锁,需要走重量级锁释放流程并唤醒等待线程
自旋与自适应自旋:
轻量级锁竞争失败后,线程不会立刻阻塞,而是自旋(忙等待)一段时间,期望持有锁的线程快速释放。
JDK 6引入了自适应自旋(Adaptive Spinning):
- 如果上一次自旋成功了,这次可以自旋更久
- 如果上一次自旋失败了,这次可能直接升级为重量级锁
第三阶段:重量级锁(Heavyweight Locking)
当竞争激烈,多个线程同时争抢同一把锁时,锁会膨胀为重量级锁。
重量级锁依赖操作系统的Mutex互斥量,涉及用户态和内核态的切换,开销很大(通常在1000ns以上)。
Monitor对象结构:
ObjectMonitor (重量级锁):
+-------------------+
| _owner | <- 指向持有锁的线程
+-------------------+
| _recursions | <- 重入次数
+-------------------+
| _EntryList | <- 等待获取锁的线程队列(blocked状态)
+-------------------+
| _WaitSet | <- 调用了wait()的线程(waiting状态)
+-------------------+
| _count | <- 进入次数统计
+-------------------+当线程竞争重量级锁失败,会被加入_EntryList,线程状态变为BLOCKED,不消耗CPU。持有锁的线程释放锁后,会唤醒_EntryList中的某个线程。
完整代码:观察锁升级过程
用JOL(Java Object Layout)工具可以直接看到Mark Word的变化:
<!-- pom.xml 添加依赖 -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>import org.openjdk.jol.info.ClassLayout;
/**
* 演示锁升级过程中 Mark Word 的变化
* JVM参数: -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
*
* 注意:JDK 15+ 偏向锁已废弃,此demo在JDK 8/11上效果最明显
*/
public class LockUpgradeDemo {
static Object obj = new Object();
public static void main(String[] args) throws Exception {
// 1. 无锁状态
System.out.println("=== 无锁状态 ===");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
// 2. 偏向锁(线程T1第一次获取)
Thread t1 = new Thread(() -> {
synchronized (obj) {
System.out.println("=== T1持有偏向锁 ===");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}, "T1");
t1.start();
t1.join();
// 3. 轻量级锁(T2来竞争,T1已退出同步块)
Thread t2 = new Thread(() -> {
synchronized (obj) {
System.out.println("=== T2持有轻量级锁 ===");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
// 4. 重量级锁(T3来竞争,T2还在持有)
Thread t3 = new Thread(() -> {
synchronized (obj) {
System.out.println("=== 重量级锁(T3等待后获取)===");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}, "T3");
t3.start();
try { Thread.sleep(100); } catch (Exception e) {}
System.out.println("=== T2持有中,T3等待,观察重量级锁 ===");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
try { t3.join(); } catch (Exception e) {}
}
}, "T2");
t2.start();
t2.join();
System.out.println("=== 锁释放后(重量级锁不会降级)===");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}输出的Mark Word最后几位就是锁状态标志:01无锁/偏向、00轻量级、10重量级。
三个踩坑实录
坑一:synchronized 方法里调用远程接口,延迟炸了
现象: 订单服务P99从80ms暴涨到2300ms,8个工作线程全部卡住。
原因: 我在synchronized方法里加了一个调用库存服务的HTTP接口,平均耗时150ms。在4000 QPS下,8个线程的并发处理能力变成了8 / 0.15s ≈ 53 QPS,严重不够用。
解法: 把远程调用移到synchronized块外面:
// 错误写法
public synchronized OrderResult processOrder(OrderRequest req) {
// 这个调用耗时150ms,锁住期间别的线程全等着
InventoryResult inv = inventoryService.checkStock(req.getSkuId());
// 真正需要加锁的只有下面这一行
return orderDao.createOrder(req, inv);
}
// 正确写法
public OrderResult processOrder(OrderRequest req) {
// 移到锁外面,并发查询
InventoryResult inv = inventoryService.checkStock(req.getSkuId());
synchronized (this) {
// 只锁住真正需要原子性的操作
return orderDao.createOrder(req, inv);
}
}锁内的操作耗时从150ms降到了5ms以内,P99立刻回归正常。
坑二:锁对象被替换,锁失效
现象: 线上某个限流逻辑偶发并发穿透,明明加了synchronized却没效果。
原因: 同事把锁对象定义成了private String lockKey = "lock",然后在某个地方做了lockKey = newKey的赋值。每次赋值,synchronized锁的对象引用就变了,相当于换了一把锁。
// 错误写法,lockKey 引用可能被更换
private String lockKey = "lock";
public void doLimit() {
synchronized (lockKey) { // 锁的是 "lock" 这个String对象
// ...
}
lockKey = "new_lock"; // 这里把锁对象换掉了!后续synchronized锁的是新对象
}
// 正确写法:用 final 保证锁对象引用不可变
private final Object lock = new Object();
public void doLimit() {
synchronized (lock) {
// ...
}
}解法: 锁对象必须用final修饰,确保引用不会被替换。
坑三:Class 锁与实例锁混用导致的假死
现象: 线程A持有实例锁,线程B持有Class锁,两个线程都在等对方,形成死锁,服务假死。
原因: 没意识到synchronized(this)和synchronized(SomeClass.class)是两把完全独立的锁,在某些场景下混用造成了循环等待。
public class LockMixDemo {
// 实例锁
public synchronized void method1() {
System.out.println(Thread.currentThread().getName() + " 获取实例锁");
// 试图获取 Class 锁
LockMixDemo.method2();
}
// Class 锁
public static synchronized void method2() {
System.out.println(Thread.currentThread().getName() + " 获取Class锁");
LockMixDemo demo = new LockMixDemo();
// 试图获取实例锁
demo.method1();
}
}解法: 统一使用同一把锁,或者梳理清楚锁的层次关系,避免交叉持有。用jstack分析线程栈,能快速定位这类死锁。
锁升级是单向的吗?
重量级锁可以降级吗?答案是:在STW期间,GC有机会降级重量级锁,但这不是常规路径,在实际编程中可以认为锁升级是单向的。
一旦锁升级为重量级锁,即使竞争消失,它也不会自动降级为轻量级锁。这就是为什么我们要尽量控制锁的粒度——一旦打起来,代价会一直在。
性能对比与选型建议
根据我自己的JMH测试(8核心机器,1000万次操作):
| 锁类型 | 单线程 | 2线程(交替) | 8线程(竞争) |
|---|---|---|---|
| 无锁 | 2ns | - | - |
| 偏向锁 | 3ns | 50ns(撤销开销) | - |
| 轻量级锁 | 18ns | 25ns | - |
| 重量级锁 | 800ns | 1200ns | 3000ns+ |
实战选型:
- 单线程/极低竞争:偏向锁自动生效,不用管
- 低竞争交替访问:轻量级锁,性能可以接受
- 高竞争:考虑用
ReentrantLock+tryLock超时机制,或者换无锁数据结构 - 方法内有IO/远程调用:坚决不要加synchronized,考虑异步化
小结
synchronized的锁升级是JVM为了平衡性能与正确性做的工程优化:
- 偏向锁:单线程场景下几乎零开销,靠CAS写线程ID实现
- 轻量级锁:低竞争场景,用CAS+自旋避免线程切换
- 重量级锁:高竞争场景,靠OS Mutex保证公平,代价是用户态/内核态切换
理解这个升级过程,你就能回答面试官的深度追问,更重要的是——能在线上出问题时快速定位到锁竞争这个方向,而不是像我当年一样对着监控大屏抓瞎。
