Java

Java 服务 CPU 高怎么排查:线上定位顺序别一上来就猜

当 Java 服务 CPU 飙高时,不要先猜 GC 或直接扩容。先分清是单实例还是整组实例,再按“进程 -> 线程 -> 线程栈 -> GC / 重试 / 热点代码”这条顺序收敛,才能更快判断问题是在计算热点、线程空转、下游放大,还是 GC 压力。

  • Java
  • CPU
  • 性能排查
  • jstack
  • JVM
18 分钟阅读

线上最容易把人带乱的告警之一,就是 Java 服务 CPU 持续 90%+

这类问题最烦的地方,不是工具不会用,而是团队第一反应经常跑偏:有人先猜 GC,有人先扩容,有人先翻最近发布,有人甚至直接想重启。动作都不算完全错,但如果顺序不对,就很容易把现场越查越散。

CPU 问题更适合按一条收敛链来处理:先看影响面,再找进程,再找线程,再看线程栈,最后把热点收敛到具体代码和业务场景。

遇到 Java CPU 高,我更在意的是别靠猜起手,先把证据按顺序收起来。

如果问题还没收窄到 JVM 这一层,可以回头看 接口响应慢怎么排查?后端 API 变慢与超时的定位步骤,把慢点落在哪一层分清。要是你已经看到 CPU 高同时伴随 GC 抖动,再接着看 Full GC 频繁怎么办:从回收效果分清泄漏和分配压力,把 CPU 和 GC 谁先失控分开看。

Java 服务 CPU 持续很高时,把影响面切开

先把实例级 CPU、线程级 CPU、接口 RT、GC 次数、线程池队列,以及最近 10 到 30 分钟内的发布或任务变化放进同一个时间窗里。CPU 高几乎从来不是孤立指标,单看一张 top 很容易把“计算热点”“线程空转”“GC 抢 CPU”“重试放大”混成一回事;先把这些现象对齐,再决定要不要沿 CPU 这条线继续收口。

你现在看到的现象更像什么下一步
某一台实例 CPU 明显高,其他实例正常单实例热点、局部任务、线程空转、资源争用沿本文顺序继续收口
整组实例 CPU 一起高,接口 RT 也一起抬共同流量、共同变更、下游放大、GC 压力先把实例与线程证据对齐
CPU 高同时伴随 Full GC 频繁GC 压力或对象创建风暴看完本文后接 Full GC 频繁怎么办:先判断是不是内存泄漏
CPU 不高,但接口 RT 和超时已经明显变差更像等待型问题,不一定先查 CPU先回 接口响应慢怎么排查?后端 API 变慢与超时的定位步骤
火焰图、线程栈已经明显指向某个业务方法更像计算热点或空转线程继续把线程证据收齐
高峰期才明显升高,低峰期恢复较好流量放大、缓存失效、批任务窗口、重试风暴先沿 CPU 主线收口,再回业务侧验证

一、CPU 高时,不要急着下结论

CPU 高只是现象,不是答案。

同样是 CPU 飙高,背后可能完全不是一类问题:

  • 业务代码死循环或空转
  • 高频重试、无退避轮询、自旋竞争
  • JSON 序列化、正则、加解密、对象转换过重
  • 大批量数据处理没有分页或分段
  • GC 频繁,回收线程持续抢 CPU
  • 线程池积压后任务集中释放
  • 下游异常导致本服务不断 fallback、补偿、重试
  • 宿主机其他进程、容器限额、系统资源争抢

所以 CPU 排查最忌讳的第一件事,就是一上来就说:

“这肯定是 GC。” “这肯定是代码死循环。” “机器不够,先扩容。”

更稳妥的做法是先回答两个问题:

  1. 影响面有多大?
  2. CPU 真正消耗在哪个层级?

二、第一步先看影响面,而不是直接抓线程

很多人一看到 CPU 高,就马上 top -Hpjstack。这不是不能做,但更高优先级的是先判断问题范围。

1. 先看是单实例高,还是整组实例都高

这里差别非常大。

如果只有一台实例异常,更常见的方向是:

  • 某个热点请求或脏数据只打到这台
  • 某个定时任务只在这一台执行
  • 某个线程卡住、空转、自旋
  • 实例本地状态异常
  • 容器限额或宿主机资源争抢

如果整个集群一起高,更应该优先怀疑:

  • 流量整体上涨
  • 最近共同变更引入了热点路径
  • 下游超时导致统一重试
  • 缓存失效或回源压力放大
  • 批量任务、报表任务、同步任务一起启动

2. 再看是持续高、周期性高,还是瞬时高

时间特征非常重要。

  • 持续高:更像死循环、热点代码、频繁重试、持续 GC 压力
  • 周期性高:更像定时任务、批处理、对账、缓存集中失效
  • 瞬时高:可能只是流量尖峰、线程瞬时释放、启动预热、JIT 抖动

