问题场景:线程池中的同步块性能异常
某交易系统的订单处理模块使用固定大小的线程池执行订单校验、风控和记账操作。这些任务共享一个订单上下文对象,并通过 synchronized 块保护其内部状态。在低负载时,同步块几乎不存在竞争,系统吞吐良好。但随着并发量上升,线程池中多个工作线程频繁争用同一把锁,系统吞吐反而下降,CPU 使用率却居高不下。火焰图显示,大量时间消耗在 ObjectMonitor::enter 和偏向锁撤销相关的代码路径上。
直觉上,synchronized 在无竞争时性能很好,但为何一旦出现竞争,性能就急剧恶化?这与 HotSpot JVM 中的一项优化——偏向锁(Biased Locking)密切相关。偏向锁的设计初衷是加速无竞争同步,但在高并发场景下,其撤销(revocation)开销可能完全抵消收益,甚至导致性能倒退。Java 15 通过 JEP 374 默认禁用了偏向锁,并在后续版本中彻底移除了相关代码。本文将深入分析这一决策背后的工程原因。
偏向锁的核心思想与获取流程
偏向锁是 HotSpot JVM 在 Java 6 引入的一项锁优化技术,其核心思想基于一个经验观察:大多数锁在整个生命周期内只会被同一个线程获取。如果锁对象总是被同一个线程访问,那么每次加锁时执行昂贵的原子操作(如 CAS)就是浪费。偏向锁通过将锁“偏向”到第一个获取它的线程,让该线程后续进入同步块时无需任何同步操作,从而消除无竞争情况下的锁开销。
在 HotSpot 实现中,对象头(Object Header)中的 Mark Word 记录了锁状态。当偏向锁启用时,Mark Word 的格式如下:
- 线程 ID:持有偏向锁的线程 ID。
- Epoch:用于批量重偏向的时间戳。
- 分代年龄和偏向锁标志位。
偏向锁的获取流程可以总结为以下步骤:
- 检查 Mark Word 的偏向锁标志位是否为 1,锁标志位是否为 01(未锁定状态)。
- 如果可偏向,则通过 CAS 操作将当前线程 ID 写入 Mark Word。如果成功,则获取偏向锁成功,后续该线程进入同步块时只需检查线程 ID 是否匹配,无需任何同步操作。
- 如果线程 ID 已匹配,则直接进入同步块,这是“偏向”带来的性能收益。
- 如果线程 ID 不匹配(即另一个线程尝试获取锁),则触发偏向锁撤销。
下面的流程图展示了偏向锁从获取到撤销的主要路径,对应线程池中两个线程争用同一把锁的场景。
flowchart TD
A[线程T1访问同步块] --> B{Mark Word可偏向?}
B -->|是| C[CAS设置线程ID为T1]
C -->|成功| D[偏向锁获取成功]
D --> E[T1后续进入无需同步]
B -->|否| F[进入轻量级锁或重量级锁路径]
E --> G[线程T2尝试获取同一锁]
G --> H{Mark Word中线程ID==T2?}
H -->|否| I[触发偏向锁撤销]
I --> J[等待T1到达安全点]
J --> K[检查T1是否存活/仍在同步块]
K -->|T1已退出同步块| L[撤销偏向锁,恢复为无锁状态]
K -->|T1仍在同步块| M[升级为轻量级锁,T1继续执行]
L --> N[T2可以竞争锁]
M --> N
在无竞争场景下,偏向锁确实能显著提升性能。例如,早期 Java 集合类(如 Vector、Hashtable)在每个方法上使用 synchronized,但实际使用中往往只有一个线程访问,偏向锁避免了大量 CAS 操作。然而,一旦出现另一个线程尝试获取锁,撤销流程的开销就变得不可忽视。
偏向锁撤销的详细流程与开销
当线程 T2 尝试获取一个已偏向 T1 的锁时,JVM 必须撤销偏向锁。撤销过程需要全局安全点(Safepoint),即所有 Java 线程暂停,以便 JVM 安全地检查和修改对象头。撤销的具体步骤如下:
- 检测冲突:T2 在 CAS 设置线程 ID 时失败,因为 Mark Word 中的线程 ID 与自身不匹配。此时 T2 不会立即自旋或阻塞,而是进入偏向锁撤销路径。
- 等待安全点:JVM 请求一个全局安全点,所有线程暂停。安全点的到达依赖于线程状态轮询,在高度活跃的线程池中,这可能导致延迟。
- 检查原偏向线程状态:JVM 检查 T1 是否仍在持有锁(即是否仍在同步块内)。
- 如果 T1 已退出同步块,则只需将 Mark Word 重置为无锁状态(匿名偏向状态),撤销开销相对较小。
- 如果 T1 仍在同步块内,则必须将锁升级为轻量级锁(Thin Lock):在 T1 的栈帧中分配 Lock Record,并将 Mark Word 更新为指向该 Lock Record 的指针。此时 T1 的锁状态变为轻量级锁,T2 随后通过 CAS 竞争该轻量级锁。
- 恢复执行:安全点结束,所有线程继续运行。T2 现在可以尝试获取轻量级锁。
撤销操作本身需要访问对象头、遍历线程栈等,这些操作在安全点内执行,会阻塞所有应用线程。即使单次撤销耗时仅几微秒,但在高并发下频繁撤销会严重降低吞吐。更糟糕的是,如果锁竞争激烈,偏向锁会反复撤销和重偏向,形成“撤销风暴”。
为了缓解频繁撤销的问题,HotSpot 引入了批量重偏向(Bulk Rebias)和批量撤销(Bulk Revocation)机制。这些机制基于 epoch 和类级别的计数器工作:
- 批量重偏向:当一个类的锁撤销次数达到阈值
BiasedLockingBulkRebiasThreshold(默认 20)时,JVM 认为该类可能不适合当前偏向,但允许重偏向。它递增类的 epoch,并更新所有该类的已偏向锁的 epoch。后续线程尝试获取锁时,如果发现 epoch 过期,会尝试通过 CAS 重偏向到自身。这减少了撤销次数,因为不再需要每次撤销都进入安全点。 - 批量撤销:如果重偏向后撤销次数仍继续增加,达到
BiasedLockingBulkRevokeThreshold(默认 40),JVM 认为该类完全不适合偏向锁,直接禁用该类的偏向锁。此后所有该类的实例在加锁时都会跳过偏向锁路径,直接进入轻量级锁。
这些机制虽然缓解了问题,但引入了更多代码复杂度和维护负担。而且,在竞争激烈的场景下,批量撤销最终会禁用偏向锁,这意味着之前的撤销开销完全是浪费。
高并发场景下的性能倒退
回到线程池的例子。假设线程池有 8 个工作线程,它们频繁争用同一个订单上下文对象的锁。初始时,锁偏向第一个访问它的线程 T1。当 T2 尝试获取锁时,触发撤销。如果 T1 仍在同步块内,则升级为轻量级锁。轻量级锁通过 CAS 自旋尝试获取,如果自旋失败则膨胀为重量级锁,线程进入阻塞。
在竞争激烈时,轻量级锁的自旋很快失败,锁膨胀为重量级锁,线程被挂起和唤醒,涉及操作系统调度。但即使如此,偏向锁的撤销开销仍然额外存在。每次锁状态从偏向变为轻量级或重量级时,都需要一次安全点操作。如果多个线程交替获取锁,偏向锁可能反复撤销和重偏向,直到批量撤销机制禁用该类的偏向锁。
下表对比了在无竞争、轻度竞争和高竞争三种场景下,偏向锁启用与禁用时的关键性能指标差异。数据基于通用工程经验,并非特定基准测试结果。
| 场景 | 偏向锁启用 | 偏向锁禁用 | 主要开销来源 |
|---|---|---|---|
| 无竞争(单线程反复加锁) | 极低延迟,无 CAS | 每次加锁需 CAS | 偏向锁消除 CAS 操作 |
| 轻度竞争(偶发多线程访问) | 首次访问后无开销,但撤销时需安全点 | 每次加锁轻量级 CAS | 偏向锁撤销的安全点暂停 |
| 高竞争(线程池频繁争用) | 频繁撤销和重偏向,安全点暂停多,最终批量撤销 | 直接使用轻量级锁或重量级锁,无撤销开销 | 偏向锁撤销和重偏向的累积开销 |
从表中可以看出,偏向锁仅在无竞争场景下有明显收益;一旦出现竞争,撤销开销会迅速侵蚀性能。在高度竞争的线程池场景中,偏向锁几乎总是带来负收益。JEP 374 明确指出,许多现代应用(尤其是基于线程池的应用)在禁用偏向锁后性能更好。SPECjbb2015 基准测试在设计时就假设偏向锁禁用,而旧版 SPECjbb2005 则受益于偏向锁,这反映了应用架构的变迁。
JEP 374 的决策依据与工程权衡
JEP 374 的决策并非一时兴起,而是基于以下工程现实:
- 维护成本过高:偏向锁的实现代码遍布 HotSpot 的运行时、编译器和垃圾收集器,逻辑复杂且容易出错。它阻碍了同步子系统的重构和优化,例如对
java.util.concurrent包的支持改进。 - 收益递减:现代 Java 应用广泛使用非同步集合(如
HashMap、ArrayList)和并发数据结构(如ConcurrentHashMap),不再像早期应用那样依赖无竞争的synchronized。偏向锁的受益面大幅缩小。 - 原子指令性能提升:现代硬件上的 CAS 操作延迟已大幅降低,使得无偏向锁的轻量级锁开销变得可接受。过去通过偏向锁避免 CAS 带来的收益相对下降。
- 全局安全点影响:偏向锁撤销需要全局安全点,这与低延迟应用(如交易系统、实时分析)的要求相悖。安全点停顿会导致请求延迟毛刺。
- 替代方案成熟:
java.util.concurrent包提供了更细粒度的锁和原子变量,开发者应优先使用这些工具,而非依赖偏向锁来弥补不必要的同步。
因此,JEP 374 在 Java 15 中将偏向锁默认禁用,并标记相关选项为废弃。在 Java 21 中,偏向锁的代码被彻底移除。这一演进路径清晰表明,偏向锁是一项过时的优化,现代 JVM 更倾向于简洁的锁实现和可预测的性能。
诊断与应对:JFR 与锁分析工具
面对线程池同步性能问题,我们需要工具来确认偏向锁是否在起作用,以及锁竞争的真实状况。JDK Flight Recorder (JFR) 提供了低开销的锁争用事件,可以持续监控生产环境。
通过 JFR 可以采集以下关键事件:
jdk.JavaMonitorEnter:记录线程进入synchronized块的事件,包含地址、线程和持续时间。jdk.JavaMonitorWait:记录线程在Object.wait()上等待的事件。jdk.BiasedLockRevocation(在支持偏向锁的版本中):记录偏向锁撤销事件,包括撤销类型(单个撤销、批量重偏向、批量撤销)。
在 Java 15 之后,由于偏向锁默认禁用,BiasedLockRevocation 事件不再产生。但我们可以通过 JavaMonitorEnter 事件的持续时间来判断锁竞争程度。如果大量事件的持续时间超过几十微秒,且伴随线程阻塞(jdk.JavaThreadPark),则表明存在严重的锁竞争。
此外,传统工具如 jstack 可以打印线程栈,观察线程是否在 ObjectMonitor::enter 上阻塞。但 jstack 会触发安全点,可能干扰应用。JFR 的流式事件是更优选择。
对于仍使用旧版 JDK 的应用,可以通过 -XX:-UseBiasedLocking 显式禁用偏向锁,并对比性能。如果禁用后吞吐提升或延迟抖动减少,则说明偏向锁带来了负优化。同时,应检查代码中是否过度使用了 synchronized,并考虑用 ReentrantLock 或 ConcurrentHashMap 等替换。
适用边界与未解决的问题
偏向锁的废弃并不意味着所有同步优化都失去意义。在极少数场景下,如果应用确实存在大量无竞争的 synchronized 块(例如遗留代码中单线程访问的 Vector),且无法重构,那么启用偏向锁可能仍有收益。但这类场景在现代 Java 应用中越来越罕见,且通常可以通过代码改造获得更大性能提升。
一个尚未完全解决的问题是:如何在 JVM 层面自动识别适合偏向锁的负载模式,并动态启用或禁用?JEP 374 选择了彻底移除,而非引入自适应机制,这体现了对简单性和可维护性的追求。未来,随着 Project Loom 的虚拟线程普及,同步模型可能进一步变化,偏向锁的历史角色将彻底终结。
对于性能诊断,JFR 提供了强大的数据支撑,但解读锁竞争事件仍需要开发者理解 JVM 内部机制。例如,轻量级锁的自旋次数、锁膨胀的阈值等参数仍会影响性能。在极端情况下,即使没有偏向锁,重量级锁的上下文切换也可能成为瓶颈,这时需要从架构层面减少共享可变状态。
最终,偏向锁的移除提醒我们:性能优化必须基于测量和场景。盲目依赖历史优化可能适得其反。通过 JFR 等工具持续观测,并采用现代并发编程实践,才能构建高效且可维护的 Java 应用。