系列:线上问题实战录 | CPU 飙高类 · 第 5 篇 本文所有命令和输出均来自真实复现环境,可照步骤重现
周三下午 14:30,身份验证服务群弹出告警:

CPU 从 82.4% 开始斜线爬升,15 分钟内冲到 96.2%。接口 p99 从 45ms 涨到 320ms。
这次的问题不是突发的,而是缓慢爬升——从中午上线新功能后,CPU 花了 2 个多小时从 30% 涨到 96%。没有大促、没有流量突增、QPS 一直稳定在 800 左右。
回滚审计日志功能后,CPU 恢复。
回滚 + 重启,问题解除。
但所有人都在想同一个问题:审计日志又不是密集计算,怎么会吃掉这么多 CPU?
$ ssh prod-auth-01
$ top -b -n 1 | head -25

Java 进程 CPU 187%(8 核机器占了近 2 核),load average 从 2.0 涨到 7.8。
但奇怪的是:QPS 没涨,业务上也没密集计算的任务。Java 进程凭什么吃 187% 的 CPU?
排查的关键一步:看线程级 CPU。
$ top -b -H -p 24512 -n 1 | head -35

结果令人震惊:
| 线程名 | CPU% | 说明 |
|---|---|---|
| GC Thread#0~#7 | 各 ~23% | 8 个 GC 线程各占近 1/4 核 |
| G1 Conc#0~#1 | 各 ~12% | 并发标记线程也在吃 CPU |
| http-nio-exec 线程 | 各 ~1% | 业务线程几乎不占 CPU |
GC 相关线程合计占 ~210% CPU,而业务线程平均不到 1%。
这就是文章标题的答案:CPU 是 GC 线程消耗的,不是业务代码。
$ jstat -gcutil 24512 1s 10

| 指标 | 数值 | 含义 |
|---|---|---|
| YGC 每秒增量 | ~1 次/s | 每秒触发 1 次 Young GC |
| YGCT 每次 | ~25ms | 每次 Young GC 暂停约 25ms |
| CGC | 8 次已累积 12.456s | 并发 GC 也有显著开销 |
| OU(老年代) | 缓慢上涨 | 对象在晋升 |
每秒 1 次 Young GC,每次 25ms 的暂停——但 GC 线程实际消耗的 CPU 远远超过 25ms/秒。
为什么?因为 G1 的 GC 线程在并发阶段(并发标记、并发清理)是持续运行的,不停地在扫描、标记、处理引用。这些不体现在 STW 时间里,但体现在 top -H 的 CPU% 上。
$ jstat -gc 24512 1s 5

Eden 区 40MB,每秒填满一次。计算分配速率:~40MB/s。
为什么分配这么快?每个请求创建了什么?
$ jmap -histo:live 24512 | head -30

