synchronized锁升级全过程:偏向锁→轻量级锁→重量级锁的对象头变化
synchronized锁升级全过程:偏向锁→轻量级锁→重量级锁的对象头变化
适读人群:Java中高级开发工程师 | 阅读时长:约18分钟 | 适用JDK版本:JDK 6 ~ 17(偏向锁在JDK 15废弃,JDK 17移除)
开篇故事
2021年,我们做了一次线程池大小的压测优化,发现一个奇怪的现象:把线程池从10个线程调整到100个线程后,系统吞吐量反而下降了30%。
CPU利用率上升了,但处理的请求量反而少了,请求的平均延迟从5ms涨到了40ms。
用async-profiler一看,大量时间花在了ObjectMonitor::enter上——这是synchronized重量级锁的争锁操作,涉及操作系统互斥量(Mutex),需要线程在内核态和用户态之间切换。
100个线程争同一把锁,绝大多数时间都在内核态挂起等待,有效工作时间极短。而10个线程时,锁竞争没那么激烈,大部分时候是轻量级锁(CAS操作,不需要内核介入),速度快得多。
这次经历让我深刻理解了synchronized锁升级的意义:JVM为了在不同的竞争场景下选择最优的锁实现,设计了从偏向锁→轻量级锁→重量级锁的升级路径,每一级都有其适用场景,滥用或使用不当会严重损害性能。
一、问题根因分析
synchronized在Java 1.0时代是一把重量级锁——每次加锁都要申请操作系统互斥量,成本极高。Java 6引入了锁升级优化(偏向锁和轻量级锁),使得无竞争情况下synchronized几乎没有开销。
理解锁升级的三个核心问题:
问题一:为什么需要偏向锁。大量研究表明,很多锁在实际运行中根本没有竞争——总是由同一个线程反复获取。偏向锁为这种场景做了极致优化,第一次获取锁后,后续同一线程再获取时不需要任何同步操作。
问题二:轻量级锁的CAS成本。轻量级锁用CAS(Compare-And-Swap)操作替代了操作系统互斥量,避免了线程挂起和唤醒的开销,但CAS本身有一定成本,自旋等待会消耗CPU。适合锁持有时间短、竞争线程少的场景。
问题三:重量级锁何时不可避免。当竞争激烈、锁持有时间长、自旋等待代价超过挂起代价时,应该升级到重量级锁,避免CPU被自旋白白消耗。
二、原理深度解析
2.1 对象头Mark Word的结构
锁升级的状态都存储在对象头的Mark Word中(见第662篇的详细说明)。回顾64位JVM中Mark Word的布局:
无锁(001):
| hashcode(31) | unused(1) | age(4) | biasable(1)=0 | lock(2)=01 |
可偏向未锁定(101):
| 0(54) | epoch(2) | age(4) | biasable(1)=1 | lock(2)=01 |
已偏向(101):
| thread_id(54) | epoch(2) | age(4) | biasable(1)=1 | lock(2)=01 |
轻量级锁(00):
| ptr_to_lock_record(62) | lock(2)=00 |
重量级锁(10):
| ptr_to_heavyweight_monitor(62) | lock(2)=10 |
GC标记(11):
| (empty) | lock(2)=11 |lock字段的最低2位决定了锁状态:01(无锁或偏向锁)、00(轻量级锁)、10(重量级锁)、11(GC标记)。偏向锁和无锁通过biasable位(倒数第3位)区分。
2.2 锁升级的完整状态机
2.3 偏向锁的详细过程(JDK 6-14)
第一次加锁(设置偏向):
初始状态:Mark Word = [0(54位)] [epoch(2)] [age(4)] [1] [01]
(biasable=1, lock=01, thread_id=0 表示可偏向未偏向状态)
线程T1第一次加锁:
1. JVM发现对象处于"可偏向"状态(biasable=1, thread_id=0)
2. 用CAS将thread_id设为T1的ID
3. CAS成功:Mark Word = [T1_id(54)] [epoch(2)] [age(4)] [1] [01]
4. 锁获取成功,没有任何同步开销同一线程重入(零开销):
T1再次加锁同一对象:
1. 检查Mark Word的thread_id是否等于当前线程T1
2. 是!直接进入同步块,无需任何操作
3. 这就是偏向锁的核心价值:重入时零开销偏向锁撤销(其他线程来竞争):
T2尝试获取已偏向T1的锁:
1. T2发现Mark Word的thread_id是T1,不是自己
2. 触发偏向锁撤销(需要STW或安全点)
3. 检查T1是否还在使用这个锁(T1是否存活且在同步块内)
- T1不在使用:将Mark Word重置为无锁状态,T2再次尝试
- T1在使用中:升级为轻量级锁,在T1的栈帧中创建Lock Record
4. 偏向锁撤销是有成本的!频繁撤销会影响性能为什么JDK 15废弃偏向锁:
偏向锁的实现复杂,而且在高并发场景下,大量的偏向锁撤销(Revoke)会造成显著的STW停顿。随着高并发编程的普及,很多代码都有轻量级的锁竞争,偏向锁反而成了负担。JDK 15将偏向锁标记为deprecated,JDK 17移除了偏向锁,-XX:-UseBiasedLocking也不再有效。
2.4 轻量级锁的详细过程
T1加轻量级锁:
1. 在T1的栈帧中创建一个Lock Record(锁记录)
2. 将对象Mark Word的副本(Displaced Mark Word)存入Lock Record
3. 用CAS将对象Mark Word的lock pointer改为指向Lock Record
- CAS成功:加锁成功,Mark Word = [ptr_to_lock_record(62)] [00]
- CAS失败:有竞争,进入重量级锁升级
T1释放轻量级锁:
1. 用CAS将对象Mark Word从Lock Record中恢复(替换回Displaced Mark Word)
- CAS成功:解锁完成,Mark Word恢复为无锁状态
- CAS失败:有线程在等待,触发重量级锁,唤醒等待线程轻量级锁的关键特征:
- 加锁/解锁都是CAS操作,在用户态完成,无内核切换
- 有竞争时,等待线程会自旋(忙等)
- 自旋的优化:JDK 6引入自适应自旋(Adaptive Spinning),根据历史成功率动态调整自旋次数(10次~128次不等)
2.5 重量级锁的详细过程
当轻量级锁的自旋失败(或自旋次数超过阈值),升级为重量级锁:
重量级锁升级:
1. 创建ObjectMonitor对象(在堆外,C++对象)
2. 对象Mark Word = [ptr_to_ObjectMonitor(62)] [10]
3. ObjectMonitor包含:
- owner:持有锁的线程
- EntryList:等待锁的线程队列(阻塞状态)
- WaitSet:调用wait()的线程集合
T2等待重量级锁:
1. T2进入ObjectMonitor::enter()
2. CAS尝试设置owner为T2
3. CAS失败(T1持有锁)
4. T2进入EntryList,线程状态变为BLOCKED
5. 操作系统将T2挂起(不占用CPU)
T1释放重量级锁:
1. 清空owner
2. 从EntryList选取一个等待线程唤醒
3. 被唤醒的线程重新竞争锁(可能有多个线程同时被唤醒)重量级锁的成本:
- 线程挂起和唤醒需要在用户态和内核态之间切换
- 每次切换约需要1-10微秒的系统调用开销
- 大量锁竞争时,CPU大部分时间在做上下文切换,有效工作时间极低
2.6 JDK 21的虚拟线程对锁的影响
JDK 21引入了虚拟线程(Virtual Threads),对synchronized有重要变化:
- JDK 21及之前:虚拟线程遇到synchronized会"pin"(固定)到载体线程,无法挂起让出载体线程,可能导致载体线程被长期占用
- JDK 24+(改进中):synchronized能正确地挂起虚拟线程,释放载体线程
在虚拟线程场景下,目前推荐用ReentrantLock替代synchronized,以避免pin的问题。
三、诊断工具与命令
3.1 查看锁竞争情况
# 用jstack查看锁竞争
jstack <pid> | grep -A5 "BLOCKED"
# 查看死锁
jstack <pid> | grep -A20 "deadlock"
# 使用Arthas查看锁竞争
java -jar arthas-boot.jar
> monitor com.example.OrderService processOrder -c 10
# 监控方法10次调用的统计,关注rt(响应时间)
# 如果rt忽高忽低,可能是锁竞争导致的抖动
# 检测死锁
> thread -b
# 查看某个特定线程的栈
> thread <线程ID>3.2 使用JFR分析锁竞争
# 启用JFR分析锁竞争
jcmd <pid> JFR.start duration=60s filename=/tmp/lock.jfr settings=profile
# 在JMC中查看
# Events → Java Monitor Blocked(找到锁竞争最多的对象)
# Events → Java Monitor Wait(找到等待最多的对象)
# 也可以设置触发阈值
jcmd <pid> JFR.start duration=60s \
+jdk.JavaMonitorEnter#threshold=1ms \ # 等待超过1ms才记录
filename=/tmp/lock_contention.jfr3.3 分析对象头状态(调试工具)
# 使用JOL(Java Object Layout)工具查看对象头
# 在pom.xml中添加依赖
# <dependency>
# <groupId>org.openjdk.jol</groupId>
# <artifactId>jol-core</artifactId>
# <version>0.17</version>
# </dependency>
# 代码中查看对象头
Object obj = new Object();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
// 加锁前后对比,可以看到Mark Word的变化四、完整调优方案
4.1 降低锁粒度
// 差:锁粒度太大,所有操作串行
public class UserService {
private final Map<Long, User> cache = new HashMap<>();
public synchronized User getUser(Long id) { ... }
public synchronized void updateUser(User user) { ... }
public synchronized void deleteUser(Long id) { ... }
// 所有方法都争同一把this锁
}
// 好:使用ConcurrentHashMap,细粒度锁
public class UserService {
private final ConcurrentHashMap<Long, User> cache = new ConcurrentHashMap<>();
public User getUser(Long id) {
return cache.get(id); // 无锁或分段锁,高度并发
}
public void updateUser(User user) {
cache.put(user.getId(), user); // ConcurrentHashMap内部细粒度锁
}
}4.2 减少锁的持有时间
// 差:持有锁期间做了耗时操作
public synchronized void processAndSave(Order order) {
Order processed = expensiveProcess(order); // 耗时100ms
save(processed); // 耗时10ms
}
// 好:在锁外做耗时操作
public void processAndSave(Order order) {
Order processed = expensiveProcess(order); // 不持有锁,并发执行
synchronized (this) {
save(processed); // 只在持久化时加锁,持锁时间极短
}
}4.3 JVM锁相关参数
# JDK 6-14:偏向锁配置
-XX:+UseBiasedLocking # 开启偏向锁(默认开启)
-XX:BiasedLockingStartupDelay=0 # 关闭4秒延迟(立即开启偏向锁,适合启动后立刻高并发的场景)
# JDK 15+已废弃偏向锁,这些参数无效
# 自适应自旋
-XX:+UseSpinning # 开启自旋(默认开启)
# -XX:PreBlockSpin=10 # 已废弃,JVM自适应控制
# 锁粗化(合并相邻的锁操作)
-XX:+EliminateLocks # 默认开启,JIT自动合并相邻锁
# 诊断:打印锁优化信息
-XX:+PrintBiasedLockingStatistics # 需要-XX:+UnlockDiagnosticVMOptions五、踩坑实录
坑一:批量偏向锁撤销导致的STW
系统上线时设置了-XX:BiasedLockingStartupDelay=0,希望立刻启用偏向锁。结果启动阶段偶发性地出现几秒钟的停顿。
原因:启动时大量线程快速地获取和释放同一批对象的锁,导致频繁的偏向锁撤销。JVM做批量偏向锁撤销(Bulk Revocation)时会触发STW。
解决方案:对于启动时高并发的系统,反而应该直接禁用偏向锁-XX:-UseBiasedLocking,让锁直接从轻量级锁开始,避免偏向锁撤销的开销。
坑二:synchronized和wait/notify配合导致的性能问题
有个生产者-消费者模型,用synchronized + wait/notify实现。生产者生产速度极快,消费者消费速度相对慢。结果发现消费者线程频繁地被wait()挂起,生产者频繁地notifyAll()唤醒,大量时间在内核态做线程切换。
改成LinkedBlockingQueue(内部用ReentrantLock + Condition实现),相同场景下吞吐量提升了4倍。LinkedBlockingQueue的内部实现对生产者和消费者用了两把锁(takeLock和putLock),生产和消费可以并发进行,减少了锁竞争。
坑三:误用String作为锁对象
有段代码用用户ID(String)作为锁对象,想实现用户级别的细粒度加锁:
synchronized (userId) { // userId是String
// 处理用户数据
}问题是:JVM有字符串常量池,"123".intern() 和另一个值为"123"的String对象,可能是同一个对象,也可能不是。用运行时创建的String对象作为锁,行为不可预测,可能根本起不到锁的作用(不同字符串对象锁不住同一个用户),也可能过度串行化(相同内容的字符串被intern后共享同一锁)。
正确做法:用ConcurrentHashMap<String, Object>维护锁对象:
private final ConcurrentHashMap<String, Object> lockMap = new ConcurrentHashMap<>();
public void processUser(String userId) {
Object lock = lockMap.computeIfAbsent(userId, k -> new Object());
synchronized (lock) {
// 处理用户数据
}
}
// 注意:这种方式lockMap里的锁对象也要定期清理,否则内存泄漏坑四:JDK 17移除偏向锁带来的兼容性问题
升级JDK 17后,某些旧代码在启动脚本里有-XX:+UseBiasedLocking和-XX:BiasedLockingStartupDelay=0参数,JDK 17启动时打印警告:
OpenJDK 64-Bit Server VM warning: Option UseBiasedLocking was deprecated
in version 15.0 and will likely be removed in a future release虽然只是警告,但每次启动都有大量的警告信息,影响日志可读性。清理掉这些废弃参数即可。
六、总结
synchronized锁升级是JVM在性能和安全性之间精心平衡的产物:
偏向锁(JDK 6-14):无竞争场景的极致优化,重入时零开销。适合单线程反复加锁的场景。但JDK 15+已废弃,因为偏向锁撤销的成本在高并发场景下得不偿失。
轻量级锁:少量线程竞争时的高效解决方案,用CAS替代内核互斥量,避免线程挂起。适合锁持有时间短、竞争不激烈的场景。
重量级锁:激烈竞争时的兜底方案,用操作系统互斥量确保正确性。适合锁持有时间长、竞争激烈的场景,通过让等待线程彻底挂起(不自旋)来避免CPU浪费。
锁升级是单向的(不降级),设计代码时要考虑锁的竞争程度,选择合适的同步手段。轻量级竞争用synchronized(JIT做了大量优化),中等竞争用ReentrantLock(更灵活),高并发读多写少用ReadWriteLock或StampedLock,无竞争读写用ConcurrentHashMap等无锁并发容器。
