锁竞争、切换成本与并发压测
并发性能问题需要同时回答线程在等什么、等待多久,以及系统为调度付出了多少成本。线程转储给出等待位置,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 和业务正确性,防止只是把等待转移到另一层。