如果你不先看时间特征,就很容易把“偶发尖峰”当成“长期根因”,或者把“每天固定时段的任务问题”当成随机事故。

3. 业务指标要一起看

CPU 告警出来后,建议第一时间把这些指标放一起:

  • QPS / TPS
  • 接口 RT
  • 错误率
  • 线程池活跃线程数和队列长度
  • GC 次数与停顿时间
  • 数据库连接数、慢 SQL、下游调用超时
  • 最近发布、配置变更、任务开关变化

CPU 从来不该脱离业务指标单看。因为很多时候,CPU 高只是链路中的一个结果,不是起点。

三、第二步:先确认是不是 Java 进程在吃 CPU

确认影响面之后,才进入进程和线程级排查。

先看机器上到底是谁在吃 CPU:

top -o cpu

或者:

ps -eo pid,ppid,cmd,%mem,%cpu --sort=-%cpu | head

如果机器上跑了多个服务,这一步非常关键。先别默认就是 Java 进程的问题。

确认是 Java 进程后,再通过:

jps -l

拿到应用进程 PID。

这一阶段至少要确认三件事:

  • 哪个 PID 在持续高 CPU
  • 它是持续异常还是瞬时波动
  • 除了它之外,机器上是否还有其他高 CPU 进程

如果宿主机本来就被别的进程打爆,你直接从 Java 栈里找热点,很可能一开始就找错方向。

四、第三步:按 top / pidstat / jstack 的顺序定位高 CPU 线程

我更推荐下面这条顺序,而不是一上来就抓一堆日志。

1. 用 top -Hp 找线程

top -Hp <pid>

例如:

top -Hp 12345

看持续排在前面的线程 ID,而不是只看某一个瞬时最高值。

2. 用 pidstat -t 做补充观察

如果机器上有 pidstat,它会更适合看线程趋势:

pidstat -t -p 12345 1 5

这个命令的价值在于:

  • 能看到线程 CPU 在几个采样点里是否稳定偏高
  • 能减少只看一次 top -Hp 的偶然性
  • 能辅助判断是少数线程持续高,还是大量线程一起抬升

3. 把线程 ID 转成十六进制

假设线程 ID 是:

23456

转换:

printf '%x\n' 23456

得到:

5ba0

后面去 jstack 里搜 nid=0x5ba0

4. 再抓 jstack

jstack 12345 > /tmp/jstack.log

必要时可加 -l

jstack -l 12345 > /tmp/jstack.log

然后搜索线程:

grep -n "5ba0" /tmp/jstack.log

这时候你要看的重点不是“线程状态是不是 RUNNABLE”这么简单,而是:

  • 它是不是业务线程
  • 它卡在哪个方法
  • 栈位置是否稳定重复
  • 是应用代码、框架代码、序列化、锁竞争,还是 GC 相关线程

五、只抓一次 jstack 不够,最好连续抓 2 到 3 次

这是 CPU 排查里非常高频的误判点。

单次 jstack 只能说明:这个瞬间线程在这里。 它不能证明:CPU 真正长期耗在这里。

更稳的做法通常是:

jstack 12345 > /tmp/jstack-1.log
sleep 3
jstack 12345 > /tmp/jstack-2.log
sleep 3
jstack 12345 > /tmp/jstack-3.log

如果同一个高 CPU 线程,在多次线程栈里都反复停在相近位置,那这段调用栈才真的值得重点怀疑。

为什么这一步重要?因为高 CPU 常见的真正形态是:

  • 持续热点循环
  • 重试或轮询持续执行
  • 复杂计算持续消耗
  • GC 连续工作
  • 线程之间频繁竞争或自旋

如果三次抓栈位置差异很大,就说明它可能只是“经过这里”,不一定是根因。

六、看到线程栈之后,先判断它属于哪一类 CPU 消耗

线程栈拿到之后,不要立刻跳进代码细节。先做分类判断,效率会高很多。

1. 业务代码死循环或低效循环

最典型的信号是线程栈长期停在某个固定业务方法里,而且方法内部有明显循环。

例如:

while (true) {
    if (queue.isEmpty()) {
        continue;
    }
    handle(queue.poll());
}

这类代码问题通常包括:

  • 没有阻塞的空轮询
  • 没有退避的重试
  • 写错退出条件
  • 自己实现的消费者循环过于激进

2. 计算热点

如果线程栈长期落在这些地方,更像计算问题:

  • JSON 序列化 / 反序列化
  • 大对象转换
  • 复杂正则
  • 大量 stream / collect / 排序 / 聚合
  • 加解密、签名、压缩解压

这类问题不一定有 bug,但会在高流量或大数据量下把 CPU 拉满。

3. 错误重试或调用风暴

