跳到主要内容
约 7 分钟2 张卡

锁竞争

锁竞争是多个线程同时争用同一把锁,因而产生排队和同步开销的现象。本文从服务变慢时的指标入手,再用线程转储和 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.JavaMonitorWaitObject.wait() 条件等待
jdk.ThreadParkLockSupport.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 锁等待和线程池空闲。
  • 能在相同负载下复测,确认等待减少且业务结果正确。

相关内容:症状时间线至优化结果验证。

参考回答

需要证明锁等待与服务变慢同时发生,并通过相同负载下的优化复测验证。

可以按以下顺序收集证据:

  1. 对齐吞吐、长尾延迟、活跃线程、队列和 CPU,确定故障时段。
  2. 连续采集线程转储,找出持续出现的等待者和持有者,检查锁内是否有慢 I/O 或大量计算。
  3. 用 JFR 量化已记录的等待,结合调用栈区分 monitor、JUC 锁、Condition 和正常池空闲。
  4. 修改后保持并发度和数据分布一致,复测吞吐、p99、等待时间、CPU 和业务结果。

相关等待下降且服务表现改善,才支持锁热点已缓解的结论。还需确认队列和下游等待没有增加,避免只是把成本转移到其他位置。

10.2 JFR 没有锁等待事件,就能排除锁竞争吗?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:能解释事件启用状态、时长阈值和录制时段怎样影响 JFR 的记录范围。

相关内容:JFR 阈值与采样边界。

参考回答

不能排除,因为 JFR 只记录符合本次录制配置的事件。

应依次确认事件是否启用、时长阈值是否过高,以及录制是否覆盖故障时段。缺少调用栈时,还要检查栈采集设置。

短而频繁的等待会被阈值过滤,可以调整阈值后重新观察。已记录时长只覆盖满足配置的事件,因此还需结合线程转储与相同负载下的复测判断。

11. 参考资料​