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 这一层,把注意力放到真正的业务代码上。

查看「Java 与 JVM」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。