synchronized锁升级全过程:偏向锁→轻量级锁→重量级锁的对象头变化
synchronized锁升级全过程:偏向锁→轻量级锁→重量级锁的对象头变化
适读人群:有Java并发基础、想深入JVM锁机制的后端工程师 | 阅读时长:约18分钟
开篇故事
2019年双十一备战,我在某电商公司负责核心交易链路的性能优化。当时有个订单状态更新的接口,压测到5000 QPS就开始抖动,P99延迟从8ms飙到了400ms。
监控大盘一看,线程池里的线程有大批处于BLOCKED状态。我们的技术leader老陈拍着桌子说:"这个接口就一个synchronized方法,怎么会这样?"
我接手排查。用jstack抓了三次线程快照,发现一个叫updateOrderStatus的方法上面聚集了200多个BLOCKED线程,持有锁的线程执行时间只有0.3ms,但排队的线程等了几百毫秒。
当时我对synchronized的理解还停留在"加锁、解锁"这个层次,完全不知道JVM内部有个锁升级的过程。后来我用-XX:+PrintBiasedLockingStatistics打出偏向锁统计,发现偏向锁撤销次数高达每秒30万次——在高并发场景下,偏向锁反复撤销的开销比重量级锁还大。
最终我们把JVM参数加上-XX:-UseBiasedLocking,关掉偏向锁,P99延迟直接从400ms降到了12ms。
这件事彻底点燃了我对synchronized底层机制的好奇心。今天就把这套锁升级的完整过程讲清楚,从对象头的二进制变化讲到每种锁状态的适用场景。
一、Java对象头与锁状态的关系
1.1 对象头的内存布局
在HotSpot JVM里,每个Java对象在堆内存中由三部分组成:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
对象头里有一个叫做Mark Word的结构,这是锁升级的核心战场。在64位JVM(开启指针压缩)下,Mark Word占8字节,随着锁状态的变化,这8字节里存储的内容会发生根本性变化。
不同锁状态下Mark Word的布局:
| 锁状态 | 低2位标志位 | Mark Word内容 |
|---|---|---|
| 无锁 | 01 | hashcode(25bit) + age(4bit) + biased_lock(0) + 01 |
| 偏向锁 | 01 | thread_id(54bit) + epoch(2bit) + age(4bit) + biased_lock(1) + 01 |
| 轻量级锁 | 00 | 指向线程栈Lock Record的指针(62bit) + 00 |
| 重量级锁 | 10 | 指向ObjectMonitor的指针(62bit) + 10 |
| GC标记 | 11 | 空 |
注意偏向锁和无锁的标志位都是01,靠biased_lock这1位区分。
1.2 为什么要设计锁升级
synchronized在JDK 1.6之前,每次加锁都是直接向OS申请互斥量(mutex),这涉及用户态到内核态的切换,代价非常高,大概是几千个纳秒级别。
JDK 1.6引入了偏向锁和轻量级锁,核心思路是:
- 大多数时候锁根本没有竞争(偏向锁假设:同一个线程多次加锁)
- 就算有竞争,往往是两个线程交替执行(轻量级锁:CAS自旋)
- 只有真正高竞争时,才升级到重量级锁(OS mutex)
这是一种"乐观主义"策略——先假设最好的情况,不行了再升级。
1.3 锁只能升级不能降级
这是一个很多人不知道的细节:synchronized的锁升级是单向的,只能升不能降(JVM有一个安全点时的批量偏向/撤销机制,但正常路径上不会降级)。
一旦升级到重量级锁,即使后来竞争消失,也不会退回轻量级锁。所以对于确实存在竞争的场景,偏向锁反而是负担。
二、锁升级全过程深度解析
2.1 偏向锁阶段
初始化: 当JVM启动后(默认延迟4秒,由-XX:BiasedLockingStartupDelay=4000控制),新创建的对象Mark Word会是[thread_id=0 | epoch=0 | age=0 | biased_lock=1 | 01],即处于可偏向但尚未偏向任何线程的状态。
第一次加锁: 线程T1执行synchronized(obj)时:
- 检查Mark Word的
biased_lock位,发现是1(可偏向) - 检查
thread_id字段,发现是0(未偏向任何线程) - CAS操作,将自己的线程ID写入Mark Word的
thread_id字段 - 写入成功,偏向锁建立
同一线程再次加锁: 线程T1再次执行synchronized(obj)时:
- 检查Mark Word,
thread_id == T1,直接进入,不需要任何CAS操作 - 这就是偏向锁的精髓:对于"一直只有一个线程"的场景,加锁解锁几乎是零开销
偏向锁撤销: 当线程T2也来竞争这个锁时,需要撤销偏向:
- T2发现Mark Word里的
thread_id != T2 - T2向JVM提交撤销请求,JVM等待下一个全局安全点(safepoint)
- 在安全点,JVM暂停持有偏向锁的T1(Stop-The-World!)
- 检查T1是否还在执行synchronized块:
- 如果T1已经退出:直接将Mark Word改为无锁或轻量级锁状态
- 如果T1还在执行:升级为轻量级锁
批量重偏向和批量撤销: 当同一个类的对象偏向撤销次数超过阈值(默认20次),JVM会进行批量重偏向(把该类所有存活对象的epoch+1,下次偏向时偏向新线程)。超过40次,JVM会进行批量撤销,该类对象直接禁用偏向锁。
2.2 轻量级锁阶段
轻量级锁适用于两个线程交替执行,不存在真正竞争的场景。
加锁过程:
- 线程T1在自己的线程栈帧中分配一块空间,叫做Lock Record(锁记录)
- Lock Record包含:
displaced_mark_word(保存对象原来的Mark Word)+object_reference(指向被锁对象) - T1尝试CAS:将对象的Mark Word替换为指向Lock Record的指针
- CAS成功:T1获得轻量级锁,对象的Mark Word低2位变为
00 - CAS失败:说明已经有其他线程持有轻量级锁,开始自旋等待
解锁过程:
- T1释放锁时,CAS将对象Mark Word还原为
displaced_mark_word - CAS成功:解锁完成
- CAS失败:说明在T1持锁期间发生了锁竞争,锁已被升级为重量级锁,需要走重量级锁的解锁流程(唤醒等待线程)
自旋优化(JDK 1.6+): JDK 1.6引入了自适应自旋,不再是固定的10次自旋。如果上次在这个锁对象上自旋成功了,这次自旋的时间就长一点;如果上次自旋超时了,这次甚至直接不自旋,直接升级。
2.3 重量级锁阶段
重量级锁依赖OS的互斥量(Mutex Lock),线程在竞争失败时会被挂起,进入内核态的等待队列。
核心数据结构是ObjectMonitor,在HotSpot源码(objectMonitor.hpp)中:
ObjectMonitor {
_header; // 保存原始Mark Word
_count; // 递归锁计数
_waiters; // 等待wait()的线程数
_recursions; // 重入次数
_object; // 对应的Java对象
_owner; // 当前持有锁的线程
_WaitSet; // 调用wait()后进入的等待集合
_EntryList; // 等待锁的线程队列(阻塞状态)
_cxq; // 竞争锁时的临时队列
}加锁流程:
- T2竞争失败后,被封装为
ObjectWaiter节点,插入_cxq队列头部 - T2调用
park(),线程挂起,进入BLOCKED状态 - T1释放锁时,调用
unpark()唤醒_EntryList(或_cxq)中的一个线程 - 被唤醒的线程重新竞争,获得锁则设置
_owner = this
wait/notify与重量级锁的关系: Object.wait()只能在持有重量级锁的情况下调用。调用wait()后,线程从_EntryList移动到_WaitSet;notify()则将_WaitSet中的一个线程移回_EntryList或_cxq参与竞争。
三、完整代码实现
3.1 用JOL观察对象头变化
package com.laozhang.concurrent.lock;
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
/**
* 用JOL(Java Object Layout)工具观察锁升级过程中对象头的变化
* 依赖:org.openjdk.jol:jol-core:0.17
*
* JVM参数:
* -XX:+UseBiasedLocking
* -XX:BiasedLockingStartupDelay=0
* -XX:+PrintBiasedLockingStatistics
*
* 测试环境:JDK 11.0.18,64位HotSpot JVM,开启指针压缩
*/
public class LockUpgradeDemo {
static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
System.out.println(VM.current().details());
System.out.println("=== 初始状态(无锁,可偏向)===");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
// 步骤1:让T1线程独占,触发偏向锁
Thread t1 = new Thread(() -> {
synchronized (lock) {
System.out.println("=== T1持有锁时(偏向锁)===");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
// 打印线程ID,和Mark Word里的thread_id对比
System.out.println("T1 thread id (native): "
+ Long.toHexString(Thread.currentThread().getId()));
}
}, "T1");
t1.start();
t1.join();
System.out.println("=== T1退出后(仍是偏向锁,偏向T1)===");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
// 步骤2:T2竞争,触发偏向锁撤销和轻量级锁升级
Thread t2 = new Thread(() -> {
synchronized (lock) {
System.out.println("=== T2持有锁时(轻量级锁或重量级锁)===");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
}, "T2");
t2.start();
t2.join();
System.out.println("=== 最终状态 ===");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
}运行这段代码(JDK 11,-XX:BiasedLockingStartupDelay=0),你能直接看到Mark Word从0x0000000000000005(可偏向)变成0x00007f...00000005(偏向某线程)再变成0x...00000000(轻量级锁)的完整过程。
3.2 模拟高竞争场景下的锁升级与性能对比
package com.laozhang.concurrent.lock;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;
/**
* 对比三种场景下synchronized的性能:
* 1. 单线程重复访问(偏向锁效果最佳)
* 2. 两线程交替(轻量级锁)
* 3. 高并发竞争(重量级锁)
*
* 测试结果参考(JDK 11,MacBook Pro M1):
* 单线程1000万次: ~18ms (偏向锁,几乎零开销)
* 两线程1000万次: ~95ms (轻量级锁,CAS开销)
* 16线程1000万次: ~1240ms (重量级锁,OS mutex开销)
*/
public class LockPerformanceTest {
private static final Object BIASED_LOCK = new Object();
private static final Object LIGHTWEIGHT_LOCK = new Object();
private static final Object HEAVYWEIGHT_LOCK = new Object();
private static long counter = 0;
public static void main(String[] args) throws InterruptedException {
// 预热,确保JIT编译
for (int i = 0; i < 100_000; i++) {
synchronized (BIASED_LOCK) { counter++; }
}
counter = 0;
// 场景1:单线程偏向锁
long t1 = System.currentTimeMillis();
for (int i = 0; i < 10_000_000; i++) {
synchronized (BIASED_LOCK) {
counter++;
}
}
System.out.printf("单线程(偏向锁):%dms,counter=%d%n",
System.currentTimeMillis() - t1, counter);
// 场景2:两线程轻量级锁
counter = 0;
CountDownLatch latch2 = new CountDownLatch(2);
long t2start = System.currentTimeMillis();
for (int t = 0; t < 2; t++) {
new Thread(() -> {
for (int i = 0; i < 5_000_000; i++) {
synchronized (LIGHTWEIGHT_LOCK) {
counter++;
}
}
latch2.countDown();
}).start();
}
latch2.await();
System.out.printf("两线程(轻量级锁):%dms,counter=%d%n",
System.currentTimeMillis() - t2start, counter);
// 场景3:16线程重量级锁
counter = 0;
CountDownLatch latch16 = new CountDownLatch(16);
long t3start = System.currentTimeMillis();
for (int t = 0; t < 16; t++) {
new Thread(() -> {
for (int i = 0; i < 625_000; i++) {
synchronized (HEAVYWEIGHT_LOCK) {
counter++;
}
}
latch16.countDown();
}).start();
}
latch16.await();
System.out.printf("16线程(重量级锁):%dms,counter=%d%n",
System.currentTimeMillis() - t3start, counter);
}
}3.3 用HSDB(HotSpot Debugger)直接查看对象头
package com.laozhang.concurrent.lock;
import sun.misc.Unsafe;
import java.lang.reflect.Field;
/**
* 直接通过Unsafe读取对象头的Mark Word
* 注意:这是调试用途,生产代码禁止使用Unsafe
*
* 在JDK 8下运行,需要添加VM参数:
* --add-opens java.base/sun.misc=ALL-UNNAMED(JDK 9+)
*/
public class MarkWordInspector {
private static final Unsafe UNSAFE;
static {
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
UNSAFE = (Unsafe) f.get(null);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 读取对象的Mark Word(64位JVM,对象头偏移量为0)
*/
public static long getMarkWord(Object obj) {
return UNSAFE.getLong(obj, 0L);
}
/**
* 解析Mark Word,返回锁状态描述
*/
public static String parseLockState(long markWord) {
int lockBits = (int)(markWord & 0x3); // 低2位
int biasedBit = (int)((markWord >> 2) & 0x1); // 第3位
if (lockBits == 0x1 && biasedBit == 0) {
return String.format("无锁状态 [markword=0x%016x, hashcode=%d, age=%d]",
markWord,
(int)((markWord >> 7) & 0x1FFFFFF),
(int)((markWord >> 3) & 0xF));
} else if (lockBits == 0x1 && biasedBit == 1) {
long threadId = (markWord >> 10) & 0x3FFFFL;
return String.format("偏向锁状态 [markword=0x%016x, threadId=%d, epoch=%d, age=%d]",
markWord, threadId,
(int)((markWord >> 8) & 0x3),
(int)((markWord >> 3) & 0xF));
} else if (lockBits == 0x0) {
return String.format("轻量级锁状态 [markword=0x%016x, lockRecord指针=0x%x]",
markWord, markWord & ~0x3L);
} else if (lockBits == 0x2) {
return String.format("重量级锁状态 [markword=0x%016x, monitor指针=0x%x]",
markWord, markWord & ~0x3L);
} else {
return String.format("GC标记 [markword=0x%016x]", markWord);
}
}
public static void main(String[] args) throws InterruptedException {
Object obj = new Object();
System.out.println("初始: " + parseLockState(getMarkWord(obj)));
synchronized (obj) {
System.out.println("持锁中: " + parseLockState(getMarkWord(obj)));
}
System.out.println("释放后: " + parseLockState(getMarkWord(obj)));
}
}四、踩坑实录
坑1:偏向锁启动延迟导致性能测试结果不准
报错现象: 做synchronized性能基准测试,前4秒的数据比后面的数据差很多,TP99相差3倍以上。
原因分析: JVM默认有-XX:BiasedLockingStartupDelay=4000(4秒延迟)。在这4秒内,新创建的对象是不可偏向的(Mark Word初始状态的biased_lock位为0),所有加锁直接走轻量级锁。4秒后新建的对象才会从可偏向状态开始。
如果你的基准测试在JVM启动后4秒内就初始化了对象,那这些对象永远不会走偏向锁。
解法: 设置-XX:BiasedLockingStartupDelay=0关闭延迟,或者在测试开始前Thread.sleep(5000)等待延迟结束。
// 错误:JVM启动立即创建对象,此时偏向锁还没启用
static final Object LOCK = new Object(); // 不可偏向
// 正确:等待偏向锁启动后再创建对象,或设置Delay=0坑2:调用hashCode()会导致偏向锁永久失效
报错现象: 明明是单线程场景,打印锁统计时发现偏向锁"从未命中",全走了轻量级锁。
原因分析: 偏向锁状态下,Mark Word里存的是thread_id,没有地方存hashCode。当你对一个可偏向对象调用Object.hashCode()(或System.identityHashCode())时,JVM会计算hashCode并把它写入Mark Word,这个对象从此永久不可偏向。
Object obj = new Object();
System.identityHashCode(obj); // 触发hashCode计算,写入Mark Word
// 此后 obj 永远不会走偏向锁!
synchronized (obj) {
// 直接走轻量级锁
}注意:String.hashCode()是逻辑哈希,不影响Mark Word;影响的是Object.hashCode()这种identity hash。
解法: 作为锁使用的对象,不要在加锁前调用identityHashCode()。实在需要,就接受它不走偏向锁。
坑3:高并发下偏向锁撤销带来Stop-The-World暂停
报错现象: 压测时GC日志里出现大量极短暂的STW暂停(1-5ms),但GC次数看起来正常,用-XX:+PrintGCDetails也没看到Full GC,排查了半天找不到原因。
原因分析: 偏向锁撤销本身就是一种Stop-The-World操作!当某个线程竞争一个已经偏向了其他线程的锁时,需要在安全点暂停所有线程来检查和修改偏向状态。大量的偏向锁撤销会产生密集的短暂STW,虽然每次只有几毫秒,但积少成多就很可观。
用-XX:+PrintBiasedLockingStatistics可以看到撤销次数:
Biased locking revocations: 152847
Biased locking bulk rebiasings: 12
Biased locking bulk revocations: 3一秒内15万次撤销,每次1ms的STW,相当于每秒150秒的STW时间——当然实际上JVM会合并处理,但高频率的安全点请求会显著影响吞吐量。
解法: 对于高并发竞争的对象,直接禁用偏向锁:
-XX:-UseBiasedLocking或者在JDK 15+,偏向锁已经被标记为deprecated,JDK 19已经彻底移除偏向锁(-XX:+UseBiasedLocking不再有效)。
坑4:synchronized方法和synchronized块的锁升级路径不同
报错现象: 用字节码分析工具看到synchronized方法用的是ACC_SYNCHRONIZED标志,而synchronized块用的是monitorenter/monitorexit指令,以为底层机制不同,其实……
原因分析: 底层机制完全一样,都依赖ObjectMonitor。ACC_SYNCHRONIZED是方法级别的标志,JVM在调用方法前后自动插入monitorenter/monitorexit语义,本质相同。
区别在于:synchronized(this)锁的是当前对象,synchronized(ClassName.class)锁的是Class对象,synchronized方法等价于synchronized(this)(非静态)或synchronized(ClassName.class)(静态方法)。
锁升级过程对这三种写法完全一致。
五、总结与延伸
synchronized锁升级是JVM为了平衡"轻量不竞争"与"高并发竞争"两种极端场景设计的自适应机制:
- 偏向锁:零开销,适合单线程重复访问,代价是撤销时有STW
- 轻量级锁:CAS开销,适合两线程交替,代价是自旋消耗CPU
- 重量级锁:OS mutex,适合高竞争,代价是线程阻塞与唤醒的上下文切换
JDK版本演变:
- JDK 6:引入偏向锁和轻量级锁,synchronized性能大幅提升
- JDK 6 update 14:引入自适应自旋
- JDK 15:偏向锁标记为deprecated(
-XX:+UseBiasedLocking加上deprecation warning) - JDK 19:完全移除偏向锁,
-XX:+UseBiasedLocking无效
实践建议:
- JDK 11及以前的高并发项目,主动加
-XX:-UseBiasedLocking - JDK 17+,不用担心偏向锁问题,专注关注轻量级锁的自旋开销
- 真正高竞争场景考虑换用
ReentrantLock(可中断、可超时、公平锁) - 用JOL和
-XX:+PrintBiasedLockingStatistics做诊断,不要凭感觉判断
延伸阅读方向:
- AQS(AbstractQueuedSynchronizer)的设计:下一篇讲
- Lock-Free数据结构:CAS原语的更广泛应用
- LMAX Disruptor:完全避开synchronized的高性能并发队列设计
