CPU与负载问题排查
开篇:CPU 100% 了,你会怎么查?
CPU 飙高是线上最常见的问题之一,也是面试中最爱考的排查题。好消息是,和内存问题相比,CPU 问题的排查路径相对标准化——top 找进程、top -Hp 找线程、jstack 看堆栈。但真正的功力体现在:拿到堆栈之后,你能不能快速定位到根因?
这篇文章记录了几个真实的 CPU 飙高案例,涵盖了压测中发现的代码问题、发布后的 JIT 预热问题、数据库锁竞争导致的 CPU 打满,以及日志打印引发的性能瓶颈。每个案例都完整还原了排查过程。
一、CPU 飙高排查三板斧
不管具体原因是什么,排查 CPU 飙高的标准流程都是一样的:
或者用 Arthas 一步到位:thread -n 3 -i 1000(查看 CPU 占用最高的 3 个线程)。
下面所有案例都是这套流程的具体应用。
二、案例一:Sequence 每次 new 导致压测 CPU 打满
问题发现
新应用上线,日常 QPS 只有 5。接入新业务后预计日常 QPS 2000、大促峰值 1 万,于是做压测。压测中发现单机 QPS 达到 200 时,RT 没有明显变化,但 CPU 利用率急剧升高直到被打满:

压测一停,CPU 立刻降下来。
排查过程
压测期间登录机器,先用 top 确认是 Java 进程在吃 CPU:
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
3480 admin 20 0 7565624 2.9g 8976 S 241.2 35.8 649:07 java用 Arthas 的 thread -n 3 -i 1000 查看最耗 CPU 的线程堆栈:

线程卡在 JDBC 底层的 TCP 套接字读取上。连续执行多次,大量线程都卡在同一个位置——TDDL(数据库中间件)的 Sequence 创建过程。
TDDL 的 Sequence 设计是每次从数据库取 1000 条序号缓存在本地,用完后再取。QPS 才 300,不应该这么频繁和数据库交互。
排查代码后发现了一个很傻的问题:
public Long insert(T dataObject) {
if (dataObject.getId() == null) {
Long id = next();
dataObject.setId(id);
}
// ...
}
public Sequence sequence() {
return SequenceBuilder.create() // ← 每次都 new 一个 Sequence!
.name(getTableName())
.sequenceDao(sequenceDao)
.build();
}
protected Long next() {
return sequence().nextValue(); // ← 每次 insert 都重建 Sequence
}每次 insert 都重新 build 了一个新的 Sequence 对象,本地缓存的 1000 条序号直接被丢掉了。下一次又去数据库取 1000 条,但只用 1 条。周而复始,把 CPU 都耗在了和数据库的无效交互上。
第一次修复
把 Sequence 实例改为应用启动时初始化一次:
public abstract class BaseMybatisDAO implements InitializingBean {
private Sequence sequence;
@Override
public void afterPropertiesSet() {
sequence = SequenceBuilder.create()
.name(getTableName())
.sequenceDao(sequenceDao)
.build();
}
}修复后数据库读 RT 明显下降,Sequence 写操作 QPS 也大幅降低:

发现第二个问题
以为彻底解决了,结果新一轮压测 CPU 还是很高。再次用 Arthas 查看:

