锁竞争
锁竞争是多个线程同时争用同一把锁,因而产生排队和同步开销的现象。本文从服务变慢时的指标入手,再用线程转储和 JFR 确认等待位置与耗时。最后说明上下文切换的成本,以及怎样通过基准测试和服务压测验证优化。
1. 面试核心
先记住这几条,再读正文- 线程转储。 单份线程转储只反映采集时刻的调用栈和锁等待关系,连续转储才能帮助识别持续等待。
- monitor 等待事件。 JFR 的
jdk.JavaMonitorEnter记录 monitor 获取时的竞争等待,不包含完整的持锁时长。 - 挂起事件。 JFR 的
jdk.ThreadPark记录线程挂起等待,其来源包括锁竞争、条件等待和队列空闲。 - 采样范围。 JFR 会过滤短于事件时长阈值的等待,因此已记录时长只覆盖满足配置的事件。卡 · JFR 阈值与采样边界
- 优化验证。 相同负载下,锁等待下降、吞吐和长尾延迟改善且业务结果正确,可以支持锁热点已缓解的判断。卡 · 症状时间线
2. 症状时间线
先确认服务何时变慢,再查看同一时段线程在做什么。记录吞吐、p95/p99、超时和错误开始变化的时刻。p95 和 p99 分别表示 95% 和 99% 的请求延迟低于该值,用来观察慢请求。
接着,将以下运行指标对齐到同一时间段:
- 观察进程与线程 CPU。
- 观察 monitor 等待、park 事件和锁持有者的调用栈。
- 观察运行线程、阻塞线程和队列数量。
- 观察 GC、连接池等待与下游调用耗时。
- 观察上下文切换与运行队列。
锁内慢 I/O 会使其他线程一起排队。例如,持锁线程调用的 HTTP 接口变慢,就会延长它的持锁时间。这时可以在保持业务一致性的前提下,把 I/O 移出锁,或缩短锁内等待。
线程多或 BLOCKED 多,只说明需要进一步查看等待原因。锁是否是瓶颈,还要结合调用栈和业务延迟的变化确认。
3. 线程转储
线程转储是某一时刻线程调用栈和状态的快照。为了区分瞬时等待和持续阻塞,可以连续采集 3—5 份,再把相同等待栈归为一组。沿锁地址找到持有者后,检查它正在执行什么代码。
jcmd <pid> Thread.print -l
命令适合查看平台线程及其锁信息。单份快照可能恰好捕获正常的短暂等待。若相同持有栈持续出现,且等待线程与业务延迟同时增长,就更值得检查该位置。
线程名称应能对应到具体执行器和业务。只有 pool-3-thread-7 这样的名称时,还需额外追查线程池归属,定位会更慢。
虚拟线程使用专门的转储方式。具体边界见 虚拟线程。
4. JFR 等待事件
JFR(Java Flight Recorder)是 JVM 的运行事件记录工具,可以记录等待和 CPU 等活动。线程转储说明“在哪里等”,JFR 则帮助观察“等了多久”。Java 21 中可重点查看以下三类等待事件:
| 事件 | 等待类型 |
|---|---|
jdk.JavaMonitorEnter | 进入 monitor 的竞争等待 |
jdk.JavaMonitorWait | Object.wait() 条件等待 |
jdk.ThreadPark | LockSupport.park 等待 |
monitor 是 synchronized 使用的对象监视器。ReentrantLock 等 JUC 锁则可通过 park 挂起线程等待,因此锁等待也会表现为 WAITING。park 还可来自 Condition 条件队列、阻塞队列或空闲工作线程,需要结合事件栈区分。
分析时,重点比较以下等待指标:
- 已记录事件的累计等待时间,用来判断总成本集中在哪里。
- 单次最长等待时间,用来寻找拖慢长尾请求的操作。
- 热点锁对象和等待栈,用来定位大量线程争用的共享状态。
- 持有者的调用栈,用来判断锁内是否包含慢 I/O 或过多计算。
JavaMonitorEnter 的时长反映获取锁之前的等待。完整持锁时长还需结合持有方采样和业务埋点。分析时也可同时查看 JFR 的 CPU 和对象分配事件,确认成本是否来自其他工作。
5. 上下文切换
上下文切换是 CPU 从一个线程转去执行另一个线程的过程。它需要保存和恢复执行状态,新线程还要重新使用自己的数据。切换还会影响缓存局部性,也就是重复访问同一批数据或相邻数据时的缓存命中机会。新线程的数据尚未进入缓存时,需要重新加载,因此切换之外还会付出数据访问的成本。
切换可以分为自愿切换和非自愿切换:
- 线程因等待锁或 I/O 主动阻塞时,会发生自愿切换。频繁阻塞与唤醒会增加调度次数。
- 线程仍可运行,但时间片耗尽或被抢占时,会发生非自愿切换。可运行线程远多于 CPU 核心时,它们会争夺执行机会。
所以,切换率升高后,还要同时查看 CPU、等待时长和业务完成量。优化可以从减少线程、批量处理或缩短临界区入手。临界区就是由锁保护的那段代码。按独立状态分片、减少共享,也能降低竞争频率。只调整调度参数,仍会保留业务中的热点。
6. 并发基准测试
JMH 是用于 JVM 微基准测试的工具,适合比较一小段并发操作的成本。为了接近实际竞争,需要覆盖以下负载与测量条件:
- 改变线程数和读写比例,观察并发度提高后性能怎样变化。
- 分别测试均匀 key 与热点 key,区分分散访问和集中争用。
- 保留临界区内有代表性的计算量,避免只测到空锁的成本。
- 使用吞吐模式和采样时间模式,分别观察成功操作量与延迟分布。
- 使用预热和多个 fork,也就是启动多个独立 JVM 运行测试,降低单次运行的影响。
- 返回或消费计算结果,防止优化器把无用计算删除。
微基准比较的是局部机制,服务压测还要覆盖以下请求处理成本:
- 序列化会消耗 CPU,并产生临时对象。
- GC 会影响运行时间和延迟。
- 连接池和下游调用会引入排队与外部等待。
性能测量之外,还需通过内存模型推导、代码审查和专门并发测试验证正确性。
具体的微基准写法见 JMH。
7. 优化结果验证
复测需要确认改动是否改善了整个服务。保持请求量、并发度和数据分布相同,再比较以下优化结果:
- 吞吐和 p99 是否改善,业务结果是否仍然正确。
- 锁等待是否下降,队列或下游等待是否随之上升。
- CPU 和对象分配率是否增加,是否引入了新的成本。
- 高峰和故障负载下,任务是否能按预期拒绝、超时,并在故障解除后恢复。
平均延迟下降但 p99 上升,说明慢请求反而变差。吞吐提高却出现业务错误,也需要继续修复。因此,验收应同时满足正确性、吞吐和长尾延迟要求。
8. JFR 阈值与采样边界
解读 JFR 前,需要确认这次录制覆盖了哪些等待。对于持续时间事件,JFR 只记录已启用且达到阈值的事件。短于阈值的等待会被过滤,所以把时长相加,得到的只是已记录事件的总时长。
jcmd <pid> JFR.start name=locks settings=profile duration=60s filename=locks.jfr
jfr print --events jdk.JavaMonitorEnter,jdk.JavaMonitorWait,jdk.ThreadPark locks.jfr
第一条命令采集 60 秒,结束后生成 locks.jfr。第二条命令输出指定的三类等待事件。诊断短而频繁的等待时,应核对实际配置,再按需要降低阈值。这样能保留更多短事件,但也会增加采集和分析成本。
事件少或缺少调用栈时,还要检查录制是否覆盖故障时段,以及对应事件是否启用了栈采集。记录为空不足以排除竞争,需要结合连续线程转储和业务指标判断。
9. 常见问题
9.1 WAITING 线程多,就说明上下文切换多吗?
仅凭 WAITING 数量无法判断切换成本。空闲工作线程会等待队列中的任务,长期停在这里也属于正常状态。应先用调用栈区分空闲和业务等待,再结合系统切换率、CPU 和连续事件确认调度开销。
9.2 把锁拆得越细越好吗?
锁应按相互独立的状态拆分,并根据实际热点决定粒度。拆得更细虽然能减少部分竞争,也会增加跨锁协调和加锁顺序的复杂度。
如果多个分片之间有必须共同满足的业务约束,就需要额外同步来维护它。例如,跨账户转账需要共同保证余额变化正确。拆分后仍应通过性能分析和正确性检查验证收益。
10 · 面试题卡片
10.1 怎样证明 Java 服务的性能问题来自锁竞争
出现公司:阿里巴巴、美团
考察重点
- 能把连续线程转储、JFR 等待事件与业务变慢的时间对齐。
- 能通过调用栈区分 monitor 竞争、JUC 锁等待和线程池空闲。
- 能在相同负载下复测,确认等待减少且业务结果正确。
参考回答
需要证明锁等待与服务变慢同时发生,并通过相同负载下的优化复测验证。
可以按以下顺序收集证据:
- 对齐吞吐、长尾延迟、活跃线程、队列和 CPU,确定故障时段。
- 连续采集线程转储,找出持续出现的等待者和持有者,检查锁内是否有慢 I/O 或大量计算。
- 用 JFR 量化已记录的等待,结合调用栈区分 monitor、JUC 锁、Condition 和正常池空闲。
- 修改后保持并发度和数据分布一致,复测吞吐、p99、等待时间、CPU 和业务结果。
相关等待下降且服务表现改善,才支持锁热点已缓解的结论。还需确认队列和下游等待没有增加,避免只是把成本转移到其他位置。
10.2 JFR 没有锁等待事件,就能排除锁竞争吗?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:能解释事件启用状态、时长阈值和录制时段怎样影响 JFR 的记录范围。
相关内容:JFR 阈值与采样边界。
参考回答
不能排除,因为 JFR 只记录符合本次录制配置的事件。
应依次确认事件是否启用、时长阈值是否过高,以及录制是否覆盖故障时段。缺少调用栈时,还要检查栈采集设置。
短而频繁的等待会被阈值过滤,可以调整阈值后重新观察。已记录时长只覆盖满足配置的事件,因此还需结合线程转储与相同负载下的复测判断。