1. 从一次诡异的性能瓶颈说起
大概几个月前,有个同事找我排查一个线上服务性能突然掉了一半的问题。这个服务平时峰值也就是几百TPS,那天压测的时候,QPS死活上不去,CPU飙高但又不是那种典型的满负荷状态,更像是大量线程在互相等待。jstack一看,好家伙,几乎所有的业务线程全都卡在同一个Synchronized代码块里,状态清一色是BLOCKED。
这个场景想必很多Java开发者都见过。但真正让我想写这篇文章的,是我后来在复盘时发现,很多人对synchronized的印象还停留在"JDK 1.6以前性能差,所以要用Lock替代"这个层面。而实际上,从JDK 1.6开始,synchronized就在JVM层面做了一整套锁优化,也就是标题里说的锁膨胀机制。如果你不理解偏向锁、轻量级锁、重量级锁这三者的关系和演进逻辑,遇到线上锁竞争的问题时,就会像我那位同事一样——只知道锁冲突了,但不知道为什么跑成了重量级锁,更不知道该怎么调。
这篇文章我尽量把锁膨胀这条链路讲透:为什么需要偏向锁、锁撤销时JVM在背后做了什么、轻量级锁的自旋为什么不是万能的、以及重量级锁到底重在哪里。适合有一定Java基础、想深入理解并发底层原理的读者,也适合准备面试的人参考。我会从原理讲到实际排查思路,最后再聊聊JDK版本演进对这套机制的影响。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 打破很多人的固有认知:synchronized也是分状态的
2.1 从对象头的Mark Word说起
理解锁膨胀之前,必须先明白一件事:Java对象在内存里不只是有字段数据,还有一个对象头(Object Header)。在64位JVM中,对象头通常由两部分组成:Mark Word(标记字)和Class Pointer(类指针)。锁状态的记录,就存放在Mark Word里。
Mark Word虽然只有64位空间,但它是个"多面手"——在不同状态下,这64个bit的解读方式完全不同。我画个简化的对照:
- 无锁状态:存储对象的hashCode、分代年龄等信息。
- 偏向锁状态:存储持有偏向锁的线程ID、epoch(偏向纪元)、分代年龄等。
- 轻量级锁状态:存储指向线程栈中Lock Record(锁记录)的指针。
- 重量级锁状态:存储指向ObjectMonitor(监视器对象)的指针。
这个设计有点像你口袋里的瑞士军刀——体积不变,但根据场景切换不同工具形态。JVM通过在Mark Word里做CAS(Compare-And-Swap)操作来切换锁状态,这就是整个锁膨胀机制的地基。如果不理解Mark Word的复用逻辑,后面所有内容都会变成空中楼阁。
2.2 锁状态迁移:单向不可回头的升级之路
锁膨胀的核心规律是:锁状态只能升级,不能降级。完整路径是:
code复制无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
有些情况下会跳级。比如,当偏向锁撤销时发现竞争已经很激烈,JVM可能让锁直接升级为重量级锁,不再经过轻量级锁。这种单向升级的设计,本质上是JVM的一种"从乐观到悲观"的渐进式策略:
- 无锁:完全没有并发竞争,也就是单线程场景。
- 偏向锁:同一线程反复获取锁,消除CAS开销。
- 轻量级锁:存在少量竞争,用CAS + 自旋解决,避免线程挂起。
- 重量级锁:竞争激烈,直接阻塞未获取锁的线程,让出CPU。
你可能会问:为什么不能降级?因为降级需要额外的同步机制来保证安全性,成本反而更高。JVM选择了"宁可让锁状态维持在高等级"的策略来避免复杂化。理解了这一点,后面讲每一步的细节时你就能串起来了。
2.3 为什么说JDK 1.6是分水岭
早年间的Java版本里,synchronized确实性能不佳,因为它直接使用操作系统的互斥量(Mutex Lock)来实现,每次加锁解锁都会涉及用户态到内核态的切换。正因为如此,才有了"同步性能差"的说法,进而催生了java.util.concurrent包下的各种Lock工具。
但JDK 1.6之后,HotSpot虚拟机对synchronized做了大量优化,引入了偏向锁、轻量级锁、自旋锁、锁消除、锁粗化等一系列手段。可以说,从JDK 1.6开始,synchronized在大多数场景下已经不输给ReentrantLock了,甚至在某些场景下更优,因为它不需要额外的API调用,JVM会自动优化。这就是为什么很多Java面试题都在考锁膨胀——它不仅是并发基础,更是理解JVM设计哲学的一个窗口。
3. 偏向锁:为了"同一个线程反复加锁"而生的极致优化
3.1 偏向锁的初衷与核心思想
偏向锁的诞生源于一个非常有意思的观察:大多数锁不存在多线程竞争,而是由同一个线程多次获取。比如一个线程内部有个循环,每次循环都进入同一个synchronized方法。在无竞争的情况下,如果每次进入都做CAS操作,那就是纯粹的浪费。
偏向锁的"偏"字,意思是锁会偏向于第一个获得它的线程。如果后续没有其他线程竞争这把锁,那么持有偏向锁的线程以后每次进出临界区,都不需要再做任何同步操作。
具体来说,偏向锁的获取过程是这样的:
- 当线程第一次进入
synchronized代码块时,JVM检查Mark Word中的偏向锁标志位。 - 如果处于可偏向状态,当前线程通过CAS操作尝试将Mark Word中的偏向线程ID设置为自己。
- CAS成功,当前线程就获得了这把锁的偏向权。
- 下一次这个线程再次进入临界区时,只需比对Mark Word中的线程ID是否是自己,如果不是就直接进入,无需任何原子操作。
这个流程的微妙之处在于:获取偏向锁的那次CAS只发生一次,之后全部是纯比对操作。从指令级别看,比对几十位内存的成本几乎可以忽略不计。这就是偏向锁最核心的性能来源。
3.2 偏向锁撤销:整个锁膨胀机制的真正起点
偏向锁看着很美,但它有一个致命弱点:一旦出现竞争,偏向锁就要撤销。
撤销的过程远比获取复杂得多。当一个非偏向线程尝试获取这把锁时,JVM发现Mark Word的锁状态是偏向锁,且偏向线程不是自己,这时就不能简单做CAS了,因为盲目修改可能破坏正在临界区执行的偏向线程。JVM的应对策略是:等待安全点(SafePoint)。
安全点是JVM进行垃圾收集、线程快照等操作时定义的全局停顿点。在安全点上,所有线程都暂停在已知字节码执行位置。偏向锁撤销必须借助安全点来完成,流程大致如下:
- 非偏向线程触发偏向锁撤销,JVM暂停所有线程,进入安全点。
- JVM检查偏向线程是否仍然存活。
- 如果偏向线程已经不存活,或者已经退出了临界区,说明锁实际上已经空闲了,JVM可以把Mark Word重置为无锁状态,然后让当前线程重新竞争。
- 如果偏向线程还在临界区执行,说明锁确实在被使用,JVM会把偏向锁升级为轻量级锁或重量级锁,具体取决于竞争的激烈程度。
请注意第4步的一个关键细节:撤销偏向锁是有代价的。等待安全点、挂起线程、检查线程状态、决定后续处理方式,这些操作的开销可不小。所以JVM对于"是否继续使用偏向锁"也有自己的考量。如果同一个类的实例频繁发生偏向锁撤销,JVM会启动**批量重偏向(Bulk Rebias)和批量撤销(Bulk Revoke)**机制,我后面单独讲。
3.3 实测中偏向锁给我的两个"反直觉"教训
我最初理解偏向锁时,以为"竞争一旦出现就立即升级为轻量级锁",后来在实测中发现不完全是这样。有一次我写了一个多线程并发访问同一个对象的程序,按理说应该触发锁竞争,但观察到的响应时间波动很大,并不是稳定的高延迟。查了JVM日志才确认,偏向锁在JDK 12之后有了不同的行为模式——偏向锁不一定会立即撤销,而是可能维持一段时间的"可偏向"状态,只有真正发生实际竞争时才升级。
这里有个重要提醒:JDK 15之前和之后,偏向锁的默认行为差别很大。JDK 15开始默认禁用了偏向锁(-XX:-UseBiasedLocking),JDK 19之后偏向锁相关代码被彻底移除。如果你在用JDK 8或JDK 11,偏向锁是默认开启的;如果你切到JDK 17,会发现很多老代码里关于偏向锁的优化手段已经失效了。后面我单独开一节聊这个版本差异。
另一个教训是,偏向锁启动有一个延迟机制:JVM启动后前4秒内,偏向锁默认是关闭的,这是为了避免JVM启动阶段频繁撤销的额外开销。4秒后偏向锁自动生效。在压测场景中,如果你的测试只跑了几秒钟,可能会遭遇"锁升级路径不同导致性能不稳定"的干扰。生产环境的初始化过程通常超过4秒,所以影响不大,但单元测试里确实会踩到这个坑。
4. 轻量级锁:自旋解决竞争,但别把它当万能药
4.1 轻量级锁的获取过程:线程栈里的Lock Record
当偏向锁因为竞争被撤销后,锁状态通常会升级为轻量级锁。轻量级锁的设计目标是:在竞争不激烈的情况下,通过CAS避免线程挂起。
轻量级锁加锁时,JVM会在当前线程的栈帧中创建一个名为**Lock Record(锁记录)**的空间,存储锁对象当前Mark Word的副本,以及一个指向锁对象的引用。然后线程尝试用CAS把对象头Mark Word替换为指向这个Lock Record的指针:
- 如果CAS成功,当前线程就持有轻量级锁,且Mark Word中的锁标志位变为"轻量级锁"。
- 如果CAS失败,说明锁可能已经被其他线程持有,JVM不会立即把线程挂起,而是让它执行自旋——在一个循环中不断重试获取锁。
这里要特别说明一下Lock Record的设计原因:当轻量级锁释放时,线程需要恢复对象头的原始Mark Word(包含hashCode等信息)。Lock Record里保存了原始Mark Word副本,释放时把副本写回对象头即可。这个设计保证了锁释放的准确性——它是对称的。
4.2 自旋锁的适应性与自适应自旋
自旋锁的核心思想是:线程在等待锁时,不立即阻塞,而是占用CPU空转等待。这样可以避免线程切换的用户态内核态开销。但自旋本身也有代价——如果锁被持有的时间很长,自旋就变成了CPU浪费。
JDK 1.6之前,可以使用-XX:PreBlockSpin参数手动指定自旋次数(默认10次)。JDK 1.6之后引入了自适应自旋(Adaptive Spinning):JVM会根据上一次在同一个锁上自旋等待的成功率,动态调整自旋次数。如果某个锁自旋很少成功,JVM会减少自旋次数甚至直接跳过自旋;如果自旋经常成功,JVM允许线程自旋更长时间。
这意味着:你不需要也无法手动为自旋设置一个"最优次数"。JVM的运行时长越长,自旋策略越贴合当前应用的实际负载模式。
4.3 轻量级锁什么时候会升级为重量级锁
自旋虽然能避免线程挂起,但它占CPU。如果大量线程同时竞争一把锁,每个线程都自旋,CPU会被白白消耗。所以JVM需要明确轻量级锁的升级条件。结合我自己的理解和源码分析,触发升级主要有几种情况:
- 自旋次数超过阈值(自旋锁未能获得锁)。
- 同时竞争的线程数量达到一定规模,JVM判断继续自旋收益较低。
- 持有锁的线程被阻塞或主动让出CPU,无法在短期内释放锁。
- 线程执行了
wait/notify操作——轻量级锁和偏向锁不支持条件等待,只要有wait/notify调用,锁就会直接膨胀为重量级锁。
第4点很多面试题会问,也常被理解为"wait会导致锁升级"——准确的说法是,调用wait或notify时,当前线程必须已经持有重量级锁。如果当前是偏向锁或轻量级锁状态,JVM会在进入wait前自动完成膨胀。
我在实际排查问题时,还发现过一个更隐蔽的触发场景:持有锁的线程执行了Thread.sleep()或发起了一次阻塞式I/O操作,这会让其他线程自旋更久。但这种场景不一定会触发升级,取决于竞争线程的数量。如果只有一两个线程等待,自旋也够用;如果有十几个线程都在等,锁大概率会升级为重量级锁。
5. 重量级锁的接管:ObjectMonitor与内核态的真实开销
5.1 重量级锁的底层结构:ObjectMonitor
当锁膨胀为重量级锁后,Mark Word会指向一个ObjectMonitor对象。这是HotSpot虚拟机内部实现的监视器结构,也是一切synchronized语义(包括wait/notify/notifyAll)的真正载体。
ObjectMonitor内部有这些关键字段:
_owner:持有锁的线程。_count:重入计数。_WaitSet:调用wait()后被挂起的线程队列。_EntryList:等待获取锁的线程队列。
重量级锁加锁时,线程会通过系统调用进入内核态,试图获取一个操作系统的互斥量。如果获取不到,线程会被挂起进入阻塞状态,直到锁释放时由JVM负责唤醒。
这正是重量级锁"重"的根源所在:每一次加锁、解锁都涉及用户态到内核态的切换。在Linux上,这通常意味着至少两次上下文切换,一次从用户态进入内核态挂起线程,一次从内核态返回用户态唤醒线程。当竞争激烈时,频繁的线程切换会导致CPU使用率虚高,但有效工作比例极低。这就是文章开头那位同事压测时看到的现象的底层原因。
5.2 膨胀路径:从偏向锁直接跳到重量级锁
前面我提到锁状态可能跳级。先说结论:当JVM判断竞争已经很激烈时,偏向锁撤销后会直接升级为重量级锁,不再经过轻量级锁。
我在一次压测中观察到的确如此:我用8个线程同时访问同一个对象,每个线程在临界区里做1毫秒的模拟计算。在日志里我看到,偏向锁撤销后,对象头直接标记为重量级锁标志。为什么?因为JVM认为8个线程竞争同一把锁,自旋几乎不可能成功,直接走重量级锁路径的效率更高——虽然线程会阻塞,但不会浪费CPU自旋。
所以,"膨胀"这个词用得很精准:它不是简单的"升级",而是JVM根据竞争压力动态选择的一种状态跃迁。
5.3 一个容易被人忽略的事实:重量级锁也做了优化
很多人以为重量级锁性能极差,但事实上JVM对重量级锁也做了一些优化。比如:
- 锁消除(Lock Elision):JIT编译器在确认一个锁对象不会被其他线程访问时,会直接去掉锁操作。
- 锁粗化(Lock Coarsening):如果同一把锁被连续加锁解锁多次(比如循环内),JIT会把相邻的加锁解锁合并成一次大范围的锁区,减少加锁次数。
- 偏向锁之外,还有
-XX:UseSpinning:在重量级锁获取失败时,如果竞争不激烈,JVM也允许线程先自旋一会儿再阻塞,降低上下文切换频率。
这些优化手段解释了为什么现代Java应用中synchronized并没有过时。我在很多实际项目中,仅用synchronized就达到了预期性能目标,关键还是要理解锁竞争的形态,然后选择正确的同步策略。
6. 面试与实战中必须掌握的锁膨胀细节
6.1 锁升级状态的观察与实验验证
要验证锁膨胀机制,最直接的办法就是写个小程序,用-XX:+PrintSafepointStatistics或者JFR(Java Flight Recorder)观察对象头状态变化和线程状态。
我推荐一个更简单的办法:使用jol(Java Object Layout)这个工具查看对象头的Mark Word变化。jol可以打印出对象在内存中的布局,包括Mark Word的二进制表示,从而确认锁当前处于什么状态。
大致方法是:
- 创建一个对象,在单线程加锁前用
jol打印Mark Word,确认无锁状态。 - 同一线程加锁后再次打印,观察Mark Word是否变为偏向锁。
- 用第二个线程尝试访问同一把锁,触发锁膨胀,再打印Mark Word,观察是否变为轻量级锁或重量级锁。
- 通过对比不同阶段的Mark Word,你可以直观看到锁状态在对象头上的编码变化。
我在实际尝试中,最麻烦的一点是:jol打印的内存布局会触发安全点,而安全点操作本身可能影响锁状态。所以做这种实验时,建议在方法内部先加锁,然后通过一个回调(比如Runnable)触发打印,而不是在锁外打印。这算是个小坑,但能帮你省去不少排查时间。
6.2 偏向锁的批量重偏向与批量撤销机制
这部分属于源码级细节,但在大型应用里非常关键。
每当一个偏向锁被撤销时,JVM会记录该对象所属类的一个计数器。当撤销次数达到一定阈值(-XX:BiasedLockingBulkRebiasThreshold,默认20),JVM会触发批量重偏向:允许该类的所有对象重新被设置为偏向另一批线程,并增加一个纪元(epoch)字段来标记这次重偏向批次。这样,那些经历过多次撤销的对象,不用再频繁撤销偏向锁,而是直接在新的纪元下重新偏向新的竞争线程。
如果撤销次数继续增长达到另一个阈值(-XX:BiasedLockingBulkRevokeThreshold,默认40),JVM会触发批量撤销:直接禁止该类的对象再使用偏向锁,也就是所有新对象直接进入无锁可偏向但不能偏向的状态。这会降低未来锁获取的开销——不再有偏向锁撤销的等待时间。
这个机制在实际场景中意味着什么?如果你的业务中有一个全局单例类,被很多线程反复访问,你可能会在日志里看到它从"偏向锁"模式切换为"非偏向锁"模式。JVM这是用"反正竞争多,偏向锁起不了作用"的判断换取了更稳定的性能表现。不过,一旦JDK版本切到15以上,这个机制就不再生效了,因为它们已经被移除了。
6.3 线上排查锁竞争的几个实用思路
回到我最开始说的线上问题。当你用jstack看到大量线程BLOCKED时,怎么判断是不是锁膨胀导致的?
第一,看jstack堆栈中的锁信息。jstack输出的线程堆栈中,如果大量线程阻塞在同一个锁对象上,且锁对象上有一个明显的- locked <0x...>标记,说明竞争集中在这把锁上。但注意,jstack不会明确告诉你锁属于哪个级别(偏向、轻量级、重量级),它只是显示状态。要确认锁级别,需要借助JFR记录锁竞争事件,或者用jcmd查看VM.monitor信息。
第二,看CPU使用率模式。如果CPU使用率本身很高,但有效工作占比很低,大量时间花在自旋上,这通常是轻量级锁自旋导致的。如果CPU使用率不高,但线程大量互相阻塞,通常就是重量级锁导致的。
第三,针对性调优。如果你确认锁竞争严重,最简单的优化是缩小临界区范围,让持锁时间变短。如果无法缩小,可以考虑用ReentrantReadWriteLock、StampedLock或ConcurrentHashMap等更适合读多写少的并发工具。永远不要一上来就调JVM参数,先把代码层的锁粒度优化好。
还有一个冷门但关键的点:一个锁对象如果重写了hashCode()方法,会破坏偏向锁的获取。因为偏向锁的Mark Word里没有足够的空间存储hashCode,一旦一个对象被计算了hashCode,它就不能进入可偏向状态。如果你的类重写了hashCode()方法且使用了System.identityHashCode(),你会发现即使单线程访问,这个对象的锁也只能走轻量级锁路径,无法使用偏向锁。这个细节我在踩坑之前一直没注意。
6.4 JDK版本演进:偏向锁的落日
既然讲到锁膨胀,我不能不提版本演进这个大趋势。HotSpot虚拟机对偏向锁的态度是:从JDK 15开始默认禁用偏向锁,JDK 19开始彻底移除相关代码。这意味着:
- JDK 8 / 11:偏向锁默认开启,可以使用
-XX:+UseBiasedLocking显式开启。 - JDK 15 - 18:偏向锁默认禁用,通过
-XX:+UseBiasedLocking可以重新开启(但JDK 17之后可能已经无法开启)。 - JDK 19+:偏向锁相关的编译和运行时代码被移除,
-XX:+UseBiasedLocking参数已经失效。
为什么社区决定移除偏向锁?核心原因是偏向锁的撤销需要安全点停顿,而现代应用中的安全点停顿又往往和GC、JIT等因素叠加,整体延迟反而可能比轻量级锁更高。再加上锁竞争通常不会长期稳定地偏向同一个线程,偏向锁带来的收益在多数场景下并不显著。
所以,如果你接手的是一个用JDK 8写的并发系统,后续升级到JDK 17,你可以把偏相关锁的优化参数视为无效。代码层面的逻辑不需要改,但性能基线需要重新测试。
6.5 面试高频问题:锁膨胀、锁消除、锁粗化是一回事吗
最后,我把面试中经常被问到的一组概念理清楚,因为这也是很多人混淆的根源。
- 锁膨胀:指锁状态从偏向锁到轻量级锁到重量级锁的升级过程,核心驱动力是竞争程度。
- 锁消除:JIT编译器的优化,当一个锁对象被逃逸分析证明不会被其他线程访问时,JIT会去除加锁代码。
- 锁粗化:JIT编译器的优化,当多个相邻的加锁解锁操作针对同一个锁对象时,JIT会扩大锁范围,减少加锁次数。
三者都是JVM对synchronized的优化手段,但锁膨胀是运行时的状态机迁移,锁消除和锁粗化是编译期的指令优化。我在面试中经常用"公共交通"来类比:偏向锁是"专用车道",轻量级锁是"拼车",重量级锁是"排队等公交",而锁消除是"一看周围没车干脆不坐公交自己走",锁粗化是"把几段短途出行合并成一次长途出行免得反复等车"。
不过这个类比只是帮理解,面试时最好还是能从Mark Word的位模式和JVM源码的角度解释清楚,才有信服力。
实战总结与一个小技巧
在JDK 8到JDK 11环境里排查锁膨胀问题时,我有个屡试不爽的小技巧:给业务线程命名。比如在创建线程池时给线程起一个能标识业务模块的名字,排查时jstack一眼就能看出哪些线程在竞争同一把锁、它们属于哪个模块。听起来很基础,但很多线上问题排查的时间,就省在这一个个命名上。
至于锁膨胀本身,我的体会是:它是一套JVM根据竞争压力自动调节的机制,理解它的价值不在于"我能手动控制它",而在于当锁竞争出现时,我知道系统在做什么、瓶颈在哪里、该从哪个方向优化。很多时候,最有效的做法不是调整锁参数,而是减少临界区的大小,或者换一种更贴合业务的并发原语。掌握了这套底层逻辑,你排查并发问题的速度会提升一个档次,这也算是我从那位同事的压测事故里收获的最大经验。
