Java 服务 CPU 高怎么排查:线上定位顺序别一上来就猜
当 Java 服务 CPU 飙高时,不要先猜 GC 或直接扩容。先分清是单实例还是整组实例,再按“进程 -> 线程 -> 线程栈 -> GC / 重试 / 热点代码”这条顺序收敛,才能更快判断问题是在计算热点、线程空转、下游放大,还是 GC 压力。
线上最容易把人带乱的告警之一,就是 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。” “这肯定是代码死循环。” “机器不够,先扩容。”
更稳妥的做法是先回答两个问题:
- 影响面有多大?
- CPU 真正消耗在哪个层级?
二、第一步先看影响面,而不是直接抓线程
很多人一看到 CPU 高,就马上 top -Hp、jstack。这不是不能做,但更高优先级的是先判断问题范围。
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 cpups -eo pid,ppid,cmd,%mem,%cpu --sort=-%cpu | headjps -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 这条线收窄后,下一步就按现场还剩下的伴随症状继续补证据:
- 还没建立接口慢的总分诊顺序:
接口响应慢怎么排查?后端 API 变慢与超时的定位步骤 - CPU 高同时伴随 Full GC 频繁:
Full GC 频繁怎么办:先判断是不是内存泄漏 - 线程池也在堆积,不确定是 CPU 打高还是排队先发生:
线程池打满以后,应该先查队列、拒绝策略还是慢任务? - 数据库连接和接口 RT 也一起变差:
数据库没打满,为什么 API 和连接池已经开始变慢? - 只想系统看懂 jstack / jmap / jstat 怎么配合:
线上问题排查时,jstack、jmap、jstat 分别怎么看 - 已经怀疑是内存和对象生命周期问题:
Java 内存泄漏怎么定位?从 Heap Dump 到 MAT 实战
十四、最后总结:CPU 排查最关键的不是会多少命令,而是别跳步骤
Java 服务 CPU 高时,真正高效的排查不是“赶快翻代码”,而是按顺序把现场一点点压缩:
- 先看影响面
- 再确认进程
- 再定位线程
- 再连续抓栈
- 再给线程栈做分类
- 最后回到代码和业务背景验证
所以最值得记住的,不是哪一个命令最厉害,而是这条顺序:
先看范围,再找线程,再分类根因,最后用监控、日志和代码把证据链补完整。
把这条顺序走熟,CPU 问题就不会再显得那么乱。