Java CPU 高负载排查实战:top -H、jstack、jcmd 与线程栈分析
前言
Java 服务出现 CPU 高负载时,最常见的排查思路是:先找到 CPU 占用高的 Java 进程,再找到该进程里 CPU 占用高的线程,最后把 Linux 线程 ID 转成 JVM 线程栈里的 nid,通过线程栈判断是哪段代码在消耗 CPU。
这篇文章整理一套可复用的 Java CPU 高负载排查 SOP,覆盖:
top找高 CPU Java 进程;top -H或ps -L找高 CPU 线程;- 线程 ID 转 16 进制;
jstack -l和jcmd Thread.print -l抓线程栈;- 如何判断线程栈是否真的异常;
- Netty EventLoop、GC、死循环、正则回溯等常见场景;
- 容器环境下的注意事项;
- 一键采样脚本模板。
如果你需要更多 Linux 排查命令,可以参考:Linux 常用命令速查:磁盘、日志、进程、网络、Docker 与文件排查实践。如果 CPU 高和系统参数、连接数、队列有关,也可以参考:Linux 内核与网络参数调优:sysctl、文件描述符、TCP 队列与高并发配置。
jstack 和 jcmd 能解决什么问题
jstack 是 JDK 自带的线程栈诊断工具,可以输出 Java 进程当前时刻的线程快照,包括线程名、线程状态、nid、调用栈和锁信息。
线程快照适合排查:
| 问题 | 说明 |
|---|---|
| CPU 高 | 找到持续 RUNNABLE 且堆栈稳定停留在热点代码的线程。 |
| 死锁 | 查看线程互相持有和等待锁的情况。 |
| 线程阻塞 | 观察大量线程是否卡在锁、IO、数据库、HTTP 调用等位置。 |
| 线程池耗尽 | 观察业务线程池是否堆积在同类调用栈。 |
| Netty / Tomcat / Dubbo 等框架问题 | 观察 IO 线程、业务线程、调度线程的状态。 |
新版本 JDK 中,jcmd 是更统一的诊断入口,可以使用:
jcmd <pid> Thread.print -l
传统方式则是:
jstack -l <pid>
两者都能用于线程栈分析。线上排查时,建议优先使用当前 JDK 自带工具,并确认执行用户和 Java 进程用户一致。
排查前准备
建议先确认以下信息:
# Java 进程
jps -l
ps -ef | grep java
# JDK 工具是否存在
which jstack
which jcmd
java -version
# 系统负载
uptime
top
注意事项:
- 执行
jstack/jcmd的用户最好和 Java 进程用户一致; - 如果是容器环境,要进入容器内部或确认宿主机能看到目标 PID;
- CPU 高问题通常需要连续采样,单次线程栈容易误判;
- 线程栈可能包含业务类名、SQL、URL、内部包名,外发前要脱敏。
步骤一:用 top 找 CPU 高的 Java 进程
输入命令:
top
示例输出:
Tasks: 3 total, 1 running, 2 sleeping, 0 stopped, 0 zombie
%Cpu(s): 2.1 us, 1.1 sy, 0.0 ni, 93.7 id, 2.9 wa, 0.0 hi, 0.3 si, 0.0 st
KiB Mem : 32675864 total, 10846008 free, 2076000 used, 19753856 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 30297432 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1 root 20 0 2705876 216868 13292 S 7.6 0.7 65:31.27 java
1238 root 20 0 15120 3440 3060 S 0.0 0.0 0:00.01 bash
1252 root 20 0 59488 4404 3788 R 0.0 0.0 0:00.00 top
这里 Java 进程 PID 是 1。
如果有多个 Java 进程,可以用:
jps -l
ps -eo pid,ppid,user,%cpu,%mem,cmd --sort=-%cpu | head
步骤二:用 top -H 找 CPU 高的线程
查看某个进程内的线程:
top -H -p 1
示例输出:
top - 09:44:41 up 8 days, 14 min, 0 users, load average: 0.65, 0.82, 0.99
Threads: 110 total, 0 running, 110 sleeping, 0 stopped, 0 zombie
%Cpu(s): 2.5 us, 1.6 sy, 0.0 ni, 94.3 id, 1.6 wa, 0.0 hi, 0.0 si, 0.0 st
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
58 root 20 0 2705876 216868 13292 S 6.7 0.7 4:11.22 java
59 root 20 0 2705876 216868 13292 S 6.7 0.7 6:40.24 java
111 root 20 0 2705876 216868 13292 S 6.7 0.7 6:03.95 java
这里 58、59、111 是 Linux 线程 ID,也就是后面要在 JVM 线程栈中匹配的 nid。
也可以用批处理方式查看线程:
top -H -b -n 1 -p 1 | head -n 30
或者使用 ps:
ps -L -p 1 -o pid,tid,pcpu,pmem,comm --sort=-pcpu | head
其中 tid 就是线程 ID。
步骤三:线程 ID 转十六进制 nid
JVM 线程栈中的本地线程 ID 使用十六进制显示,例如 nid=0x3a。
将 Linux 线程 ID 转为十六进制:
printf "0x%x\n" 58
输出:
0x3a
如果要批量转换:
for tid in 58 59 111; do
printf "%s -> 0x%x\n" "$tid" "$tid"
done
输出示例:
58 -> 0x3a
59 -> 0x3b
111 -> 0x6f
步骤四:用 jstack 定位线程栈
抓取线程栈:
jstack -l 1 > jstack-$(date +%F-%H%M%S).log
查找对应 nid:
grep -A 50 "nid=0x3a" jstack-*.log
也可以直接:
jstack -l 1 | grep -A 50 "nid=0x3a"
示例:
"nioEventLoopGroup-4-1" #48 prio=10 os_prio=0 tid=0x5b320000 nid=0x3a runnable [0x56e5b000]
java.lang.Thread.State: RUNNABLE
at sun.nio.ch.EPollArrayWrapper.epollWait(Native Method)
at sun.nio.ch.EPollArrayWrapper.poll(EPollArrayWrapper.java:269)
at sun.nio.ch.EPollSelectorImpl.doSelect(EPollSelectorImpl.java:93)
at sun.nio.ch.SelectorImpl.lockAndDoSelect(SelectorImpl.java:86)
- locked <0x68c05ab8> (a io.netty.channel.nio.SelectedSelectionKeySet)
- locked <0x68c05ac8> (a java.util.Collections$UnmodifiableSet)
- locked <0x68c05a78> (a sun.nio.ch.EPollSelectorImpl)
at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:97)
at io.netty.channel.nio.NioEventLoop.select(NioEventLoop.java:805)
at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:457)
at java.lang.Thread.run(Thread.java:748)
这里要特别注意:看到 nid=0x3a 只能说明找到了对应线程,不能直接说明它就是问题根因。
例如上面的 Netty EventLoop 停在 epollWait,很多时候是在等待 IO 事件,可能是正常状态。是否异常,需要结合连续采样、CPU 时间变化和业务现象判断。
步骤五:用 jcmd Thread.print 抓线程栈
新版本 JDK 推荐使用 jcmd 作为统一诊断入口:
jcmd 1 Thread.print -l > thread-$(date +%F-%H%M%S).log
查找对应线程:
grep -A 50 "nid=0x3a" thread-*.log
jcmd 还可以查看该进程支持的诊断命令:
jcmd 1 help
常见对比:
| 工具 | 用途 |
|---|---|
jstack -l <pid> |
经典线程栈工具,适合快速抓取 Java 线程快照。 |
jcmd <pid> Thread.print -l |
新版本 JDK 常用诊断入口,也能输出线程栈和锁信息。 |
jcmd <pid> GC.heap_info |
查看堆信息,辅助判断是否 GC 压力导致 CPU 高。 |
jcmd <pid> JFR.start |
开启 JFR,适合持续性能问题分析。 |
为什么要连续采样
CPU 高负载问题常常是动态的。单次线程栈可能刚好抓到线程处于 IO 等待、锁等待或正常调度点,容易误判。
建议连续抓 3~5 次,每次间隔 5~10 秒:
PID=1
for i in 1 2 3 4 5; do
now=$(date +%F-%H%M%S)
top -H -b -n 1 -p $PID | head -n 40 > top-$now.log
jcmd $PID Thread.print -l > thread-$now.log
sleep 5
done
判断原则:
- 同一个线程 ID 是否持续出现在 CPU 排名前列;
- 同一个
nid的线程栈是否多次停留在同一段业务代码; - 线程状态是否持续为
RUNNABLE; - CPU 时间是否持续增长;
- 是否和业务流量、慢 SQL、GC、外部调用异常同一时间出现。
如何判断线程栈是否异常
常见可疑堆栈
| 堆栈特征 | 可能原因 |
|---|---|
| 持续停留在业务循环代码 | 死循环、循环条件错误、集合遍历过大。 |
| 持续停留在正则相关方法 | 正则回溯严重。 |
| 持续停留在 JSON 序列化 / 反序列化 | 大对象、循环引用、字段过多。 |
| 持续停留在加解密 / 压缩 / 编码 | CPU 密集型任务。 |
| 大量线程争抢同一锁 | 锁竞争、同步块过大。 |
| GC 线程 CPU 高 | 可能是内存压力、频繁 Full GC。 |
| 业务线程持续 RUNNABLE 且栈不变 | 高概率热点代码或死循环。 |
不一定异常的堆栈
| 堆栈 | 说明 |
|---|---|
epollWait / socketRead |
可能是正常 IO 等待,要结合 CPU 时间判断。 |
Unsafe.park / LockSupport.park |
线程池空闲或等待任务时很常见。 |
Object.wait |
等待条件或队列消息,不一定是问题。 |
Thread.sleep |
定时任务或重试等待中常见。 |
所以排查时不能只看一次 RUNNABLE,更要看“是否持续消耗 CPU”。
常见高 CPU 场景
1. 死循环
典型特征:同一线程持续高 CPU,堆栈稳定停在某个业务方法或循环逻辑。
处理建议:
- 检查循环退出条件;
- 检查数据是否异常导致循环无法结束;
- 增加最大迭代次数或保护条件;
- 补充单元测试覆盖边界条件。
2. 正则回溯
典型特征:堆栈停留在 java.util.regex.Pattern 相关方法。
处理建议:
- 避免灾难性回溯的正则;
- 对输入长度做限制;
- 使用更明确的匹配规则;
- 对高风险正则做压测。
3. JSON / XML 大对象处理
典型特征:堆栈停留在 Jackson、Fastjson、Gson、JAXB 等序列化框架。
处理建议:
- 控制对象大小;
- 避免循环引用;
- 减少无用字段;
- 对大对象处理做异步化或分页。
4. GC 导致 CPU 高
如果多个 GC 线程占用 CPU,或者业务线程频繁分配对象,需要结合 GC 日志和堆信息判断。
可以执行:
jcmd 1 GC.heap_info
jcmd 1 GC.class_histogram | head -n 30
也可以查看 GC 日志、JFR 或监控平台中的 Young GC / Full GC 次数和耗时。
5. 线程池任务堆积
如果大量业务线程堆栈类似,可能是线程池中任务执行慢、下游接口慢、数据库慢或锁竞争。
建议结合:
- 线程池活跃线程数;
- 队列长度;
- 拒绝次数;
- 下游接口耗时;
- 慢 SQL;
- 日志中的 timeout / retry。
容器环境注意事项
容器中排查 Java CPU 高有几个坑:
- 容器内 PID 可能是
1,宿主机看到的是另一个 PID; top -H -p <pid>要在能看到目标线程的命名空间里执行;- 容器镜像可能只有 JRE,没有
jstack/jcmd; - 如果工具不在容器里,可以使用带 JDK 的 debug 镜像或进入宿主机排查;
- Kubernetes 中要注意 CPU limit,容器被限流时表现可能和宿主机 CPU 高不同。
进入容器后排查:
docker exec -it <container> bash
jps -l
top -H -p 1
jcmd 1 Thread.print -l
Kubernetes 场景:
kubectl exec -it <pod> -- bash
kubectl top pod <pod>
如果镜像不包含诊断工具,建议在构建基础镜像时保留必要的 JDK 工具,或者准备专门的 debug 方案。
一键采样脚本
下面脚本适合快速保存现场:
#!/usr/bin/env bash
set -euo pipefail
PID=${1:?"Usage: $0 <java-pid>"}
OUT_DIR="cpu-dump-${PID}-$(date +%F-%H%M%S)"
mkdir -p "$OUT_DIR"
for i in 1 2 3 4 5; do
now=$(date +%F-%H%M%S)
echo "sampling $i at $now"
top -H -b -n 1 -p "$PID" | head -n 50 > "$OUT_DIR/top-$now.log"
if command -v jcmd >/dev/null 2>&1; then
jcmd "$PID" Thread.print -l > "$OUT_DIR/thread-$now.log" || true
jcmd "$PID" GC.heap_info > "$OUT_DIR/heap-$now.log" || true
elif command -v jstack >/dev/null 2>&1; then
jstack -l "$PID" > "$OUT_DIR/thread-$now.log" || true
else
echo "jcmd/jstack not found" > "$OUT_DIR/error-$now.log"
fi
sleep 5
done
tar -czf "$OUT_DIR.tar.gz" "$OUT_DIR"
echo "saved: $OUT_DIR.tar.gz"
使用方式:
bash collect-java-cpu.sh 1
采样完成后,重点对比每次 top-*.log 中 CPU 高的线程 ID,再到 thread-*.log 里找对应 nid=0x...。
排查流程总结
Java CPU 高负载排查可以按下面流程执行:
1. top 找 CPU 高的 Java 进程 PID
2. top -H -p PID 找 CPU 高的线程 TID
3. printf "0x%x\n" TID 转成十六进制 nid
4. jstack -l PID 或 jcmd PID Thread.print -l 抓线程栈
5. grep "nid=0x..." 定位线程
6. 连续采样 3~5 次
7. 判断线程栈是否稳定停在业务热点代码
8. 结合 GC、日志、慢 SQL、下游调用和监控确认根因
最重要的一点是:找到线程不等于找到根因。必须结合连续采样和业务上下文判断线程是否真的持续消耗 CPU。