有些 CPU 问题不是本地计算太重,而是本服务在不断做“无效工作”:

  • 下游超时后立即重试
  • fallback 自己又发起重试
  • 补偿任务没有止损
  • 失败请求不断打印异常和堆栈

这种场景下,CPU 消耗很可能来自:

  • 重试逻辑本身
  • 异常对象创建
  • 日志格式化和打印
  • 重复序列化请求与响应

4. 锁竞争、自旋或线程切换异常

锁竞争不一定表现为“所有线程都 BLOCKED”。

有时候你会看到:

  • 某些线程 RUNNABLE,但栈停在锁相关方法附近
  • 大量线程频繁切换
  • 吞吐没有提升,CPU 却很高
  • 自旋重试逻辑很激进

这时就不能只从“业务方法慢”理解 CPU,需要回头看并发模型和锁策略。

5. GC 干扰

GC 方向特别容易误判。

如果你看到:

  • GC 线程 CPU 很高
  • jstat 里 YGC / FGC 增长很快
  • CPU 高同时伴随内存抖动、吞吐下降、停顿感增强

那就别只盯业务线程了。问题可能更像:

  • 对象创建过快
  • 缓存膨胀
  • 老年代回收压力大
  • Full GC 频繁

这时候我通常会把 GC 这条线一起补上:

jstat -gcutil 12345 1000 10

再结合 GC 日志判断,不要看到 CPU 高就直接把锅全甩给代码热点。我一般会顺手接着看这篇后面的 Full GC 频繁怎么办:先判断是不是内存泄漏

七、Java CPU 高最常见的几类真实根因

如果把线上 CPU 问题按出现频率粗略归类,我会优先怀疑下面几类。

1. 死循环、空轮询、无退避重试

这是最典型,也最容易一核打满的原因。

特征通常是:

  • 单或少数线程持续高 CPU
  • 多次抓栈都在相近位置
  • 代码里有 while、轮询、重试、自旋逻辑

2. 大批量数据处理直接在线上实时执行

例如:

  • 一次查几十万数据做聚合
  • 没分页直接全量处理
  • 在接口线程里做大排序、大集合转换
  • 报表生成、导出、统计放在实时链路

这种问题不一定是“代码写错”,但通常是“放错了执行位置”。

3. 日志和异常风暴

这个问题经常被低估。

例如:

  • 下游报错后疯狂打印异常堆栈
  • 某段代码在大循环里反复 warn / error
  • 格式化超大对象日志
  • 大量序列化日志上下文

最后 CPU 并不是花在核心业务上,而是花在创建异常、拼日志、刷输出上。

4. GC 频繁

有时候业务线程看起来都正常,CPU 却很高,真正吃 CPU 的是回收线程。

这类情况更应该继续读下游页 Full GC 频繁怎么办:先判断是不是内存泄漏。因为 CPU 高有时不是主问题,而是 GC 压力的外部表现。

5. 下游异常把本服务拖成计算热点

最常见的是:

  • 下游超时 -> 本服务重试 -> CPU 更高 -> 线程更忙
  • 缓存失效 -> 数据库回源 -> 请求处理链变重
  • 某个热点 key、热点请求把一条复杂路径打热

所以 CPU 高经常不是孤立故障,它很可能和接口变慢、连接池打满、线程池堆积一起出现。

八、CPU 排查里最容易出现的误判

1. 误把 CPU 高当成“机器不够”,直接扩容

扩容有时能止血,但不等于定位完成。

如果根因是死循环、异常风暴、热点任务、频繁重试,扩容通常只是把问题摊开,不是解决。

2. 看到 Full GC 就默认 CPU 高一定由 GC 导致

CPU 高和 Full GC 可以互相影响。

  • 可能是对象创建太快,先把 GC 打起来
  • 也可能是业务热点先造成 CPU 高,顺带放大分配压力

所以必须区分谁是因、谁是果,别看到 GC 指标就停下排查。

3. 只抓一次 jstack 就下结论

一次快照非常容易误判。至少抓 2 到 3 次,再看高 CPU 线程是不是稳定重复出现在同一段栈里。

4. 只看 Java,不看系统层

你还得确认:

  • 宿主机还有没有别的高 CPU 进程
  • 容器 CPU quota 是否过低
  • 是否发生上下文切换异常
  • 是否只有个别节点资源被打偏

5. 把 CPU、线程池、接口慢拆开看

这几个现象经常是一条链:

  • 慢 SQL / 下游超时 -> 线程池堆积
  • 堆积任务集中释放 -> CPU 抬高
  • CPU 抬高 -> RT 更差 -> 重试更多

如果孤立看其中一个点,很容易误判真正起点。

九、一个更稳的排查顺序:先范围,再线程,再分类,再验证

如果你希望记住一条够用的固定路径,我建议用下面这版。

