线上 Java CPU 飙高怎么定位?从 `top`、线程 ID 到 `jstack` 的完整实战
线上服务 CPU 突然飙高时,第一反应往往是登录机器执行 top。但仅仅找到高 CPU 的 Java 进程还不够,真正关键的是继续定位到:
- 哪个 Java 进程占用了 CPU;
- 该进程中的哪个线程在持续消耗 CPU;
- 该线程对应哪段 Java 代码;
- 到底是业务死循环、频繁 GC、锁竞争,还是 native 层问题。
本文以 Linux 生产环境为主,完整演示从 top 到 jstack 的排查链路,并提供一个不会明显影响笔记本的本地模拟程序。
一、先明确:CPU 高不一定是业务代码的问题
Java 服务 CPU 高,常见原因包括:
- 死循环、递归未退出、错误的重试逻辑;
- 大量数据计算、排序、JSON 序列化或复杂正则;
- 高并发请求导致业务计算放大;
- Full GC 或频繁 Young GC;
- 线程池配置不当,任务堆积后持续抢占 CPU;
- 加解密、压缩、网络编解码等计算密集型操作;
- JNI、第三方 native 库或操作系统层面的消耗。
因此,排查的目标不是“CPU 降下来”,而是找到持续消耗 CPU 的执行路径。
二、完整排查流程
text
CPU 告警
↓
找到高 CPU 的 Java 进程 PID
↓
找到该进程中 CPU 最高的线程 TID
↓
把十进制 TID 转为十六进制
↓
在 jstack / jcmd 输出中匹配 nid
↓
连续抓取多次线程栈,确认热点是否稳定
↓
定位代码或 JVM 问题并修复
以下命令默认在 Linux 上执行。
三、第一步:找到 CPU 最高的 Java 进程
使用 top:
bash
top -c
-c 会展示完整启动命令,方便确认是不是目标 Java 服务。
也可以使用 ps,更适合复制和脚本化:
bash
ps -eo pid,ppid,pcpu,pmem,cmd --sort=-pcpu | head -20
输出示例:
text
PID PPID %CPU %MEM CMD
12345 1 385.2 12.1 java -jar order-service.jar
假设目标 Java 进程的 PID 是:
bash
PID=12345
这里的 385.2% 并不代表异常。多核机器上,一个进程可以同时占用多个 CPU 核,因此 CPU 使用率可能超过 100%。
四、第二步:找到进程内 CPU 最高的线程
执行:
bash
top -H -p "$PID"
参数说明:
-H:显示线程;-p:只看指定进程。
输出中,线程列表可能类似:
text
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
67890 app 20 0 10.2g 1.5g 30000 R 99.7 10.1 3:20.31 java
67891 app 20 0 10.2g 1.5g 30000 S 0.3 10.1 0:01.22 java
第一列的 67890 就是 CPU 最高线程的 TID。
也可以使用 ps 查看并排序:
bash
ps -L -p "$PID" -o pid,tid,pcpu,comm --sort=-pcpu
输出示例:
text
PID TID %CPU COMMAND
12345 67890 99.7 java
12345 67891 0.3 java
为了后续复用,记录这个线程 ID:
bash
TID=67890
五、第三步:为什么要把线程 ID 转为十六进制?
这里是很多排查教程最容易让人困惑的地方。
top -H 或 ps -L 输出的线程 ID 是十进制,例如:
text
67890
而 jstack 输出中用于关联操作系统线程的 nid 是十六进制,例如:
text
nid=0x10932
因此需要转换:
bash
printf '0x%x\n' "$TID"
示例:
bash
TID=67890
printf '0x%x\n' "$TID"
输出:
text
0x10932
保存为变量:
bash
NID=$(printf '0x%x' "$TID")
echo "$NID"
注意:这里匹配的是 nid,不是 Java 中 Thread.currentThread().getId() 返回的线程 ID。Java 线程 ID、Linux 线程 ID 和线程栈中的 nid 并不是同一套编号体系。
六、第四步:导出线程栈并匹配热点线程
传统命令是:
bash
jstack -l "$PID" > "/tmp/jstack-$PID.log"
更推荐使用 jcmd:
bash
jcmd "$PID" Thread.print -l > "/tmp/jstack-$PID.log"
其中:
Thread.print:输出全部线程栈;-l:额外输出锁信息,对排查锁竞争很有帮助。
然后搜索刚才转换出的十六进制 nid:
bash
rg -n -C 12 "nid=$NID" "/tmp/jstack-$PID.log"
如果机器没有安装 rg,可以使用 grep:
bash
grep -n -A 12 -B 4 "nid=$NID" "/tmp/jstack-$PID.log"
可能看到这样的结果:
text
"cpu-hot-demo" #21 prio=5 os_prio=0 cpu=58234.12ms elapsed=60.03s tid=0x00007f... nid=0x10932 runnable
java.lang.Thread.State: RUNNABLE
at com.example.OrderService.calculate(OrderService.java:86)
at com.example.OrderService.handleRequest(OrderService.java:52)
at com.example.OrderController.query(OrderController.java:31)
重点关注:
- 线程状态是否为
RUNNABLE; - 栈顶方法是什么;
- 是否停留在自己的业务代码;
- 是否出现大量重复的递归、循环、序列化、正则、集合计算或 RPC 重试逻辑。
jcmd Thread.print 是 JDK 官方提供的 JVM 诊断命令,线程较多时会有一定开销,因此生产环境不建议高频、无限制执行。 Oracle jcmd 文档
七、不要只抓一次:连续抓 3 次线程栈
单次线程栈只能说明“这一瞬间线程在做什么”,不能证明它就是持续消耗 CPU 的原因。
推荐连续抓取 3 次,每次间隔 5 秒:
bash
for i in 1 2 3; do
printf '\n===== %s =====\n' "$(date '+%F %T')"
jcmd "$PID" Thread.print -l
sleep 5
done > "/tmp/thread-dump-$PID-$(date +%s).log"
如果同一个高 CPU 线程,在连续多份线程栈中都停留在同一段代码,例如:
text
OrderService.calculate()
OrderService.calculate()
OrderService.calculate()
那么基本可以确认问题就在这段调用链中。
如果每次线程栈都不同,可能是高并发请求导致 CPU 整体升高,而不是某一处代码进入死循环。
八、只查看匹配线程的前 20 行
有时日志很大,只想快速展示某个线程后的前 20 行,可以使用:
bash
jcmd "$PID" Thread.print -l |
awk -v nid="$NID" '
index($0, "nid=" nid) { printing=1; count=0 }
printing { print; count++ }
printing && count >= 20 { exit }
'
这里的“20 条”是指线程栈输出的前 20 行。
需要注意,jstack 并不存在“最近 20 条调用栈”的概念。更推荐的方式是抓取 3~5 次完整线程栈,确认同一个热点方法是否持续出现。
九、本地如何安全模拟 CPU 飙高?
不需要安装完整虚拟机。
最简单的方案是运行一个只占用一个逻辑 CPU、并且会自动停止的 Java 程序。它会让一个核心短时间处于高负载,但不会把整台笔记本跑满。
创建文件 CpuSpikeDemo.java:
java
public class CpuSpikeDemo {
private static volatile double sink;
public static void main(String[] args) throws InterruptedException {
int seconds = args.length > 0 ? Integer.parseInt(args[0]) : 60;
long deadline = System.nanoTime() + seconds * 1_000_000_000L;
Thread hotThread = new Thread(() -> {
double value = 1.0;
while (System.nanoTime() < deadline) {
value += Math.sqrt(value + 1.0);
if (value > 1_000_000_000_000D) {
value = 1.0;
}
sink = value;
}
}, "cpu-hot-demo");
hotThread.start();
hotThread.join();
}
}
编译并运行 60 秒:
bash
javac CpuSpikeDemo.java
java CpuSpikeDemo 60 &
PID=$!
echo "Java PID: $PID"
此时可以在另一个终端中执行:
bash
top -H -p "$PID"
找到高 CPU 线程后,再执行:
bash
TID=替换为实际线程ID
NID=$(printf '0x%x' "$TID")
jcmd "$PID" Thread.print -l | grep -A 12 -B 4 "nid=$NID"
正常情况下,你会看到线程名:
text
"cpu-hot-demo"
以及持续运行在 Math.sqrt 附近的调用栈。
程序默认 60 秒后自动结束。如果需要手动停止:
bash
kill "$PID"
十、macOS 本地调试说明
线上排查通常以 Linux 为准。macOS 可以运行上面的 Java 示例和 jcmd,但线程查看命令略有区别:
bash
ps -M -p "$PID" -o pid,tid,pcpu,command
然后将高 CPU 的 tid 转为十六进制:
bash
printf '0x%x\n' 线程ID
再使用:
bash
jcmd "$PID" Thread.print -l
不同 JDK、不同 macOS 版本下,线程 ID 的显示方式可能略有差异。因此,博客中的“top -H 获取 TID,再转换为 nid”流程应明确标注为 Linux 生产环境方案。
十一、想严格限制本机资源,可以使用 Docker
如果担心本地模拟影响工作,可以用 Docker 限制 CPU 和内存:
bash
docker run --rm \
--cpus=0.25 \
--memory=256m \
--pids-limit=128 \
-v "$PWD":/app \
-w /app \
eclipse-temurin:21-jdk \
sh -c 'javac CpuSpikeDemo.java && java CpuSpikeDemo 60'
参数含义:
--cpus=0.25:最多使用四分之一个 CPU 核;--memory=256m:最多使用 256 MB 内存;--pids-limit=128:限制容器最多创建的进程和线程数;--rm:结束后自动清理容器。
macOS 上的 Docker Desktop 内部会使用轻量虚拟化,但不需要额外安装和维护传统虚拟机。
十二、除了 top + jstack,还有哪些排查手段?
1. pidstat:持续观察线程 CPU
如果机器安装了 sysstat,可以执行:
bash
pidstat -t -p "$PID" 1
它每秒输出一次线程级 CPU 使用率,适合观察热点线程是否稳定。
2. Arthas:快速找到最耗 CPU 的线程
Arthas 可以直接输出 CPU 最高线程的堆栈:
bash
thread -n 3
查看指定线程:
bash
thread 线程ID
它适合紧急线上排障,但生产环境 attach 前仍应遵守权限、审计和变更规范。
3. Java Flight Recorder(JFR)
JFR 适合分析一段时间内的 CPU、GC、锁竞争、线程状态和方法热点。
bash
jcmd "$PID" JFR.start name=cpu-demo settings=profile duration=60s filename=/tmp/cpu-demo.jfr
60 秒后可用 Java Mission Control 打开 .jfr 文件分析。
4. async-profiler
async-profiler 可以生成 CPU 火焰图,尤其适合定位“到底哪些方法占用 CPU 最多”。
它的优势是比单次 jstack 更接近真实的 CPU 时间分布。对于复杂问题,火焰图通常比人工阅读几十份线程栈更直观。
5. perf
如果怀疑 CPU 消耗在 JVM 外部、JNI、系统调用或 native 库中,可以使用:
bash
perf top -p "$PID"
或者:
bash
perf record -F 99 -p "$PID" -g -- sleep 30
perf report
这类命令通常需要额外权限,生产环境应提前确认可用性。
十三、常见问题与判断方向
如果热点线程栈顶是业务代码,优先检查:
while、for循环退出条件;- 递归是否可能无限调用;
- 是否存在失败后立即重试;
- 大集合排序、嵌套循环、全量扫描;
- 复杂正则表达式;
- 大对象 JSON 序列化或反序列化;
- 是否在请求线程中执行了不该同步执行的大任务。
如果线程栈中大量出现 GC 相关内容,优先检查:
- 堆内存是否过小;
- 是否存在对象创建过快;
- 是否出现内存泄漏;
- GC 日志中是否有频繁 Full GC;
- 缓存、集合或批处理逻辑是否持续制造垃圾对象。
如果 CPU 很高但 Java 栈不明显,重点考虑:
- JNI 或 native 库;
- 压缩、加密、网络库;
- 容器 CPU 限制导致的“看起来很高”;
- 其他进程抢占 CPU;
- 内核态 CPU 占比高。
十四、总结
定位 Java CPU 飙高的关键,不是只执行一次 top,而是建立完整关联:
bash
# 找高 CPU 进程
top -c
# 找该进程高 CPU 线程
top -H -p PID
# 十进制 TID 转十六进制 nid
printf '0x%x\n' TID
# 导出线程栈
jcmd PID Thread.print -l > /tmp/jstack.log
# 根据 nid 查找对应 Java 栈
grep -A 12 -B 4 "nid=0x十六进制线程ID" /tmp/jstack.log
最后一定要连续抓取多次线程栈,并结合 GC、线程池、监控指标和业务日志交叉判断。只有确认热点调用栈持续稳定地出现,才能把问题准确归因到具体代码。