发现一个联调工具在预发布环境默认开启了 TDDL 日志采集,采集过程中会做数据脱敏——调用 Google 的 re2j 做正则匹配。大量 TDDL 操作 + 每条都做正则脱敏,CPU 直接被吃满。
关闭该工具的 TDDL 采集后,问题彻底解决。
教训:排查问题是一个抽丝剥茧的过程。解决了一个问题以为万事大吉,实际上可能还有第二个、第三个。每次修复后都要重新验证。
三、案例二:Hibernate Validator 每次请求都初始化
问题发现
大促前压测,某个接口在 QPS 上升到 500 后,CPU 急剧升高。
排查过程
用 top → top -Hp → printf → jstack 的标准流程定位到问题线程:
$top
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1893 admin 20 0 7127m 2.6g 38m S 181.7 32.6 10:20.26 java
$top -Hp 1893
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
4519 admin 20 0 7127m 2.6g 38m R 18.6 32.6 0:40.11 java
$printf '%x\n' 4519
11a7
$sudo -u admin jstack 1893 | grep -A 200 11a7堆栈信息直指 BeanValidator.java 第 30 行:
at javax.validation.Validation.buildDefaultValidatorFactory(Validation.java:111)
at com.test.common.util.BeanValidator.validate(BeanValidator.java:30)查看代码发现,自定义的 BeanValidator 在每次 validate 调用中都通过 Validation.buildDefaultValidatorFactory().getValidator() 初始化一个新的 Validator 实例。这个初始化过程涉及 XML 解析和 ClassLoader 资源加载,非常耗时。
解决方案
把 Validator 实例的初始化提到类级别,只创建一次:
public class BeanValidator {
private static final Validator validator =
Validation.buildDefaultValidatorFactory().getValidator();
public static <T> void validate(T object) {
Set<ConstraintViolation<T>> violations = validator.validate(object);
// ...
}
}总结:用 Arthas 也可以一步到位——thread -n 3 查看 CPU 占比前三的线程。
四、案例三:发布后 Load 飙高——JIT 预热问题
问题发现
某应用平时运行稳定,但每次发布过程中,刚重启的机器 CPU 和 Load 都会飙高,导致大量调用方超时。

发布后几分钟内 CPU 飙到 70%、Load 飙到 11,持续几分钟后自行恢复。
排查过程
这个问题排查了很久:
- 打各种堆栈 Dump、分析火焰图 → 没看到明显异常
- 换机器配置、换 Docker 镜像 → 没有变化
- 换 JDK 版本、调整堆内存、换垃圾收集器 → 也不是根因
最后经过多方讨论,定位到和 JIT 编译有关。
Java 刚启动时,所有代码都是解释执行的。JIT 优化器需要先运行一段时间,发现热点代码后才会将其编译成机器码。在 JIT 优化完成之前:
- 所有请求都走解释执行,CPU 开销大
- 如果应用请求量大,解释器持续满负荷工作
- 解释执行占用大量 CPU → Load 飙高 → RT 变长 → 更多请求积压
随着请求增多,JIT 逐渐识别热点方法并编译优化。几分钟后热点代码都编译好了,性能就恢复正常了——这就是为什么"飙高几分钟后自行恢复"。
解决方案
方案一:JwarmUp 技术(阿里 Dragonwell JDK)
记录上一次运行的编译信息,下次启动时提前加载,跳过解释执行阶段直接用编译好的机器码。
配置开关开启 JwarmUp 后效果明显:

方案二:流量预热
应用刚启动时不要立刻分配大流量。通过负载均衡调节,先给一小部分流量触发 JIT 编译,优化完成后再逐步放大流量。
五、案例四:数据库热点行并发更新导致 CPU 打满
问题发现
收到数据库 CPU 飙高告警,间歇性把 CPU 打满:

排查过程
监控显示 CPU 飙高的同时有大量 SQL 锁等待,平均 1.5 秒,高峰期 4-5 秒。具体是一些 update 语句:
SET gmt_modified = now(), business_type_enum = ?, product_type_enum = ?
WHERE number = ?其中 number 有唯一索引。问题很明确:多个线程同时更新同一行记录。InnoDB 的 update 自动加行锁,拿不到锁的线程必须等待,大量锁等待导致自旋消耗 CPU。
结合业务分析:有一个合案定时任务,扫描风控数据后基于用户维度合并到审核单上。伪代码:
for (FraudRiskOrder order : riskOrders) {
update fraud_audit_order set xxx = 'xx' where audit_no = "同一个单号";
}之前没出过问题,是因为最近做了一次性能优化——用分布式任务并发扫表。性能好了,扫得快了,如果同一用户的风险单多,就会并发修改同一条审核单。
解决方案
不再单条更新,而是先做预合案(用 SQL 聚合),再批量一次性更新:
SELECT
subject_id,
GROUP_CONCAT(DISTINCT(number) SEPARATOR ',') AS risk_order_numbers,
GROUP_CONCAT(DISTINCT(risk_level_enum) SEPARATOR ',') AS risk_levels
FROM fraud_risk_order
WHERE product_type_enum = 'XXX' AND status = 'DRAFT'
GROUP BY subject_id_enum, subject_id用 GROUP_CONCAT 把需要合并的数据在 SQL 层面就聚合好,代码中只需做一次合并 + 一次更新。
效果:CPU 问题解决,合案任务从 2 小时降到 10 分钟。
六、案例五:日志打印导致 CPU 飙高
问题发现
秒杀方案压测中(4C8G 机器,QPS 100),CPU 飙到 300%(4 核机器就是 75%+)。

