一次报警:某实例 CPU 打满。值班的人重启了进程,恢复。第二天同一时间又打满。第三次终于有人去看火焰图,发现是定时任务在同一分钟内启动了三个线程池的预热——CPU 打满不是"算得太慢",而是四个不同问题的共同表现,而它们的分辨方法完全不同。
还有一种被误当成"第五种"的情形:CPU 限额与配额。容器里 cpu.max 限流不会让 CPU 打满,而是让线程拿不到时间片——表现为延迟升高、CPU 看起来不高,与这四种正好相反,所以排障时先要确认"是不是真的打满"。
四种成因:真实计算密集、锁竞争与自旋、GC 消耗、系统调用与内核态。区分它们只需要看两个数:用户态与内核态的比例,以及GC 占用的时间占比。
1. 四种成因的指纹
| 成因 | 指纹 |
|---|---|
| 计算密集 | 用户态高、GC 正常、单线程饱和 |
| 锁竞争/自旋 | 用户态高、上下文切换高、线程数多 |
| GC 消耗 | GC 时间占比高、内存分配速率高 |
| 系统调用/内核 | 内核态高、syscall 次数多 |
这里有个容易走错的分支:看到 CPU 高就先去查代码效率,而实际上代码效率问题通常表现为"CPU 高但请求量不高",锁竞争则表现为"请求量一上来 CPU 就满"。先用请求量做一次分流,能省掉一半时间。
第一项与第二项在 top 上看起来一样(都是用户态 100%),差别在上下文切换(cs)与线程状态:锁竞争会让大量线程处于 runnable 而拿不到锁。
第三项的特征是"CPU 高但业务吞吐不高":GC 线程在消耗 CPU,而应用线程在等 GC。
// 一次看全四项指纹
top -H -b -n1 | head -30 # 线程级占用
pidstat -u -w -t -p $(pgrep -f app.jar) 1 3 # 用户/内核 + 上下文切换
jstat -gcutil $(pgrep -f app.jar) 1000 3 # GC 占比
pidstat -d -p $(pgrep -f app.jar) 1 3 # IO 与内核态关联
2. 锁竞争的识别
| 现象 | 说明 |
|---|---|
| 大量线程处于 runnable | 抢锁而非阻塞 |
cs 每秒数万次 |
自旋或唤醒频繁 |
| 单线程占用异常高 | 某热点锁的持有者 |
| 吞吐随并发上升而下降 | 典型的争用曲线 |
第四项是最有诊断力的:加并发应该提吞吐,如果反而下降,几乎一定是争用。而它在监控上表现为"CPU 上升、QPS 下降"。
与自旋相邻的一种是被"忙等"掩盖的问题:轮询式重试(while (!ok) { } 或高频重试循环)会让 CPU 高得毫无意义,而在栈里看起来只是在循环里调用一个方法,不像 bug。
自旋锁(如 ConcurrentHashMap 的短自旋)在争用激烈时会把 CPU 白白烧掉,而不是阻塞——所以"没有阻塞"不等于"没有争用"。
// 抓三次线程栈,看是否集中在同一把锁
for i in 1 2 3; do jstack $(pgrep -f app.jar) | grep -A3 'BLOCKED\|WAITING' | head -20; sleep 2; done
// 同一栈帧反复出现 → 该处是热点
3. GC 消耗的识别
| 指标 | 阈值 |
|---|---|
| GC 时间占比 | > 10% 需关注 |
| 分配速率 | 与堆大小不匹配 |
| Full GC 频率 | 任何非零都需解释 |
| 停顿与 CPU 峰值是否同步 | 同步则 GC 是主因 |
第四项是最直接的因果验证:CPU 峰值与 GC 停顿在时间上重合,说明 CPU 花在回收上。不重合则要往别处找。
第一项的计算方式很简单:一分钟内 GC 总耗时 / 60 秒。若超过 10%,说明应用线程可用 CPU 已被显著挤占。
// GC 占比:从统一日志里算
grep -oE 'Pause.*[0-9]+\.[0-9]+ms' gc.log | awk -F'ms' '{s+=$1} END {print "total_ms", s}'
// 与运行时长对比即可得占比
4. 内核态的识别
| 现象 | 说明 |
|---|---|
| 内核态 > 30% | 系统调用密集 |
sys 高但 us 低 |
纯 IO 或网络 |
| 大量小 read/write | 无缓冲的 IO |
| 网络中断集中在少数核 | 网卡队列不均 |
第三项常见于"忘了缓冲"的代码:逐行读文件、逐条写 socket,每次都是系统调用。修复方式通常是加缓冲,而不是加机器。
内核态高还有一个"看不到"的来源:软中断处理集中在少数核。网络流量大时,ksoftirqd 会把某些核占满,而业务线程看起来并不忙——这时优化方向是网卡队列与中断亲和性。
第四项在容器里表现为"某些核 100%、其余空闲",用 mpstat -P ALL 1 能立刻看出来。
5. 区分流程:四步收敛
| 步骤 | 动作 |
|---|---|
| 1 | 看 us/sy 比例 |
| 2 | 看 GC 占比 |
| 3 | 看线程状态分布 |
| 4 | 抓栈与火焰图 |
第四步(抓栈与火焰图)有一个实践细节:单次抓栈没有意义,要抓三次以上。因为一次抓到的可能是一个瞬时状态,而三次里重复出现的栈帧才是热点。
第三步是分流点:大量 runnable → 争用;少数线程高占用 → 计算密集;多数线程 WAITING → 等外部。多数线程在等外部时,CPU 高通常另有原因(GC 或后台任务)。
// 四步收敛的判定表
us 高、sy 低、GC < 5%、单线程饱和 → 计算密集(优化算法或加实例)
us 高、sy 低、GC < 5%、多线程 runnable → 锁竞争(拆锁或换结构)
us 高、sy 低、GC > 10% → GC(调堆或查分配)
sy 高(>30%) → 系统调用/IO(加缓冲或改批处理)
排障时还应当区分"瞬时"与"持续":瞬时尖峰(秒级)通常由批量操作触发,持续高位(分钟级以上)才指向结构性问题。而监控通常按分钟聚合,把两者混在一起,于是"尖峰"被读成"持续"。
// 采样粒度决定结论
1 分钟均值:尖峰被平均掉 → 看起来正常(漏报)
5 秒采样:能看到峰值 → 但噪声大
1 秒采样 + P99:既能看峰值又能排除噪声
// 建议:CPU 用 1 秒采样,看 P99 与最大值两个数
6. 排错:五种"CPU 高但查不出原因"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 重启就好,过几天复发 | 定时任务或周期性任务 | 查调度时间 |
| 单核打满其余空闲 | 单线程热点或中断集中 | 火焰图看那一核 |
| 容器内 CPU 高但宿主正常 | 限额与配额 | 查 cgroup 限制 |
| 业务停了 CPU 还高 | 后台线程或 GC | 看线程清单 |
| 加机器后总 CPU 不降 | 瓶颈在共享资源 | 查下游与锁 |
第三项在容器里很常见:cpu.max 限流会造成"看起来没满但拿不到 CPU",表现为延迟升高而非 CPU 100%——与本题相反,但常被混淆。
7. 边界:什么情况 CPU 高是正常的
| 场景 | 为什么 | 做法 |
|---|---|---|
| 批处理窗口 | 设计如此 | 只看窗口内其他指标 |
| 预热与编译 | 短时高 | 忽略 |
| 加密与压缩 | 计算密集 | 接受或换算法 |
| 压测环境 | 目标就是打满 | 忽略 |
最后一条与"容量"有关的边界:周期性的 CPU 尖峰不该被当成故障。定时任务、缓存刷新、日志切割都会造成尖峰;判据是"尖峰是否与业务量相关"——相关是容量问题,不相关是调度问题。
判断口径一句话:这段时间的 CPU 是否换来了等价的产出。换来了(吞吐同步上升),说明它是被用满而非被浪费;没换来,就是四种成因之一。
8. 自测
- 四种成因的指纹分别是什么?为什么"用户态 100%"无法区分前两种?
- 锁竞争为什么表现为 CPU 高而不是阻塞?
cs与线程状态在其中起什么作用? - 如何用时间重合验证"GC 是 CPU 峰值的主因"?GC 占比怎么算?
- 内核态 >30% 最常见的代码原因是什么?为什么修复方式是加缓冲而不是加机器?
- 四步收敛流程里,哪一步是分流点?"多数线程 WAITING"说明什么?
- 判断"CPU 高是否正常"的那个问题是什么?