4. 锁竞争的可观测信号
锁竞争本身是一个逻辑现象——多个线程试图进入同一临界区,但只有一个能成功。它没有直接的“锁竞争计数器”可以读取,必须通过一组间接但高度相关的系统信号来推断。这些信号分布在操作系统调度层、CPU运行状态层和JVM/应用层三个观测面上,单独看任何一个都容易误判,组合起来才能形成可靠的证据链。
一、调度层信号:上下文切换与CPU迁移
当线程因获取锁失败而阻塞时,操作系统会将其从运行队列移出,触发一次自愿上下文切换(voluntary context switch)。锁释放后,被唤醒的线程重新进入运行队列,又可能触发一次切换。因此,锁竞争加剧时,自愿上下文切换速率会显著上升。在Linux上可用 vmstat 1 观察 cs 列,或用 pidstat -w -p <pid> 1 区分自愿(cswch/s)与非自愿(nvcswch/s)切换。
更隐蔽的信号是CPU迁移。当线程被唤醒时,调度器可能将其分配到与阻塞前不同的CPU核心上。跨核迁移意味着该线程的本地缓存(L1/L2)和NUMA节点亲和性全部失效,重新加载数据的代价可能远超锁等待本身。用 perf sched 或 pidstat -w 中的迁移计数可以观察这一现象。高迁移率叠加高锁等待,往往说明调度器在“救火式”地平衡负载,而非真正解决了竞争。
二、CPU状态信号:运行队列、iowait与系统态占比
运行队列长度(run queue length)反映的是“可运行但未获得CPU”的线程数。锁竞争导致的阻塞线程不在此列,但锁释放瞬间大量线程同时被唤醒,会短暂推高运行队列。如果运行队列长期大于CPU核数,说明瓶颈在CPU本身而非锁;如果运行队列不高但吞吐上不去,锁竞争的可能性更大。
iowait是一个容易被误读的指标。锁竞争本身不产生iowait,但如果临界区内包含文件或网络I/O,锁持有时间会被I/O延迟放大,进而表现为iowait升高。因此,当iowait与锁等待时间同步上升时,应优先检查临界区内是否有阻塞式I/O调用。
系统态CPU占比(%sys)是锁竞争最直接的CPU侧信号之一。线程的阻塞与唤醒、futex系统调用、自旋锁的CAS操作,都消耗系统态CPU。在Linux上,top的sy列或mpstat -P ALL 1的%sys持续偏高(例如超过30%),同时用户态占比不高,通常指向激烈的锁竞争或频繁的系统调用。
三、应用层信号:锁等待时间与阻塞线程数
操作系统信号只能说明“有大量线程在阻塞和唤醒”,但无法区分是锁竞争、I/O等待还是条件变量等待。要精确定位,必须回到应用层。
在Java中,jstack输出的线程状态是最直接的证据。处于BLOCKED状态的线程正在等待monitor锁,处于WAITING (parking)的线程可能在等待ReentrantLock或Condition。统计BLOCKED线程数量及其堆栈中共同出现的锁对象,可以快速定位竞争热点。例如,若20个线程的堆栈都停在synchronized (orderCache)上,则orderCache就是竞争源。
锁等待时间需要主动埋点。JDK自带的ThreadMXBean.getThreadInfo()可以获取线程阻塞计数和阻塞时间,但精度有限。更实用的方式是在锁获取前后记录时间戳:
long t0 = System.nanoTime();
lock.lock();
long waitNs = System.nanoTime() - t0;
if (waitNs > 1_000_000) { // 超过1ms记录
lockWaitHistogram.record(waitNs);
}
try {
// 临界区业务逻辑
} finally {
lock.unlock();
}
这段代码的关键在于:只记录等待时间,不记录持有时间。等待时间反映竞争强度,持有时间反映临界区效率,两者需要分开统计。如果等待时间的P99超过10ms,而持有时间的P99只有0.5ms,说明竞争激烈但临界区本身不慢,优化方向是减小锁粒度或改用无锁结构;如果持有时间P99达到50ms,则优先缩短临界区。
四、组合判读与常见误判
单一信号容易产生误判。例如,%sys高也可能是频繁的系统调用或网络中断;自愿上下文切换高也可能是I/O阻塞。可靠的判读需要至少三个信号同时异常:
- 锁等待时间P99上升 + BLOCKED线程数增加 + 自愿上下文切换速率上升 → 锁竞争确认。
- iowait上升 + 锁等待时间上升 + 持有时间上升 → 临界区内存在阻塞I/O,锁被I/O延迟放大。
- 运行队列长度高 + 非自愿上下文切换高 + 锁等待时间不高 → 瓶颈在CPU算力,而非锁。
- CPU迁移率高 + 锁等待时间高 + %sys高 → 调度器频繁迁移阻塞线程,NUMA亲和性被破坏,需考虑绑核或调整调度策略。
pidstat -w -u -t 1同时观察上下文切换、CPU利用率和线程级数据,再用jstack抓取阻塞线程堆栈,最后用埋点数据确认锁等待时间的分布。三个观测面的数据时间窗口要对齐,否则容易把不同时段的信号错误关联。
五、一个可复用的观测清单
| 观测面 | 信号 | 采集方式 | 锁竞争特征 |
|---|---|---|---|
| 应用层 | 锁等待时间P99 | 锁前后埋点 + 直方图 | 持续上升,与吞吐下降同步 |
| 应用层 | BLOCKED线程数 | jstack / ThreadMXBean | 多个线程堆栈指向同一锁对象 |
| 调度层 | 自愿上下文切换 | pidstat -w / vmstat | cswch/s 显著上升 |
| 调度层 | CPU迁移 | perf sched / pidstat -w | 迁移计数与锁等待同步上升 |
| 调度层 | 运行队列长度 | vmstat / sar -q | 锁释放瞬间出现尖峰 |
| CPU状态 | %sys | mpstat -P ALL 1 | 持续偏高,用户态占比不增 |
| CPU状态 | iowait | iostat / mpstat | 与锁等待同步上升,提示临界区I/O |
这张清单的价值在于:它把“锁竞争”这个抽象概念拆解成了可采集、可对比、可验证的具体信号。实际使用时不必每次都采集全部七项,但至少应覆盖应用层的锁等待时间和调度层的自愿上下文切换,再根据怀疑方向补充CPU状态信号。只有信号之间形成一致的因果链,才能避免把CPU瓶颈、I/O瓶颈或调度抖动误判为锁竞争。