面试专题

如果一个系统 CPU 飙高,应该如何排查处理?

从线上止损、确认 CPU 消耗主体、定位高 CPU 线程、关联 Java 栈与火焰图,到死循环、GC、锁竞争、线程膨胀和热点计算分析,系统讲解 Java 服务 CPU 飙高的排查方法。

难度:深入更新:2026-07-29

如果一个系统 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、线程和流量定位根因。

一张图看懂

Java 系统 CPU 飙高的止损、分层定位、线程关联、根因分析和修复验证流程

详细讲解

先理解容器 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_throttlednr_periods 的比例持续较高,或 throttled_usec 快速增长,说明容器经常受 CPU 配额限制;还要继续判断是 CPU limit 配置过低,还是应用存在异常计算。

一、CPU 高并不等于业务代码一定有死循环

看到 CPU 告警后,首先要明确三个问题:

  1. 是整台机器 CPU 高,还是某个容器 CPU 高?
  2. 是 Java 进程消耗 CPU,还是同机其他进程或内核消耗 CPU?
  3. 是真实计算压力增加,还是容器 CPU 配额不足、线程调度或 GC 等问题?

常见根因包括:

  • 流量或任务量突然上升;
  • 死循环、无上限重试或消息空转;
  • 正则表达式灾难性回溯;
  • JSON 序列化、压缩、加解密等计算热点;
  • 对象分配速度过快,引发频繁 GC;
  • 线程数量膨胀和上下文切换;
  • CAS 竞争、自旋等待或热点锁;
  • 异常大量抛出、堆栈生成和同步日志输出;
  • JIT 编译、GC 线程或第三方原生库消耗 CPU;
  • 其他进程、内核中断或虚拟机宿主机争抢 CPU;
  • 容器 CPU limit 太低或持续发生 CPU 限流。

因此不能一看到 CPU 高,就直接重启、扩容或者抓一份线程栈后认定某段代码有问题。

二、第一阶段:先控制故障影响

线上处理应先恢复系统可用性:

  1. 从负载均衡中摘除最异常的实例;
  2. 对非核心接口限流、熔断或降级;
  3. 暂停可能造成空转、重试风暴或计算放大的任务;
  4. 有健康副本时切换流量或临时扩容;
  5. 如果线程已经长期占满 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用户态 CPUJava 业务代码、GC、JIT 或其他用户进程
sy内核态 CPU系统调用、网络、磁盘、驱动或内核开销
waCPU 等待 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、mpstatpidstat其他进程、内核、中断或宿主机问题
CPU 未满但延迟很高cgroup cpu.stat、容器 limit容器 CPU 限流或配额过低
CPU 高且异常日志暴增异常指标、日志速率、火焰图异常堆栈生成、重复日志和失败重试

十一、修复后的验证

修复完成后至少验证:

  1. 相同或更高流量下 CPU 是否恢复到合理范围;
  2. 吞吐、平均延迟和 P99 延迟是否改善;
  3. 错误率和超时率是否恢复;
  4. GC 频率、分配速率和 GC 线程 CPU 是否正常;
  5. 线程数和上下文切换是否稳定;
  6. 容器 CPU 限流是否下降;
  7. 降峰后 CPU 是否能够及时回落;
  8. 是否出现把 CPU 问题转移成内存、队列积压或下游压力的问题。

不能只看某个热点方法消失就宣布修复。优化可能降低单次计算成本,也可能只是把任务积压到了队列或下游系统。

十二、预防与监控

建议建立以下监控:

  • 机器、容器和 JVM 进程 CPU;
  • 用户态、内核态、I/O wait 和 steal time;
  • CPU request、limit 与容器 CPU 限流;
  • 线程总数、RUNNABLE 线程数和上下文切换;
  • GC 线程 CPU、停顿、频率和分配速率;
  • 接口 QPS、任务速率、延迟和错误率;
  • 消息积压、线程池活跃线程、队列长度和拒绝次数;
  • 异常数量和日志写入速率;
  • 单实例负载分布。

对于偶发且持续时间很短的问题,可以考虑常态化开启低开销的 JFR 连续记录。连续记录是让 JFR 在后台长期以较低开销采集事件,并通过 maxagemaxsize 维护一个循环缓冲区;旧数据会被自动覆盖,告警发生后再导出最近数小时的运行现场。长期记录通常使用偏低开销的 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、线程数、锁、流量和容器限流定位根因
    ↓
修复后用真实负载验证,并补齐监控和容量保护

参考资料

DISCUSSION

评论与讨论

留下你的想法