Java 线上排查工具链:从 CPU 100% 定位到方法行
CPU 高、内存涨、线程堆积、接口慢,四类现象各对应不同的第一条命令。jstack、jcmd、Arthas 的完整用法与判读方法。
线上出现 CPU 100%、接口变慢、内存持续上涨,这三类问题排查时用的工具完全不一样。但很多人手上只有 jstack 和 jmap,遇到 CPU 问题就去 dump 堆,看到 3 GB 的 hprof 文件打不开就放弃了。
这篇文章按「现象 → 工具 → 命令 → 判读」的顺序,把 Java 线上排查的完整工具链梳理一遍。所有命令都基于 JDK 自带工具和 Arthas,不需要额外装东西。
一、先建立一张对照表
| 现象 | 首选工具 | 一条命令定位 |
|---|---|---|
| CPU 高 | top -Hp + jstack |
找最忙的线程做方法栈 |
| 内存涨 | jcmd + MAT |
堆 dump 看 Dominator Tree |
| 线程池满 | jstack |
统计线程状态分布 |
| 接口慢 | Arthas trace |
方法级耗时 |
| GC 频繁 | jstat / GC 日志 |
看各代使用率 |
| 死锁 | jstack |
直接输出死锁报告 |
| 类冲突 | jcmd VM.classloaders |
看同名类加载来源 |
先把 jcmd 记住。JDK 7u40 之后它整合了大部分诊断命令,一条入口:
jcmd -l # 列出本机所有 JVM 进程
jcmd 1 help # 列出该进程支持的所有命令
二、CPU 100%:五步定位到方法行
这是最经典也最高效的一套流程。
第 1 步:找到最吃 CPU 的线程
top -Hp 1 -b -n1 | head -20
PID USER PR NI VIRT RES %CPU %MEM TIME+ COMMAND
28471 app 20 0 8912456 2.1g 98.7 26.4 1842:31 java
28498 app 20 0 8912456 2.1g 61.2 26.4 980:12 java
28501 app 20 0 8912456 2.1g 38.4 26.4 612:45 java
-H 把线程也展开显示,-p 1 只看 PID 1。这里的 28498、28501 是线程 ID(十进制)。
第 2 步:转成十六进制
jstack 输出里的 nid 是十六进制:
printf '%x\n' 28498
# 6f52
第 3 步:抓栈并过滤
jstack 1 > /tmp/ts.txt
grep -A 40 'nid=0x6f52' /tmp/ts.txt
输出:
"http-nio-8080-exec-12" #142 daemon prio=5 os_prio=0 tid=0x... nid=0x6f52 runnable
java.lang.Thread.State: RUNNABLE
at java.util.regex.Pattern$Loop.match(Pattern.java:4849)
at java.util.regex.Pattern$GroupHead.match(Pattern.java:4750)
...
at com.example.filter.HtmlSanitizer.clean(HtmlSanitizer.java:87)
at com.example.filter.XssFilter.doFilter(XssFilter.java:42)
一眼定位:XSS 过滤器里的正则回溯爆炸。改成预编译 + 简化正则可解决。
第 4 步:多次采样确认
单次采样可能是偶然。连续抓三次(间隔 2 秒),如果同一个方法反复出现,基本可以确定:
for i in 1 2 3; do
jstack 1 | grep -A 5 "nid=0x$(printf '%x' $TID)" | grep 'at com\.'
sleep 2
done
第 5 步:一起看多个线程
如果 top -Hp 显示 5 个线程都在 40% 以上,说明是多个线程跑同一个热点方法,那问题在方法本身(比如死循环、正则、序列化),不是锁。
如果只有一个线程高,其他都低,可能是单线程处理所有请求——查是不是用了单线程的 ExecutorService,或者 @Scheduled 里做重活。
三、内存问题:三个层次
第 1 层:先看趋势,不看快照
jstat -gcutil 1 2000 10
关注 O(老年代)列。10 次采样(20 秒)里如果持续上涨且不回落,就是有问题。
第 2 层:分代统计
jcmd 1 GC.class_histogram | head -25
输出按实例数量排序的类。要按占用字节排:
jcmd 1 GC.class_histogram | sort -k3 -rn | head -20
典型判读:
| 占用最大的类 | 可能原因 |
|---|---|
byte[] / char[] |
大对象、批量查询未分页、文件读写 |
java.lang.String |
字符串拼接、日志、缓存 key |
| 业务实体类 | 集合里有引用未清 |
java.util.HashMap$Node |
Map 无界增长 |
java.lang.reflect.Method |
动态代理过多(Spring 常见) |
com.mysql.cj.jdbc.* |
连接未关闭 |
第 3 层:堆 dump 找引用链
jcmd 1 GC.heap_dump /tmp/heap.hprof
注意:dump 会 STW,大堆可能停几秒到几十秒。 生产环境先摘流量再 dump,或者用 -XX:+HeapDumpOnOutOfMemoryError 让它在 OOM 时自动抓。
另一种方式是不 STOP 的 live dump:
jmap -dump:live,format=b,file=/tmp/heap.hprof 1
live 会先触发一次 Full GC,只 dump 存活对象,文件小很多,但同样会 STW。
堆 dump 拉到本地用 MAT 打开,只看两个视图:
- Dominator Tree:按「如果这个对象被回收能释放多少内存」排序,找第一个大块
- Path to GC Roots(排除弱引用/软引用):看谁在引用它
四、线程问题:jstack 的正确读法
jstack 的输出信息密度很高,先学会看状态分布。
jstack 1 > /tmp/ts.txt
# 各状态线程数
for s in RUNNABLE BLOCKED WAITING TIMED_WAITING; do
printf '%-16s %s\n' $s $(grep -c "java.lang.Thread.State: $s" /tmp/ts.txt)
done
判读规则:
| 状态分布 | 含义 |
|---|---|
BLOCKED 很多且都卡在同一把锁 |
锁竞争,找 waiting to lock 的对象 |
WAITING 很多 |
在 park() / wait(),通常是队列空或线程池闲置,正常 |
RUNNABLE 多且 CPU 不高 |
在做 IO(网络读),正常 |
TIMED_WAITING 且堆栈都在 socketRead0 |
下游超时,查下游 |
WAITING 多不代表有问题。空闲的线程池线程就是 WAITING 在 take() 上。真正的异常是:
# 统计同一位置的线程数(超过阈值说明堆积)
grep -A 3 'java.lang.Thread.State' /tmp/ts.txt \
| grep 'at com\.example' \
| sort | uniq -c | sort -rn | head -10
如果 at com.example.dao.XxxMapper.select 出现 180 次,说明 180 个线程都卡在这个查询上——下游 DB 有问题,不是 JVM 的问题。
死锁会自动报告:
jstack 1 | sed -n '/Found one Java-level deadlock/,/^$/p'
Found one Java-level deadlock:
=============================
"thread-A":
waiting to lock monitor 0x00007f... (object 0x000000076b2a3c08, a com.example.Account),
which is held by "thread-B"
"thread-B":
waiting to lock monitor 0x00007f... (object 0x000000076b2a3d18, a com.example.Account),
which is held by "thread-A"
两把锁反向加锁,经典 ABBA 死锁。
五、Arthas:把「改代码才能加日志」这件事省掉
Arthas 最大的价值是线上热诊断,不用重启。
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar # 选择目标进程编号
五个最常用的命令:
1. trace — 找慢在哪一层
trace com.example.OrderService getOrder '#cost > 200'
只打印耗时超过 200ms 的调用,并展示每个子调用的耗时占比。输出里一眼看出是 DB(selectById 800ms)还是 Redis(get 3ms)。
2. watch — 看入参和返回值
watch com.example.OrderService getOrder '{params, returnObj, throwExp}' -x 2
排查「返回了错误数据」类问题时,这是最快的手段——能看到每次调用的真实入参。
3. tt — 记录所有调用,事后回放
tt -t com.example.OrderService getOrder -n 20
tt -i 1003 -p # 重放第 1003 次调用,看完整细节
特别适合「偶发失败、日志又没打全」的场景。
4. jad — 反编译线上 class
jad com.example.OrderService
用来确认「线上跑的代码版本和我本地一致吗」。这个用得非常频繁——排查半天发现线上是旧版本的事情经常发生。
5. thread — 比 jstack 更好读的线程分析
thread # 最忙的线程(相当于 top -Hp + jstack 合体)
thread -n 5 # CPU 最高的 5 个线程栈
thread -b # 找阻塞其他线程的那一个(一键找锁瓶颈)
thread -s # 处于 RUNNABLE 的线程
thread -b 特别有用,它直接告诉你「谁抢到锁不放,卡了多少线程」。
注意事项:watch / trace 用的是字节码增强(Instrumentation),会带来轻微性能开销。在高 QPS 方法上开 trace 要小心,配合 -n 限制次数。诊断完记得 stop 退出,Arthas 会自己撤销增强。
六、其他几个救命命令
1. 看 JVM 启动参数(常用来发现「线上到底配了什么」)
jcmd 1 VM.flags
jcmd 1 VM.system_properties
排查问题时经常需要确认「线上 -Xmx 是多少」「spring.profiles.active 是什么」。
2. 查类从哪加载的(ClassNotFoundException / NoSuchMethodError 必备)
jcmd 1 VM.classloaders
# 或
jcmd 1 VM.class_hierarchy com.example.OrderService
遇到 NoSuchMethodError 通常是 jar 冲突:
# 找同一个类在几个 jar 里
find /app -name '*.jar' -exec sh -c \
'unzip -l "$1" 2>/dev/null | grep -q "com/example/OrderService.class" && echo "$1"' _ {} \;
3. 强制 Full GC(用于区分「泄漏」还是「垃圾太多」)
jcmd 1 GC.run
jstat -gcutil 1 1000 3
GC 后老年代掉到 20% 以下 → 没泄漏;只掉到 85% → 有泄漏。
4. 看到底在跑什么(jstack 里全是 lambda 时)
jcmd 1 VM.command_line
jcmd 1 PrintClassHistogram # 相当于 GC.class_histogram
七、一个完整的排查脚本
把这些串起来,做成一个一键采集脚本,出事时直接跑:
#!/bin/bash
# diag.sh <pid> <output_dir>
PID=${1:-1}
OUT=${2:-/tmp/diag-$(date +%Y%m%d-%H%M%S)}
mkdir -p "$OUT"
echo "[1/7] 基本信息"
jcmd "$PID" VM.command_line > "$OUT/cmdline.txt" 2>&1
jcmd "$PID" VM.system_properties > "$OUT/sysprops.txt" 2>&1
echo "[2/7] GC 状态"
jstat -gcutil "$PID" 1000 10 > "$OUT/gcutil.txt" 2>&1
jcmd "$PID" GC.heap_info > "$OUT/heap.txt" 2>&1
echo "[3/7] 线程栈(3 次采样)"
for i in 1 2 3; do
jstack "$PID" > "$OUT/thread-$i.txt" 2>&1
sleep 2
done
echo "[4/7] 死锁检查"
jstack "$PID" | sed -n '/Found one Java-level deadlock/,/^$/p' > "$OUT/deadlock.txt"
echo "[5/7] CPU 最高的 5 个线程"
top -Hp "$PID" -b -n1 | head -15 > "$OUT/top.txt"
echo "[6/7] 对象统计"
jcmd "$PID" GC.class_histogram > "$OUT/histogram.txt" 2>&1
echo "[7/7] 打包"
tar czf "$OUT.tar.gz" -C "$(dirname "$OUT")" "$(basename "$OUT")"
echo "完成:$OUT.tar.gz ($(du -h "$OUT.tar.gz" | cut -f1))"
这个包大概 2~5 MB(不含堆 dump),可以直接拖下来分析或者传给同事。
注意不要把堆 dump 放进这个脚本——一个 8G 的堆 dump 会写到磁盘满,而且采集过程会 STW。堆 dump 单独按需触发。
八、容器环境下的两个坑
1. jstack 报「Unable to open socket file」
容器里以非 root 用户运行时,jstack 需要和目标进程同用户,且 /tmp 下要能创建 socket 文件。常见解决办法:
# 方式 1:同一个用户执行
su - app -c "jstack 1"
# 方式 2:加 -F 强制(会 STW,仅用于无法正常 attach 时)
jstack -F 1
# 方式 3:用 jcmd 代替(它走 attach API,更稳)
jcmd 1 Thread.print
2. PID 1 是 Java 时,kill -3 无效
容器里 Java 是 PID 1,内核对 PID 1 的信号处理有特殊规则——如果没有注册 handler,信号会被忽略。kill -3(SIGQUIT)本应打印线程栈到 stdout,但在 PID 1 上可能不生效。
替代方案:
jcmd 1 Thread.print
# 或者用 docker kill --signal=QUIT <container>
小结
排查 Java 线上问题的效率,取决于第一步选对工具:
| 症状 | 第一条命令 |
|---|---|
| CPU 高 | top -Hp <pid> → printf '%x' → jstack 过滤 |
| 内存涨 | jstat -gcutil 看趋势 → jcmd GC.class_histogram |
| 接口慢 | Arthas trace '#cost > 200' |
| 线程堆积 | jstack 统计 at com. 出现次数 |
| 死锁 | jstack \| grep -A 20 deadlock |
| 类冲突 | jcmd VM.classloaders |
最后一条经验:大部分「JVM 问题」最后都不是 JVM 问题。CPU 高可能是正则回溯,线程堆积可能是下游 DB 慢,内存涨可能是缓存无界。工具的作用是快速排除 JVM 这一层,把注意力放到真正的业务代码上。
正在加载评论…