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%),同时用户态占比不高,通常指向激烈的锁竞争或频繁的系统调用。

锁竞争可观测信号的分层证据链 应用/JVM层 锁等待时间 monitor contention / park time 阻塞线程数 BLOCKED / WAITING 线程计数 临界区持有时间 锁内耗时 / 锁外耗时比值 因果传导 操作系统调度层 自愿上下文切换 cswch/s 上升 CPU迁移 跨核唤醒 / 缓存失效 运行队列长度 runnable 线程排队 资源消耗 CPU运行状态层 系统态CPU占比 %sys 持续偏高 iowait 临界区内I/O放大 futex系统调用 FUTEX_WAIT / WAKE 组合判读:锁等待↑ + 自愿切换↑ + %sys↑ → 锁竞争确认

三、应用层信号:锁等待时间与阻塞线程数

操作系统信号只能说明“有大量线程在阻塞和唤醒”,但无法区分是锁竞争、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瓶颈或调度抖动误判为锁竞争。