排查过程
Arthas thread -n 3 定位到占用 CPU 最高的线程(50% 左右,且持续排在第一):

堆栈最后一行:logback 的 AsyncAppender 调用。定位到和日志打印有关。
查看 logback-spring.xml 中的 AsyncAppender 配置:
| 参数 | 当前配置 | 问题 |
|---|---|---|
| queueSize | 很小 | 队列容易满 |
| discardingThreshold | 0 | 不自动丢弃任何级别的日志 |
| neverBlock | 未配置(默认 false) | 队列满时阻塞等待,严重影响性能 |
同时检查应用日志,发现大量 ShardingJDBC 的 SQL 日志,这些完全不需要打印。
解决方案
- 调大
queueSize,设置合理的discardingThreshold,开启neverBlock=true - 关闭 ShardingJDBC 的 SQL 日志输出
修改后 CPU 从 75%+ 降到 50% 以下,日志线程 CPU 占用从 50% 降到 10% 左右。
七、死循环和死锁对 CPU 的影响
死循环会导致 CPU 飙高吗?
会。 死循环中的线程不断执行指令,持续占用 CPU 时间片。
while (true) {
// CPU 会一直忙
}如果死循环中有系统调用(如 System.out.println()),us(用户态)和 sy(内核态)CPU 都会升高。如果是纯用户态计算,只有 us 升高。
死锁会导致 CPU 飙高吗?
不会,甚至可能降低。 死锁发生时,涉及的线程被挂起等待锁(状态为 BLOCKED 或 sleeping),不消耗 CPU。CPU 使用率统计的是 running 状态的任务。
死锁的危害不在 CPU,而在业务线程被永久卡住——无法处理新请求。用 jstack -l 可以直接检测到死锁。
八、常见面试题精选
CPU 飙高了你会怎么排查?
标准答案就是三板斧:top → top -Hp → jstack(或 Arthas thread -n 3)。但面试官真正想听的是你拿到堆栈后怎么分析:
- 堆栈卡在 JDBC/网络 IO → 查数据库慢 SQL 或下游接口超时
- 堆栈卡在锁等待 → 查锁竞争,是否有热点行更新
- 堆栈卡在正则/序列化/日志 → 检查这些操作的频率和数据量
- 堆栈在业务代码中不断循环 → 检查是否死循环或循环次数过多
- 堆栈在 GC 线程 → 参见 内存与 GC 问题排查
压测中发现瓶颈怎么分析?
压测的核心指标是 QPS、RT、CPU、内存、IO。当某个指标先达到瓶颈时:
| 瓶颈指标 | 常见原因 | 排查方向 |
|---|---|---|
| CPU 先到顶 | 计算密集、锁竞争、序列化开销 | jstack / 火焰图 |
| RT 先变长 | 外部依赖慢、数据库慢、锁等待 | 链路追踪 + 数据库监控 |
| 内存先打满 | 大对象、泄漏、缓存过大 | 堆 Dump 分析 |
| IO 先到顶 | 日志量大、磁盘慢 | iostat + 日志检查 |
Load 高但 CPU 不高是什么情况?
Load Average 反映的是系统中可运行 + 不可中断睡眠状态的任务数。CPU 不高但 Load 高,通常是大量线程在做 IO 等待(不可中断睡眠状态,比如磁盘 IO、网络 IO)。
用 iostat -x 1 查看磁盘 IO 利用率,或者 vmstat 1 查看等待 IO 的进程数(wa 列)。
九、CPU 排查常用命令速查
日常排查中最常用的命令备忘:
# 1. 找到 CPU 最高的 Java 进程
top
# 2. 找到进程中 CPU 最高的线程
top -Hp <pid>
# 3. 线程 ID 转 16 进制
printf '%x\n' <tid>
# 4. 查看线程堆栈
jstack <pid> | grep -A 200 <hex_tid>
# 5. Arthas 一步到位(查 CPU Top 3 线程)
thread -n 3
# 6. Arthas 查看方法调用耗时
trace com.example.MyService myMethod
# 7. Arthas 查看方法被谁调用
stack com.example.MyService myMethod
# 8. 查看系统整体负载
uptime # Load Average
vmstat 1 # CPU/IO/内存概览
iostat -x 1 # 磁盘 IO 详情
# 9. 生成火焰图(需要 async-profiler)
./profiler.sh -d 30 -f flamegraph.html <pid>Arthas 进阶用法:
dashboard:实时查看线程、内存、GC 的综合面板thread -b:找到阻塞其他线程的罪魁祸首watch com.example.MyService myMethod returnObj:观察方法返回值tt -t com.example.MyService myMethod:记录方法调用的入参和返回值,支持回放
火焰图是定位 CPU 瓶颈的利器。横轴是 CPU 时间占比,纵轴是调用栈深度,最宽的那块就是 CPU 消耗最多的代码路径。async-profiler 可以生成 Java 应用的火焰图,不需要重启应用:
# 采集 30 秒 CPU 数据并生成火焰图
./profiler.sh -d 30 -f cpu_flamegraph.html <pid>
# 也可以采集内存分配的火焰图
./profiler.sh -d 30 -e alloc -f alloc_flamegraph.html <pid>当 jstack 看不出明显问题时(比如多个线程都"看起来正常",但 CPU 就是高),火焰图往往能一眼看出瓶颈在哪。
十、补充:常见 CPU 飙高的代码模式
除了前面详细展开的案例,以下代码模式也经常在生产中引发 CPU 问题,简要记录特征和解法。
模式一:正则表达式回溯
某些正则表达式在特定输入下会触发指数级回溯,一个匹配操作就能吃满一个 CPU 核。
典型的危险模式:(a+)+b 对输入 aaaaaaaaac 做匹配。
解法:避免嵌套量词,使用原子组或占有量词;或者用 RE2J(不支持回溯的正则引擎)替代 java.util.regex。
模式二:序列化/反序列化开销
JSON 序列化(Jackson/Fastjson)和 XML 序列化在高 QPS 场景下会成为 CPU 瓶颈,尤其是对象结构复杂、字段多的情况。
解法:使用 Protobuf/Kryo 等二进制序列化方案替代 JSON;或者缓存 ObjectMapper 实例,不要每次都创建。
模式三:大量对象的 hashCode/equals 计算
HashMap 中的 key 如果 hashCode 实现不好(大量冲突),或者 equals 方法需要比较很多字段,在高并发下也会成为 CPU 瓶颈。
解法:优化 hashCode 的散列效果,减少 hash 冲突;equals 中优先比较开销小的字段。
模式四:GC 频繁
GC 线程也是 CPU 消费大户。如果 Young GC 每秒好几次,或者频繁 FullGC,CPU 大部分时间都在做垃圾回收。
解法:参见 内存与 GC 问题排查,从减少对象创建和优化内存分配入手。
小结
CPU 问题的排查比内存问题更"标准化"——工具链就那几个,流程也是固定的。但每次排查的收获都不一样,因为根因千变万化。
几个反复出现的教训:
- 重量级对象不要反复创建:Sequence、Validator、Pattern 这类初始化成本高的对象,要在启动时或首次使用时创建一次
- 日志不是免费的:异步日志的队列大小、丢弃策略、是否阻塞,都需要合理配置
- JIT 预热不可忽视:高流量应用发布时要做流量预热或使用 JwarmUp
- 并发更新同一行是大忌:预聚合 + 批量更新代替循环单条更新
- 解决了一个问题不要急着庆祝——重新验证,可能还有第二个