| 对象类型 | 实例数 | 占用字节 |
|---|---|---|
[Ljava.lang.Object; |
482,350 | ~305MB |
java.util.HashMap$Node |
48,235 | ~6.9MB |
java.util.HashMap |
48,235 | ~5.4MB |
java.lang.String |
48,300 | ~3.9MB |
java.util.UUID |
24,128 | ~1.9MB |
HashMap、HashMap$Node、UUID——一看就知道是审计日志那个功能。
审计日志功能的 V1 代码,每次请求都会创建大量临时对象:
public void audit(Map<String, Object> payload) {
// 每次请求创建:
Map<String, Object> auditRecord = new HashMap<>(); // ①
Map<String, Object> detail = new HashMap<>(); // ②
Map<String, Object> requestContext = new HashMap<>(); // ③
List<Map<String, Object>> permissions = new ArrayList<>(); // ④
for (int i = 0; i < 50; i++) {
Map<String, Object> perm = new HashMap<>(); // ⑤ ×50
permissions.add(perm);
}
detail.put("sessionId", UUID.randomUUID().toString()); // ⑥
String json = mapper.writeValueAsString(auditRecord); // ⑦ jackson 临时对象
}
每个请求分配约 50KB+ 的临时对象。QPS=800 → 每秒分配 40MB。
这里需要理解 G1 GC 的线程模型:
| GC 阶段 | 线程数 | CPU 行为 |
|---|---|---|
| Young GC(暂停) | 8 个 (ParallelGCThreads) | 全线程并行,但时间短(25ms/次) |
| 并发标记(Concurrent Marking) | 2 个 (ConcGCThreads) | 持续运行,扫描引用链 |
| 并发清理(Concurrent Cleanup) | 1 个 | 释放空闲区域 |
并发标记线程持续跑:每秒 1 次 Young GC → 新对象不断产生 → 并发标记线程持续扫描新生代引用的老年代对象。这些线程开起来就不停,直到标记完成——但如果分配太快,标记永远追不上,线程就一直跑。
GC 线程是真正的 OS 线程:8 个 GC Thread + 2 个 Conc Thread,每个都在消耗 CPU 时间片。在 top -H 眼里,它们和业务线程没有区别。
死亡螺旋:
分配率 ↑ → YGC 更频繁 → GC 线程占 CPU ↑ → 业务线程 CPU ↓
→ 请求处理变慢 → 同时在途请求 ↑ → 总分配率 ↑ → 更频繁的 YGC
| 指标 | 正常状态 | 问题状态 |
|---|---|---|
| YGC 频率 | 每 30 秒 1 次 | 每秒 1 次 |
| 单次 YGC 耗时 | ~12ms | ~25ms |
| GC 线程 CPU | < 1% | ~210% |
| 分配速率 | ~1.2 MB/s | ~40 MB/s |
| 业务线程 CPU | ~25% | ~8% |
关键在:GC 频率低的时候,GC 线程几乎不占 CPU。但当 GC 每秒发生一次,GC 线程的开销就不再是「暂停时间」那点事了。它们变成了系统的 CPU 大户。
Demo 项目模拟了 V1 和 V2 两种实现:

V1 的问题:每次 audit() 调用都 new 一堆 HashMap、ArrayList、UUID,再序列化成 JSON。

V2 的修复:
- StringBuilder 池化:重用同一个 StringBuilder,setLength(0) 重置
- 静态缓存:权限列表是不变的,提前构建好
- 扁平化拼接:不用 HashMap 套娃,直接拼接字符串
- 减少临时对象:去掉 UUID、去掉多余的 HashMap 层级
| 指标 | V1 | V2 | 改善 |
|---|---|---|---|
| 每次请求分配 | ~50KB | ~2KB | 25x |
| 分配速率 | 40MB/s | 1.6MB/s | 25x |
| YGC 频率 | 每秒 1 次 | 每 30 秒 1 次 | 30x |
| GC 线程 CPU | ~210% | <1% | 200x+ |
核心修复思路:减少每次请求的临时对象分配。
① 池化 StringBuilder
// 问题
public void audit() {
StringBuilder sb = new StringBuilder();
sb.append(...);
// 每次都 new
}
// 修复
private final StringBuilder auditBuffer = new StringBuilder(4096);
public void audit() {
auditBuffer.setLength(0);
auditBuffer.append(...);
// 重置而不是新建
}
② 静态缓存不变量
// 修复
private static final class AuditTemplate {
static final String EVENT = "user.login";
static final String SOURCE = "auth-service";
static final List<Map<String, Object>> STATIC_PERMISSIONS =
Collections.unmodifiableList(buildPermissions());
}
③ 减少 JSON 序列化层级
V1 用了 3 层 HashMap 嵌套再序列化 → 大量中间对象。V2 直接拼接字符串,省掉所有中间 HashMap。
除了代码层面的修复,还可以:
-XX:NewSize=256m -XX:MaxNewSize=256m 降低 YGC 频率-XX:ParallelGCThreads=4 减少 GC 线程数量(但会延长暂停时间)
| 指标 | 修复前 | 修复后 |
|---|---|---|
| CPU | 187% | 24.5% |
| Load | 7.82 | 1.82 |
| YGC 频率 | 每秒 1 次 | 几乎 0 |
| p99 | 320ms | 48ms |
监控 GC 频率,而不是只看 GC 暂停时间:YGC 每秒超过 1 次就要警惕。暂停时间只占 GC 开销的一小部分,并发 GC 线程的 CPU 才是大头。
top -H 是排查 CPU 问题的第一利器:别只盯着进程级 CPU%,看线程级才能发现真相。GC 线程集体排在前列 = 分配率过高。
每个请求的分配量是关键指标:QPS 不变时 CPU 涨了 → 大概率是每个请求做了更多的事。用 jmap -histo 或 JFR 看什么对象在暴增。
审计日志/业务日志不要序列化完整上下文:每次请求创建 50 个 HashMap 再序列化 JSON,这个开销比你想象的大得多。需要完整上下文就用结构化日志 + 异步写入。
GC 优化不是只有调参数:减少对象分配比调任何 GC 参数都有效。最好的 GC 是不发生 GC。
StringBuilder 池化是低成本的优化:setLength(0) 比 new StringBuilder() 快一个数量级,且不产生垃圾。
GC 线程是 JVM 中「看不见的 CPU 消费者」。正常情况下它们几乎不占资源,但当分配率过高时,GC 线程能吃掉整个机器的 CPU——不是因为暂停时间长,而是因为并发 GC 线程持续运行。
排查要点:
- top 看进程 CPU → top -H 查线程 → 发现 GC 线程霸榜
- jstat -gcutil 确认 GC 频率(每秒 > 1 次 YGC = 危险信号)
- jstat -gc 计算分配速率
- jmap -histo 或 JFR 定位分配热点
- 减少临时对象分配比调 GC 参数更重要
GC 暂停时间是冰山的一角。GC 线程的 CPU 消耗是水下看不见的那部分。
top -b -n 1 | head -25 # 查看进程 CPU 排行
top -b -H -p <pid> -n 1 | head -35 # 查看进程内各线程 CPU 排行
ps -T -p <pid> -o pid,pcpu,cputime,comm --sort=-pcpu | head -20 # 线程按 CPU 排序
jstat -gcutil <pid> 1s 10 # GC 统计每秒采样,连续 10 次
jstat -gc <pid> 1s 5 # 各区容量与使用量
jstat -gccause <pid> # GC 触发原因
jstat -gcnew <pid> # 新生代详情
jstat -gcold <pid> # 老年代详情
jstat -gccapacity <pid> # 各区容量配置
jmap -histo:live <pid> | head -30 # 存活对象直方图
📖 全文带可复现 Demo 和排查截图 🔗 个人博客:https://opencao.cn 📺 公众号:Ai拆代码的曹操 🌟 知识星球:源阅会 (82877104)