从偏向锁到重量级锁:一次高并发下的Java锁升级实战案例深度剖析
目录导读
- 引言:锁升级不是“性能银弹”,而是“自适应策略”
- 基础回顾:Java对象头与Mark Word的底层博弈
- 核心案例:一个计数器在四种锁状态下的性能蜕变
- 1 无锁竞争:偏向锁的“懒汉式”胜利
- 2 轻度竞争:轻量级锁的CAS自旋
- 3 重度竞争:重量级锁的线程阻塞与唤醒
- 锁升级触发条件:JVM的“智能开关”如何运作
- 实战踩坑:为什么你的锁升级后性能反而更差?
- 高频问答(FAQ):锁升级的三大灵魂拷问
- 如何利用锁升级规则优化高并发代码
引言:锁升级不是“性能银弹”,而是“自适应策略”
很多Java开发者误以为Synchronized关键字在JDK 6之后“永远性能很好”,从而忽视了对锁竞争粒度的设计,JVM的锁升级机制(无锁→偏向锁→轻量级锁→重量级锁)是一种自适应自旋优化,它不是让锁变快,而是让锁在不同竞争烈度下,用最合适的代价去换取线程安全,我们通过一个真实的计数器Demo,看清这场发生在对象头里的“军备竞赛”。

基础回顾:Java对象头与Mark Word的底层博弈
在HotSpot虚拟机中,普通对象的对象头由Mark Word和Klass Pointer组成,Mark Word(32位机为32bit,64位机为64bit)存储了对象的哈希码、分代年龄、锁状态标志位等。
锁状态与Mark Word存储内容的对应关系:
| 锁状态 | (Mark Word) |
|---|---|
| 无锁 | 对象哈希码 + 分代年龄 + 01(低位标志) |
| 偏向锁 | 线程ID + 纪元戳 + 分代年龄 + 01(高位标志101) |
| 轻量级锁 | 指向栈中锁记录的指针 + 00 |
| 重量级锁 | 指向互斥量(Monitor)的指针 + 10 |
关键点:锁升级是单向不可逆的(除了偏向锁可重置为无锁),这意味着一旦竞争激烈升级到重量级锁,后续即使竞争消失,也不会自动回退。
核心案例:一个计数器在四种锁状态下的性能蜕变
我们设计一个简单的Counter类,开启100个线程,每个线程循环累加i++100万次。
public class LockUpgradeDemo {
private int count = 0;
public synchronized void increment() {
count++;
}
// 测试入口:分别使用 -XX:-UseBiasedLocking 或 -XX:+UseBiasedLocking
}
1 无锁竞争:偏向锁的“懒汉式”胜利
场景:单线程或极低竞争(几乎同一时刻只有一个线程访问)。
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0(启动即启用)。- 现象:第一次线程T1获取锁时,JVM将Mark Word中的偏向线程ID设为T1,此后T1再次进入同步块时,只需比对线程ID,无需CAS原子操作,耗时极短。
- 代码层面:当
increment()被T1连续调用时,锁开销几乎为0。
2 轻度竞争:轻量级锁的CAS自旋
场景:T1和T2交替执行,但每个线程持有锁的时间极短。
- 升级过程:当T2尝试获取锁,发现偏向线程ID不是自己时,触发撤销偏向,JVM在T1的安全点暂停,撤销偏向,并复制Mark Word到T2的栈帧锁记录中。
- 获取方式:T2通过CAS(Compare And Swap) 尝试将对象头Mark Word替换为指向自己栈中锁记录的指针,若成功,则获取锁;若失败,说明存在更多竞争,开始自旋(循环重试CAS,默认自旋次数由JVM动态调整)。
- 性能特征:避免了线程内核态切换,但占用了CPU空转。
3 重度竞争:重量级锁的线程阻塞与唤醒
场景:超过2个线程同时竞争,或自旋超过阈值(或CPU核心数少于竞争线程数)。
- 升级触发:CAS自旋失败次数达到阈值(JVM自适应),Mark Word中的指针指向Monitor对象(基于操作系统底层的
pthread_mutex_t)。 - 性能特征:获取不到的线程进入
BLOCKED状态,由操作系统调度,这涉及用户态与内核态的切换,上下文切换开销极大,但保证了公平性(FIFO队列)。
实验数据参考(不同JDK版本略有差异):
| 竞争模式 | 耗时(毫秒) |
|---|---|
| 单线程(偏向锁有效) | 5 ms |
| 双线程交替(轻量级锁成功) | 35 ms |
| 十线程激烈竞争(升级重量级锁) | 460 ms(包含大量线程阻塞唤醒) |
锁升级触发条件:JVM的“智能开关”如何运作
JVM的延迟偏向机制(-XX:BiasedLockingStartupDelay=4000)默认在JVM启动4秒后才开启偏向锁,这主要是为了应对JVM启动初期的内部竞争。
触发升级的三大硬性条件:
- 偏向锁撤销次数:当JVM检测到某个锁对象频繁发生偏向撤销(超过20次),它会认为该对象不适合偏向锁,直接进入轻量级锁模式。
- 自旋失败指数:轻量级锁自旋重试超过阈值(如50次),或自旋总时间超过设定的阈值(如5ms),则升级为重量级锁。
- CPU核心数:如果
CPU核数 <= 1,JVM会直接关闭偏向锁和自旋,因为单核下自旋毫无意义。
实战踩坑:为什么你的锁升级后性能反而更差?
反模式案例:在循环体内使用Synchronized锁住一个被频繁读写的局部变量。
- 偏离设计:偏向锁适合低竞争且线程访问次数多的场景,如果你创建的对象每次都被新线程访问,偏向锁撤销成本反而成为负担。
- 重量级锁不可逆:一旦升级,无法回退,如果你的服务在高峰期达到重量级锁,之后即使流量下降,该锁也永远是重量级锁。
- 正确姿势:
- 使用
-XX:-UseBiasedLocking显式关闭偏向锁(对于高并发、多线程交替访问的共享对象)。 - 使用
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0确保偏向锁立即生效(对于服务启动初期有单线程预热场景)。 - 代码层面,尽量减小同步块的粒度(
synchronized只锁方法内的必要代码)。
- 使用
高频问答(FAQ):锁升级的三大灵魂拷问
Q1:偏向锁可以被撤销吗?会阻塞线程吗? A:可以,当有第二个线程访问时,偏向锁需要撤销,JVM会等待全局安全点(SafePoint),暂停持有偏向锁的线程,然后根据该线程是否还在执行同步块来决定回退到无锁还是直接升级为轻量级锁,这个过程可能造成短暂的STW暂停(但时间极短)。
Q2:轻量级锁自旋时,持锁线程在执行什么?
A:持锁线程(T1)正在紧密循环(执行count++操作),自旋线程(T2)在空转执行CAS指令,如果T1很快释放锁,T2能立刻通过CAS获取;但如果T1执行时间过长,T2自旋就浪费CPU。
Q3:重量级锁下,JUC的ReentrantLock一定比Synchronized好吗?
A:不一定,在JDK 6之后,Synchronized通过锁升级机制已经拥有与ReentrantLock相当的性能(在非公平模式下),但ReentrantLock提供了可中断、可超时、公平锁等高级功能,如果你的代码需要这些能力,选ReentrantLock;否则,Synchronized因其句柄不需要显式解锁且升级机制自动智能而更容易写出安全代码。
如何利用锁升级规则优化高并发代码
- 分析读写比例:如果共享数据读多写少,优先考虑
ReadWriteLock或StampedLock,而不是Synchronized。 - 控制锁粒度:锁内只执行必要运算,避免在同步块内进行
I/O、网络请求或长耗时计算,这会逼着轻量级锁自旋升级为重量级锁。 - 利用锁消除:JVM会通过逃逸分析,将局部变量上的锁(如
StringBuffer在方法内使用)直接消除。 - 善用启动参数调优:
- 偏向锁存延迟:
-XX:BiasedLockingStartupDelay=0(加速启动)。 - 关闭偏向锁:
-XX:-UseBiasedLocking(适合高并发大量多线程交替访问)。
- 偏向锁存延迟:
- 杜绝锁竞争撕裂:不要让重量级锁成为常态,如果业务高峰期必然导致重量级锁,方案请改为分段锁(如
ConcurrentHashMap的分段思想)或无锁并发队列(Disruptor)。
最后提醒:锁升级是JVM的自动适应行为,但你的代码设计决定了它最终会停在哪一种状态。最好的锁是没有锁,其次是低竞争的偏向锁,最差的是被阻塞等待的重量级锁。 理解升级路径,是为了让你在写Synchronized时,心里有一把尺子,测量出真正的并发安全与执行效率的黄金分割点在哪里。