如果一个系统 CPU 飙高,应该如何排查处理?
从线上止损、确认 CPU 消耗主体、定位高 CPU 线程、关联 Java 栈与火焰图,到死循环、GC、锁竞争、线程膨胀和热点计算分析,系统讲解 Java 服务 CPU 飙高的排查方法。
如果一个系统 CPU 飙高,应该如何排查处理?
面试回答
系统 CPU 飙高时,我会先确认影响范围并止损,通过摘除异常实例、限流、降级或临时扩容保护系统,同时保存故障时间、流量、发布变更、CPU、负载、GC、线程数和容器限流等现场数据。CPU 高不一定来自当前 Java 进程,因此要先从机器、容器和进程三个层级确认消耗主体;如果机器 CPU 高而 JVM CPU 不高,应继续检查同机其他进程、内核态、I/O wait 和 steal time。
确认是 Java 进程后,我会定位高 CPU 线程。可以通过 top -H -p <pid> 或 ps -Lp 找到线程 ID,将十进制 TID 转成十六进制,再与 jcmd <pid> Thread.print -l 中的 nid 对应。线程栈要间隔数秒连续采集多次,只有持续出现在相同执行位置的热点线程,才更可能是死循环、频繁重试、正则回溯、序列化、加解密或其他热点代码。
如果没有稳定的单个热点线程,或者问题持续时间很短,我会使用 JFR 或 async-profiler 做一段时间的采样,通过 Hot Methods、Call Tree 或 CPU 火焰图确认 CPU 时间消耗在哪里。同时结合 GC 日志和分配分析判断是否是对象分配过快导致 GC 消耗 CPU;结合线程数、上下文切换和锁事件判断是否是线程膨胀、CAS 自旋或锁竞争;还要检查 JIT 编译线程、原生库和内核调用。
修复时要针对根因处理,而不是只增加 CPU:死循环和无上限重试要修正退出条件并增加退避;高分配要减少临时对象和批次大小;热点计算要优化算法或缓存结果;线程膨胀要设置线程池和队列上限;流量超出容量则需要限流、扩容和容量规划。最后使用接近真实的流量验证 CPU、吞吐、延迟、GC 和错误率是否恢复,并补齐分层 CPU、线程、GC、容器限流和热点方法监控。
一句话总结:
CPU 飙高的排查主线是先止损,再确认是机器、容器还是 JVM 消耗 CPU,然后把高 CPU 线程关联到 Java 栈或火焰图,最后结合 GC、线程和流量定位根因。
一张图看懂
详细讲解
先理解容器 CPU 限流
在容器环境中,CPU 使用量会受到 cgroup 配额约束。容器在一个统计周期内用完可用 CPU 时间后,内核会暂时停止调度其中的线程,等下一个周期再恢复运行,这就是容器 CPU 限流(CPU throttling)。
它不代表应用主动休眠,也不一定表现为整机 CPU 100%。即使宿主机仍有空闲 CPU,只要容器自己的配额已经耗尽,应用也会被限制运行,接口延迟和任务处理时间可能因此升高。
在 cgroup v2 中可以查看:
cat /sys/fs/cgroup/cpu.stat
常见字段包括:
| 字段 | 含义 |
|---|---|
nr_periods | 已经经过的 CPU 配额统计周期数 |
nr_throttled | 容器在多少个周期内触发过 CPU 限流 |
throttled_usec | 容器累计被限制运行的时间,单位为微秒 |
排查时应观察这些计数在一段时间内的增长速度,而不是只看某个累计值。nr_throttled 占 nr_periods 的比例持续较高,或 throttled_usec 快速增长,说明容器经常受 CPU 配额限制;还要继续判断是 CPU limit 配置过低,还是应用存在异常计算。
一、CPU 高并不等于业务代码一定有死循环
看到 CPU 告警后,首先要明确三个问题:
- 是整台机器 CPU 高,还是某个容器 CPU 高?
- 是 Java 进程消耗 CPU,还是同机其他进程或内核消耗 CPU?
- 是真实计算压力增加,还是容器 CPU 配额不足、线程调度或 GC 等问题?
常见根因包括:
- 流量或任务量突然上升;
- 死循环、无上限重试或消息空转;
- 正则表达式灾难性回溯;
- JSON 序列化、压缩、加解密等计算热点;
- 对象分配速度过快,引发频繁 GC;
- 线程数量膨胀和上下文切换;
- CAS 竞争、自旋等待或热点锁;
- 异常大量抛出、堆栈生成和同步日志输出;
- JIT 编译、GC 线程或第三方原生库消耗 CPU;
- 其他进程、内核中断或虚拟机宿主机争抢 CPU;
- 容器 CPU limit 太低或持续发生 CPU 限流。
因此不能一看到 CPU 高,就直接重启、扩容或者抓一份线程栈后认定某段代码有问题。
二、第一阶段:先控制故障影响
线上处理应先恢复系统可用性:
- 从负载均衡中摘除最异常的实例;
- 对非核心接口限流、熔断或降级;
- 暂停可能造成空转、重试风暴或计算放大的任务;
- 有健康副本时切换流量或临时扩容;
- 如果线程已经长期占满 CPU 且服务不可用,保留必要现场后重启实例。
重启能够暂时清除异常状态,但也会丢失最有价值的线程和性能现场。条件允许时,至少先保存:
- 告警开始和结束时间;
- 进程 CPU、机器 CPU、负载和容器 CPU;
- 请求量、任务量、消息消费速率和错误率;
- 线程数、GC 次数和 GC 线程消耗的 CPU;
- 最近的发布、配置和数据变更;
- 多份线程栈或一段 JFR、async-profiler 记录。
三、第二阶段:确认 CPU 到底消耗在哪里
1. 机器层面
先查看整机情况:
top
mpstat -P ALL 1
pidstat -u 1
vmstat 1
这些命令观察的层次不同:
| 命令 | 主要用途 |
|---|---|
top | 查看整机负载、CPU 和内存概况,并找出资源占用较高的进程 |
mpstat -P ALL 1 | 每秒输出一次每个 CPU 核的使用情况,用于判断负载是否集中在少数核心,并区分用户态、内核态、I/O wait 和 steal time |
pidstat -u 1 | 每秒按进程输出一次 CPU 使用情况,用于确认哪个进程正在消耗 CPU |
vmstat 1 | 每秒输出一次系统运行队列、内存、交换、I/O、上下文切换和 CPU 概况,用于判断 CPU 高是否伴随排队、换页或 I/O 压力 |
重点区分:
| 指标 | 含义 | 常见方向 |
|---|---|---|
us | 用户态 CPU | Java 业务代码、GC、JIT 或其他用户进程 |
sy | 内核态 CPU | 系统调用、网络、磁盘、驱动或内核开销 |
wa | CPU 等待 I/O 的时间占比 | 磁盘或存储瓶颈,不等同于 CPU 计算饱和 |
st | 虚拟机被宿主机拿走的 CPU 时间 | 宿主机资源争抢 |
| Load Average | 正在运行或不可中断等待的任务压力 | 不是 CPU 使用率,需结合 CPU 核数和任务状态判断 |
如果机器 CPU 很高,但目标 JVM CPU 不高,应先找出真正的高 CPU 进程,而不是继续分析 Java 线程。
2. 容器层面
在 Kubernetes 或其他容器环境中,还要关注:
- Pod 实际 CPU 使用量;
- CPU request 和 limit;
- 容器是否发生 CPU 限流;
- Node 是否整体繁忙;
- Pod 是否频繁扩缩容、重启或迁移。
前文已经介绍了 CPU 限流的含义和 cpu.stat 指标。这里还应将 Pod 的 CPU 使用量、limit、限流时间与业务延迟放到同一时间线上,避免只根据某一时刻的 CPU 百分比判断。
3. 进程层面
确认 Java 进程:
# 每秒查看一次指定进程的用户态、内核态及总 CPU 使用率
pidstat -u -p <pid> 1
# 查看进程 ID、父进程 ID、CPU、内存、线程数和启动命令
ps -p <pid> -o pid,ppid,%cpu,%mem,nlwp,cmd
%CPU 在多核系统中可能超过 100%。例如一个进程稳定使用两个 CPU 核,在某些工具中可能显示约 200%,不能机械地把超过 100% 当成监控错误。
四、第三阶段:定位高 CPU 线程
1. 找到进程内的热点线程
可以使用:
top -H -p <pid>
或者:
ps -Lp <pid> -o pid,tid,pcpu,stat,comm --sort=-pcpu
记录 CPU 最高的 TID。TID(Thread ID)是操作系统为线程分配的标识;在 Linux 中,Java 线程会对应一个可调度的本地线程,因此可以先按 TID 找到消耗 CPU 的操作系统线程,再通过 JVM 线程栈中的 nid 定位对应的 Java 调用栈。
假设十进制线程 ID 为 12345,转换成十六进制:
printf '%x\n' 12345
输出:
3039
2. 获取 Java 线程栈
推荐使用:
jcmd <pid> Thread.print -l > thread-1.txt
间隔数秒再采集:
jcmd <pid> Thread.print -l > thread-2.txt
jcmd <pid> Thread.print -l > thread-3.txt
然后在文件中查找:
nid=0x3039
nid 是 JVM 线程对应的本地线程 ID,通常以十六进制展示。找到线程后,要同时观察:
- 线程名称;
- 线程状态;
- 当前方法和完整调用链;
- 是否连续多次停留在相同或相邻代码位置;
- 是否属于业务线程、GC 线程、JIT 编译线程或第三方原生线程。
3. 为什么要连续采集多份线程栈
单份线程栈只是一瞬间的快照。一个正常执行请求的线程也可能恰好位于某个计算方法中,不能仅凭一次采样就认定它是根因。
如果同一线程在多份栈中持续处于 RUNNABLE,并反复出现在相同调用路径,同时该线程的 TID CPU 持续较高,才更能说明它正在消耗 CPU。Oracle 的故障排查文档也建议对疑似循环线程采集一系列线程转储,重点观察持续处于 RUNNABLE 状态的线程。
RUNNABLE 线程数是线程转储或监控中处于 Java RUNNABLE 状态的线程数量。它可以辅助发现大量活跃任务、忙循环或线程池膨胀,但不能单独等同于“正在占用 CPU 的线程数”:Java 的 RUNNABLE 同时覆盖正在执行 Java 代码以及部分处于原生调用中的线程,最终仍要结合线程级 CPU 和连续栈判断。
五、线程栈中重点看什么
1. 死循环或条件无法退出
典型表现:
- 某个线程长期接近占满一个 CPU 核;
- 多次线程栈都停在同一段业务代码;
- 请求量下降后 CPU 仍不回落。
例如:
while (running) {
if (queue.poll() == null) {
continue;
}
}
队列为空时仍然持续空转。可以根据场景改为阻塞队列、条件等待或带退避的重试。
2. 无上限重试
例如远程调用或 CAS 更新失败后立即重试:
while (!success) {
success = callRemoteService();
}
当下游持续失败时,它会形成重试风暴。应设置最大次数、指数退避、随机抖动、熔断和失败后的补偿路径。
3. 正则表达式回溯
复杂正则在特定输入下可能产生大量回溯,线程栈常出现:
java.util.regex.Pattern
java.util.regex.Matcher
需要结合具体表达式、输入数据和耗时复现,不能简单认为所有正则调用都有问题。
4. 序列化、压缩、加解密和大集合计算
常见热点包括:
- 大 JSON 的序列化和反序列化;
- GZIP 压缩与解压;
- 哈希、签名和加解密;
- 大集合排序、去重和嵌套遍历;
- 图片、音视频或文档转换。
这类问题通常不是线程异常,而是单位请求计算量过大或流量超过容量。需要从算法、数据规模、缓存、批次和并行度处理。
5. 异常和日志风暴
频繁创建异常会生成堆栈信息;大量同步日志还会增加格式化、锁和 I/O 开销。监控中常伴随:
- 异常计数骤增;
- 日志量骤增;
- 相同错误重复打印;
- CPU 与错误率同步上升。
应修复异常根因,同时对可预期失败避免无意义的完整堆栈,并对重复日志做采样或限速。
六、没有明显热点线程时怎么办
线程 ID 与线程栈关联适合定位持续占用 CPU 的线程,但以下场景可能看不到稳定热点:
- 大量短任务分散到多个线程;
- 热点线程变化很快;
- 问题只持续几十秒;
- CPU 消耗发生在 GC、JIT、原生库或内核中;
- CPU 时间分散在多个方法上,没有单一调用栈长期占用 CPU。
这时更适合使用采样分析。
1. 使用 JFR
JFR(Java Flight Recorder)是 JDK 内置的低开销事件记录器。它会在一段时间内记录 CPU 采样、线程、GC、对象分配、同步和 I/O 等事件,生成 .jfr 文件,再通过 JDK Mission Control 分析时间趋势、热点方法和调用关系。这里所说的“一段 JFR 记录”,就是限定时间采集到的一份 JVM 运行现场。
可以对运行中的 JVM 启动一段性能记录:
jcmd <pid> JFR.start name=cpu-check settings=profile duration=60s filename=/tmp/cpu-check.jfr
在 JDK Mission Control 中重点查看:
jdk.CPULoad:JVM 和机器 CPU 趋势;jdk.ThreadCPULoad:线程 CPU 使用;- Hot Methods:热点方法;
- Call Tree:热点方法的调用来源;
- GC、分配、锁竞争和线程事件。
JFR 是采样和事件记录工具,不保证每次短暂热点都能被完整捕获。default 配置偏向持续、低开销记录,profile 配置会采集更多性能信息,开销也相对更高。采样数量过少时,应延长记录时间或在可控范围内使用 profile 配置。
2. 使用 async-profiler
async-profiler 是面向 HotSpot JVM 的低开销采样分析器,可以按固定频率采集 CPU、分配、锁等事件,并输出火焰图。这里所说的“一段 async-profiler 记录”,是指在问题发生期间采样数十秒,将大量调用栈汇总为热点分布,而不是只抓取某一个瞬间的线程栈。
CPU 火焰图示例:
asprof -e cpu -d 30 -f /tmp/cpu-flamegraph.html <pid>
火焰图中:
- 横向宽度表示该调用栈被采样到的比例,不表示时间先后;
- 纵向表示调用栈深度;
- 越宽的方法越值得优先分析;
- 应从宽栈向下查看是谁调用了热点方法,而不是只修改最顶部的方法。
async-profiler 还能看到部分原生和内核栈,适合 Java 栈无法解释 CPU 去向的场景。
七、判断是不是 GC 导致 CPU 高
对象创建速度过快时,GC 线程可能消耗大量 CPU。此时通常同时出现:
- 分配速率明显升高;
- Young GC 变得频繁;
- GC CPU 占比升高;
- 吞吐下降但堆未必持续增长;
- 热点线程可能是 GC 工作线程,而不是业务线程。
这里的 GC CPU 是诊断中的简写,不是所有平台都具有完全一致口径的单一 JVM 指标。它通常表示 GC 线程执行并发标记、复制、整理、引用处理等垃圾回收工作所消耗的 CPU 时间或 CPU 占比。
GC CPU 与 GC 停顿不是同一个概念:停顿时间描述应用线程被暂停多久,GC CPU 描述垃圾回收工作消耗了多少计算资源。并发收集器可能在停顿不长时仍消耗较多 CPU,因此要把 GC 日志中的次数和阶段、JFR 的 GC 事件、进程或线程 CPU,以及分配速率结合起来判断。
GC 日志不是业务代码通过日志框架主动打印的,而是 JVM 自身输出的诊断日志,但通常需要在 JVM 启动参数中提前开启。JDK 9 及以上可以使用统一日志参数,例如:
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=100M
如果故障前没有开启 GC 日志,就无法事后补回完整的历史 GC 明细,只能结合现有监控、JFR 记录和当前 JVM 状态分析。因此生产环境应提前配置 GC 日志轮转,避免日志无限增长。
需要结合 GC 日志、JFR 和分配采样分析:
jcmd <pid> GC.heap_info
jcmd <pid> GC.class_histogram
GC.class_histogram 可能带来额外开销,不应在高压环境下无节制频繁执行。更重要的是确认哪些调用路径在高速分配对象,而不是只观察当前对象数量。
常见修复方向:
- 减少循环内临时对象;
- 避免不必要的装箱、字符串拼接和重复序列化;
- 限制查询结果、批次和消息大小;
- 复用代价高但线程安全的对象;
- 根据真实存活集和分配速率重新评估堆与 GC 参数。
不要在没有证据时首先调整 GC 参数。很多所谓的“GC 问题”,根因其实是业务代码制造对象的速度过快。
八、线程和锁也可能把 CPU 推高
1. 线程数量过多
线程越多不代表吞吐越高。大量可运行线程会产生:
- 上下文切换;
- 调度开销;
- CPU 缓存失效;
- 线程栈和原生内存占用;
- 请求超时后继续执行,形成任务堆积。
可以观察:
pidstat -w -p <pid> 1
ps -p <pid> -o nlwp
pidstat -w 用于查看任务切换活动,常见字段包括:
| 字段 | 含义 |
|---|---|
cswch/s | 每秒自愿上下文切换次数,例如线程因为等待锁、I/O 或条件而主动让出 CPU |
nvcswch/s | 每秒非自愿上下文切换次数,例如线程时间片用完或被更高优先级任务抢占 |
切换次数升高本身不是根因,但如果它与线程数、CPU 和延迟同步激增,通常说明线程过多、锁竞争频繁或调度压力增大。
nlwp 表示轻量级进程数量,在 Linux 上可近似理解为该进程的线程数。
2. CAS 自旋和热点竞争
CAS 失败后通常需要重试。多个线程持续竞争同一个变量时,可能产生大量失败和自旋,CPU 很高但有效吞吐不高。
例如高并发统计可以考虑 LongAdder 分散热点;业务状态更新则要重新评估数据分片、临界区、锁粒度和是否真的需要所有线程竞争同一资源。
3. 锁竞争为什么不一定直接表现为高 CPU
普通阻塞锁竞争中的线程可能进入等待状态,CPU 不一定高;但以下情况可能提高 CPU:
- 自旋锁或轻量级锁长时间竞争;
- 大量线程频繁唤醒又重新阻塞;
- 临界区过小但竞争极其频繁;
- 公平竞争和线程调度造成大量切换。
因此要结合 JFR 同步事件、线程状态、上下文切换和吞吐判断,而不是只看线程栈里是否出现 BLOCKED。
JFR 中常用的同步与等待事件包括:
| 事件 | 主要表示什么 |
|---|---|
jdk.JavaMonitorEnter | 线程进入 synchronized 监视器时发生了阻塞,可用于分析锁竞争 |
jdk.JavaMonitorWait | 线程执行 Object.wait() 后等待监视器通知或超时 |
jdk.ThreadPark | 线程通过 LockSupport.park() 等机制进入等待,AQS 锁和并发组件经常使用它 |
这些事件是否记录以及记录多短的等待,取决于所使用的 JFR 配置和事件阈值。分析时要关注累计等待时间、事件次数、关联线程和调用栈,不能把正常的短暂等待都判断为性能问题。
九、区分 CPU 使用率、饱和度和业务压力
CPU 使用率高本身不一定是故障。如果系统在高流量下:
- 吞吐正常;
- P99 延迟可接受;
- 错误率稳定;
- 没有明显排队和超时;
- CPU 仍保留安全余量;
那么它可能只是有效使用计算资源。
真正需要警惕的是:
- CPU 长时间接近上限;
- Run Queue 持续增长;
- 延迟和超时显著上升;
- 吞吐不再增加;
- 容器 CPU 限流持续加重;
- 单实例负载明显倾斜;
- 流量下降后 CPU 无法恢复。
处理时应把 CPU 与 QPS、任务速率、延迟、错误率和实例数放在同一时间线上分析。
**Run Queue(运行队列)**可以理解为已经具备运行条件、正在使用 CPU 或正在等待 CPU 时间片的任务数量。在 vmstat 中通常观察 r 列。它不是线程池任务队列,也不包含普通的休眠线程。
判断 Run Queue 是否异常要结合 CPU 核数:短时波动很常见;如果 r 长时间明显高于可用 CPU 核数,同时 CPU 接近饱和、延迟上升,说明可运行任务持续排队,系统已经出现 CPU 调度压力。
十、常见场景与证据对应
| 现象 | 重点证据 | 常见根因 |
|---|---|---|
| 单个线程稳定占满一个核 | 热点 TID + 多份线程栈 | 死循环、重试、单线程热点计算 |
| 多个业务线程共同升高 | JFR、CPU 火焰图、请求量 | 流量增长、序列化、压缩、算法热点 |
| GC 线程 CPU 高 | GC 日志、JFR、分配火焰图 | 分配速率过高、存活对象过多 |
| 线程数与切换次数激增 | 线程数、pidstat -w、线程栈 | 线程池失控、任务堆积、阻塞补偿过度 |
| 机器 CPU 高但 JVM CPU 低 | 进程 CPU、mpstat、pidstat | 其他进程、内核、中断或宿主机问题 |
| CPU 未满但延迟很高 | cgroup cpu.stat、容器 limit | 容器 CPU 限流或配额过低 |
| CPU 高且异常日志暴增 | 异常指标、日志速率、火焰图 | 异常堆栈生成、重复日志和失败重试 |
十一、修复后的验证
修复完成后至少验证:
- 相同或更高流量下 CPU 是否恢复到合理范围;
- 吞吐、平均延迟和 P99 延迟是否改善;
- 错误率和超时率是否恢复;
- GC 频率、分配速率和 GC 线程 CPU 是否正常;
- 线程数和上下文切换是否稳定;
- 容器 CPU 限流是否下降;
- 降峰后 CPU 是否能够及时回落;
- 是否出现把 CPU 问题转移成内存、队列积压或下游压力的问题。
不能只看某个热点方法消失就宣布修复。优化可能降低单次计算成本,也可能只是把任务积压到了队列或下游系统。
十二、预防与监控
建议建立以下监控:
- 机器、容器和 JVM 进程 CPU;
- 用户态、内核态、I/O wait 和 steal time;
- CPU request、limit 与容器 CPU 限流;
- 线程总数、RUNNABLE 线程数和上下文切换;
- GC 线程 CPU、停顿、频率和分配速率;
- 接口 QPS、任务速率、延迟和错误率;
- 消息积压、线程池活跃线程、队列长度和拒绝次数;
- 异常数量和日志写入速率;
- 单实例负载分布。
对于偶发且持续时间很短的问题,可以考虑常态化开启低开销的 JFR 连续记录。连续记录是让 JFR 在后台长期以较低开销采集事件,并通过 maxage 或 maxsize 维护一个循环缓冲区;旧数据会被自动覆盖,告警发生后再导出最近数小时的运行现场。长期记录通常使用偏低开销的 default 配置,需要更细采样时再短时开启 profile 配置。
十三、常见误区
1. CPU 高就立即扩容
扩容可以缓解容量不足,但无法根治死循环、重试风暴或热点数据倾斜。异常逻辑可能在每个新实例上再次出现。
2. 只抓一份线程栈
单个快照不能证明某个线程持续占用 CPU,应连续采样并与线程 CPU 数据关联。
3. 只看线程状态
RUNNABLE 不等于线程一定正在消耗大量 CPU。Java 的 RUNNABLE 还可能包含正在执行某些原生或网络操作的线程,需要结合 TID CPU、连续线程栈和采样分析。
4. 看到 GC 线程就直接调大堆
堆变大可能降低 GC 频率,也可能增加停顿和资源占用。应先确认分配热点、存活对象和容量模型。
5. 在线上长时间开启重型分析
诊断工具本身也有开销。应优先使用低开销采样,限定持续时间和输出大小,并评估目标实例当前承压能力。
十四、推荐排查顺序
可以记成下面这条主线:
止损并保留现场
↓
确认机器、容器、进程哪一层 CPU 高
↓
确认是否为 Java 进程
↓
找到高 CPU TID,并与线程栈 nid 对应
↓
连续线程栈仍不明确时,使用 JFR 或 async-profiler
↓
结合 GC、线程数、锁、流量和容器限流定位根因
↓
修复后用真实负载验证,并补齐监控和容量保护
评论与讨论
回复 :
留下你的想法