第 1 步:先判断范围

  • 单实例还是全实例
  • 持续高还是周期性高
  • 是否伴随 RT、错误率、QPS、GC 变化
  • 最近是否有发布、配置、任务变更

第 2 步:确认进程

  • top -o cpu
  • ps -eo pid,ppid,cmd,%mem,%cpu --sort=-%cpu | head
  • jps -l

第 3 步:确认线程

  • top -Hp <pid>
  • pidstat -t -p <pid> 1 5
  • 找持续高 CPU 线程 ID
  • 转成十六进制

第 4 步:连续抓栈

  • jstack <pid>
  • 最好抓 2 到 3 次
  • 搜索 nid=0x...
  • 判断栈是否稳定重复

第 5 步:先分类,再定位代码

先判断更像:

  • 死循环 / 空转
  • 计算热点
  • 错误重试 / 日志风暴
  • 锁竞争 / 自旋
  • GC 干扰

第 6 步:结合监控与业务背景交叉验证

  • 接口、任务、日志、GC、线程池、数据库、下游依赖一起看
  • 不要只靠单个工具做最后结论

十、一个典型例子:为什么某台实例 CPU 持续 95%,但不是 GC 问题

假设某台应用机器报警,CPU 持续 95% 以上,接口 RT 也明显抖动。

先看影响面

发现只有一台实例异常,其他实例基本正常。

这时候就不该优先怀疑“流量整体上涨”或“全链路共性问题”,而应该更怀疑单实例局部异常。

再看线程

top -Hp 12345

发现线程 23456 长时间占用最高。

转十六进制:

printf '%x\n' 23456

得到 5ba0

抓三次线程栈

连续三次 jstack 后,发现这个线程都稳定停在:

com.example.job.ReportTask.buildDailyReport

再回头看业务场景

代码里并没有死循环,但这个任务会把几十万行数据一次性查出,在内存里做聚合和去重,而且这台实例恰好承担了该任务。

于是链路就清楚了:

  • 不是全站流量问题
  • 不是 GC 主因
  • 不是宿主机资源争抢
  • 是批任务执行方式过重,单线程长期占满 CPU

这时候真正该做的不是“先调 JVM”,而是:

  • 拆批处理
  • 降低单次数据量
  • 调整任务并发策略
  • 必要时改为异步离线处理

十一、短期止血动作和长期治理动作要分开

定位到方向后,动作也要分层。

短期止血

适合现场先控影响:

  • 限流或摘流量
  • 暂停高风险任务
  • 关闭激进重试
  • 回滚最近可疑变更
  • 隔离异常实例
  • 必要时临时扩容,但把它当止血,不当结论

长期治理

适合故障后补工程能力:

  • 给热点方法、线程池、GC 增加监控
  • 给重试、轮询、补偿链路加退避和限速
  • 把大批量任务改成拆批、异步、可中断
  • 对高风险路径做压测和容量边界验证
  • 沉淀团队排障 checklist,而不是只靠个人经验

十二、CPU 高时,现场最常冒出来的几个问题

1. Java 服务 CPU 高一定是 GC 导致的吗?

不一定,而且很多时候不是。

更常见的是业务热点、空轮询、计算过重、日志风暴、重试风暴。GC 只是其中一类方向。

2. CPU 高时应该先看日志还是先看线程?

我更建议先看线程。

因为日志量在 CPU 高场景里经常会变成噪音,先把问题压缩到具体线程和调用栈,收敛速度通常更快。

3. 单实例 CPU 高和整组 CPU 高,排查入口为什么不同?

因为两者代表的故障范围不同。单实例高更像局部任务、脏数据、实例状态异常;整组高更像共同变更、下游放大、流量上涨或 GC 压力。先分清范围,后面的排查顺序才不会跑偏。

4. 只抓一次 jstack 够不够?

通常不够。

除非线程栈已经明显暴露问题,否则最好连续抓 2 到 3 次,对比是否稳定重复。

5. CPU 高时要不要立刻重启?

除非影响已经不可接受且你明确知道是止血动作,否则不要把重启当排查动作。

重启会丢现场,也会把真正证据清掉。

十三、CPU 压住以后,我会顺手补哪几条证据

CPU 这条线收窄后,下一步就按现场还剩下的伴随症状继续补证据:

十四、最后总结:CPU 排查最关键的不是会多少命令,而是别跳步骤

Java 服务 CPU 高时,真正高效的排查不是“赶快翻代码”,而是按顺序把现场一点点压缩:

  • 先看影响面
  • 再确认进程
  • 再定位线程
  • 再连续抓栈
  • 再给线程栈做分类
  • 最后回到代码和业务背景验证

所以最值得记住的,不是哪一个命令最厉害,而是这条顺序:

先看范围,再找线程,再分类根因,最后用监控、日志和代码把证据链补完整。

把这条顺序走熟,CPU 问题就不会再显得那么乱。