跳到主要内容

锁竞争、切换成本与并发压测

并发性能问题需要同时回答线程在等什么、等待多久,以及系统为调度付出了多少成本。线程转储给出等待位置,JFR 和 profiler 给出时间分布,压测再验证修改是否改善真实负载。

1. 先从用户可见症状建立时间线

记录吞吐、p95/p99、超时和错误开始变化的时刻,再对齐:

  • 进程与线程 CPU。
  • monitor blocked、thread park 和锁持有栈。
  • runnable、blocked 与等待线程数量。
  • 队列、连接池和下游延迟。
  • 操作系统上下文切换与运行队列。

线程多不等于切换一定是瓶颈,BLOCKED 多也不说明锁本身是根因。一个线程在锁内调用慢接口,真正修复点可能是移动 I/O 边界。

2. 多份线程转储定位稳定等待点

连续采集 3—5 份线程转储,按相同栈聚合等待者,并找出锁持有者。单份快照可能只捕获正常瞬时状态;相同持有栈持续出现,且等待线程和业务延迟同步增长,证据才更强。

线程名应映射到具体执行器。若都是匿名的 pool-3-thread-7,定位与容量归属都会变慢。

3. JFR 量化等待时间

JFR 可以记录 Java Monitor Blocked、Thread Park、CPU、分配等事件。分析时关注总等待时间、单次最长等待、热点锁对象和持有者栈,而不是只数事件次数。

ReentrantLock 等 JUC 锁常通过 park 等待,不一定表现为 BLOCKED。需要同时看 monitor 与 park 事件,并结合具体栈判断这是锁、Condition、队列还是正常空闲。

4. 上下文切换是结果指标

线程远多于可运行核心、临界区频繁唤醒或锁竞争严重时,自愿与非自愿切换可能升高。切换会保存和恢复执行状态,也会影响缓存局部性,但它通常是过度并发或等待协议的结果。

修复方向可能是减少线程、批量处理、缩短锁内工作、按 key 分片或减少共享。只调整调度参数很少能解决业务层热点。

5. 并发基准要测正确负载

JMH 基准应覆盖:

  • 不同线程数和读写比例。
  • 均匀 key 与热点 key。
  • 临界区内的真实计算量。
  • 吞吐模式和采样时间模式。
  • 返回值消费、预热和多个 fork。

微基准用于比较局部机制,服务压测还要覆盖连接池、序列化、GC 和下游。性能通过不代表并发正确;竞态需要模型推导、代码审查和专门并发测试共同验证。

6. 优化后验证完整结果

修改后使用同样负载比较吞吐与长尾,并确认:

  • 业务结果仍然正确。
  • 锁等待是否真正下降,而非转移到队列或下游。
  • CPU 和分配是否出现新成本。
  • 高峰与故障条件下是否仍能拒绝和恢复。

平均值改善但 p99 恶化,或者吞吐提高却出现错误,都不能视为完成。

7. 常见问题

7.1 看到很多 WAITING 是否说明上下文切换太多

不说明。线程池空闲 Worker 本来就在 WAITING。需要系统级切换指标、CPU 和连续事件来确认调度成本。

7.2 把锁拆得越细越好吗

更细的锁可能降低独立状态间竞争,也会增加锁顺序、协调和不变量拆分难度。应按真正独立的状态分片,并用 profile 验证热点。

8. 面试题

8.1 怎样证明 Java 服务的性能问题来自锁竞争

出现公司:阿里巴巴、美团

考察重点

  • 连续线程转储、JFR 等待事件与业务时间线。
  • monitor 和 JUC park 的区别。
  • 优化后进行同负载验证。

相关内容:第 1 节“先从用户可见症状建立时间线”至第 6 节“优化后验证完整结果”。

参考回答

先确认长尾或吞吐下降时 active、队列和 CPU 的变化,再连续采集线程转储,找出稳定出现的等待栈与持有者。用 JFR 的 Monitor Blocked、Thread Park 等事件量化等待持续时间,并区分 monitor、JUC 锁、Condition 和正常池空闲。

锁内慢 I/O、热点 key 或过大临界区才是具体原因。修改后用相同并发与数据分布复测吞吐、p99、等待时间、CPU 和业务正确性,防止只是把等待转移到另一层。