从 jps 到 jmap:JDK 诊断工具的一条排查路线

最近把 java-snippets 里的 JDK 诊断 Demo 补齐了,想按现场排查的顺序走一遍,而不是按字母表背命令。在这里记录一下。环境是 JDK 21。手册和源码都在仓库里1


一、先起一个常驻进程

排查的第一步是有一个还活着的 JVM。仓库里的「JvmToolkitDemo」只做一件事:后台线程不断分配短生命周期的 byte[],不往集合里塞,用来触发 Young GC。

启动如下:

$ mvn -q compile -DskipTests
$ java -Xms64m -Xmx128m -XX:+UseG1GC \
    -Dtoolkit.demo.name=JvmToolkitDemo \
    -cp target/classes io.weli.concurrent.JvmToolkitDemo

控制台会打出 PID。分配循环在这里:

// src/main/java/io/weli/concurrent/JvmToolkitDemo.java: 42-54
private static void allocateYoungGarbage() {
    long round = 0;
    while (true) {
        for (int i = 0; i < YOUNG_ALLOCS_PER_ROUND; i++) {
            byte[] chunk = new byte[YOUNG_CHUNK_BYTES];
            allocationSink = chunk[0];
        }
        round++;
        if (round % 50 == 0) {
            System.out.printf("young-alloc round %d%n", round);
        }
        sleepQuietly(50);
    }
}

对应源码:JvmToolkitDemo.javaallocationSinkvolatile,避免 JIT 把分配整段消掉。

另开一个终端,后面的命令都打在那边。Demo 这个窗口不要关。


二、jps:先确认打的是这个进程

本机同时跑着好几个 Java 时,PID 抓错后面全白做。jps 只列 HotSpot 进程2

$ jps -l | grep JvmToolkitDemo
65237 io.weli.concurrent.JvmToolkitDemo

$ jps -lvm | grep JvmToolkitDemo
65237 io.weli.concurrent.JvmToolkitDemo -Xms64m -Xmx128m -XX:+UseG1GC -Dtoolkit.demo.name=JvmToolkitDemo

可以看到,-l 给出主类全名,-v 把这次真正带上的 -Xmx-D 也打出来。jps 自己会出现在列表里,忽略即可。


三、jcmd / jinfo:核对启动参数

不要靠记忆「当时是怎么启动的」。jcmd 是 JDK 7 起的统一入口3,先问这个进程支持哪些命令:

$ jcmd 65237 help

本机接着跑了这几条:

$ jcmd 65237 VM.command_line
$ jcmd 65237 VM.flags
$ jcmd 65237 VM.system_properties | grep toolkit.demo
$ jinfo -flag MaxHeapSize 65237

VM.command_line 的输出如下:

VM Arguments:
jvm_args: -Xms64m -Xmx128m -XX:+UseG1GC -Dtoolkit.demo.name=JvmToolkitDemo
java_command: io.weli.concurrent.JvmToolkitDemo
java_class_path (initial): target/classes

jinfo -flag MaxHeapSize 打出来是 134217728,也就是 128MB,和 -Xmx128m 对得上4。系统属性里能看到 toolkit.demo.name=JvmToolkitDemo

JDK 21 上我更常用 jcmd VM.*jinfo 还在,现场同事提起它时对照一下即可。


四、jstat:看接下来几十秒 GC 怎么走

参数核对完,下一个问题是:堆是在正常回收,还是 Old 只增不减。jstat 做周期性采样,不 dump 堆5

$ jstat -gcutil 65237 1000 5

本机 5 秒里的输出是:

  S0     S1     E      O      M     CCS    YGC     YGCT     FGC    FGCT     CGC    CGCT       GCT
     -  45.54  28.21   3.89  75.32  34.18      8     0.006     0     0.000     0     0.000     0.006
     -  44.15  30.77   3.89  75.32  34.18      9     0.007     0     0.000     0     0.000     0.007
     -  43.28  41.03   3.89  75.32  34.18     10     0.009     0     0.000     0     0.000     0.009
     -  42.22  43.59   3.89  75.32  34.18     11     0.010     0     0.000     0     0.000     0.010
     -  42.70  53.85   3.89  75.32  34.18     12     0.011     0     0.000     0     0.000     0.011

E(Eden)在涨,YGC 从 8 走到 12,O(Old)停在 3.89FGC 一直是 0。G1 下 S0 常显示为 -,以表头为准。

如上所示,这个 Demo 在分配,但对象活不过 Young 代。堆摘要可以用 jcmd 65237 GC.heap_info 再对一眼。


五、线程问题换 JstackTroubleDemo

CPU 高、卡住、怀疑死锁时,走 jstack(或 jcmd Thread.print6。先把 Toolkit Demo 停掉,换这个:

$ java -cp target/classes io.weli.concurrent.JstackTroubleDemo

它同时造了三类现场:交叉加锁、占着锁 sleep、空转循环。死锁那段如下:

// src/main/java/io/weli/concurrent/JstackTroubleDemo.java: 31-50
private static void startDeadlock() {
    Thread t1 = new Thread(() -> {
        synchronized (LOCK_A) {
            sleepQuietly(100);
            synchronized (LOCK_B) { ... }
        }
    }, "deadlock-thread-1");
    // thread-2 先 B 后 A
}

对应源码:JstackTroubleDemo.java

$ jstack <pid> > /tmp/jstack-demo.txt

输出末尾会出现 Found one Java-level deadlockblocked-threadBLOCKED,对面的 lock-holder 持锁在 sleepbusy-loop-threadRUNNABLE,而且 cpu= 远大于别的线程。连续抓三次、间隔几秒,栈顶不变就可以把忙等和正常短任务分开。

jcmd <pid> Thread.printjstack 等价。


六、内存问题换 JmapHeapDemo

OOM、堆持续涨,用 jmap 看直方图,必要时 dump7。这个 Demo 把 byte[]String 放进 static 集合,只增不减:

// src/main/java/io/weli/concurrent/JmapHeapDemo.java: 18-20
private static final List<byte[]> LEAKED_BUFFERS = new ArrayList<>();
private static final List<String> STRING_LEAK = new ArrayList<>();

对应源码:JmapHeapDemo.java

$ java -Xmx256m -cp target/classes io.weli.concurrent.JmapHeapDemo
$ jmap -histo <pid> | head -20

跑几轮之后,本机看到 [Bbyte[])和 java.lang.String 排在前面,实例数大约按轮次 × 1000 往上走。jmap -histo:live 会先做一次 Full GC 再统计:若 GC 之后这两类还很大,强引用泄漏的嫌疑就大了。

JDK 21 上 jmap -heap 经常直接报错,提示改用 jhsdb jmap。活进程用 jcmd <pid> GC.heap_info 就够。要离线分析:

$ jmap -dump:live,format=b,file=/tmp/jmap-heap-demo.hprof <pid>

dump 会 STW。Eclipse MAT 打开这份 .hprof,Leak Suspects 会指到 JmapHeapDemo 的 static 集合;再看 Path to GC Roots,引用链就是 LEAKED_BUFFERS / STRING_LEAK。仓库里有 MAT 手册和抓堆脚本1

和上一节的 Toolkit Demo 对照一下:

flowchart LR
    A["JvmToolkitDemo"] --> B["短生命周期 byte[]"]
    B --> C["YGC 升 / Old 不动"]
    D["JmapHeapDemo"] --> E["static List 只增不减"]
    E --> F["histo 里 B 和 String 持续涨"]

    style C fill:#90EE90,stroke:#333,stroke-width:2px
    style F fill:#FF6347,stroke:#333,stroke-width:2px

左边是正常 Young GC,右边才是泄漏形状。两个 Demo 不要同时 grep Demo 混 PID。


七、jcmd 把旧命令收拢

现场还是常听到 jstackjmapjinfo。JDK 21 上不少能力已经汇到 jcmd

旧命令 jcmd
jstack Thread.print
jmap -histo GC.class_histogram
jmap -heap GC.heap_info
jmap -dump GC.heap_dump /abs/path.hprof
jinfo -flags VM.flags
jinfo -sysprops VM.system_properties

jstat 没有完整等价物,连续采样还是用它。GC.heap_dump 要用绝对路径。

整条路线可以压成下面这张图:

flowchart TB
    JPS["jps -lvm 锁定 PID"] --> FLAG["jcmd VM.command_line / VM.flags"]
    FLAG --> STAT["jstat -gcutil 看趋势"]
    STAT --> Q{症状}
    Q -->|卡住 / CPU 高| ST["jstack / Thread.print"]
    Q -->|堆涨 / OOM| MP["jmap -histo"]
    MP --> DP["dump + MAT"]
    ST --> GUI["jconsole 本机看图"]
    DP --> GUI
    Q -->|attach 失败或 core| SA["jhsdb"]

    style JPS fill:#87CEEB,stroke:#333,stroke-width:2px
    style STAT fill:#90EE90,stroke:#333,stroke-width:2px
    style Q fill:#FFD700,stroke:#333,stroke-width:2px

八、jconsole 和 jhsdb

本机看实时曲线,把 PID 传给 jconsole 即可,不要为了练习打开无认证的远程 JMX8

$ jconsole 65237

Memory 页上 Eden 是锯齿,Threads 里能看到 young-allocator。JDK 21 不再自带 jvisualvm,深度分析还是 MAT。

jhsdb 是 Serviceability Agent,用来补 jmap -heap,或对 core 做事后分析9。本机(macOS)对活进程跑 jhsdb jmap --heap --pid 65237 时,task_for_pid 直接失败;同一时刻 jcmd GC.heap_info 是成功的。Linux 上这条通常能用。能走 jcmd 就先走 jcmd


九、收一下

现象 先跑
一堆 Java,不知道打谁 jps -l / jps -lvm
-Xmx、GC、-D 对不对 jcmd VM.command_line
Young 狂跑还是 Old 在涨 jstat -gcutil
死锁 / 阻塞 / 忙等 jstack
谁占了堆 jmap -histo,引用链再 MAT

仓库里分册手册和十日练习顺序在 docs/jdk-toolkit-learning-plan.md。后续如果把 JFR 也串进去,再另写一篇。

References

  1. liweinan,java-snippets — JDK 诊断 Demo 与手册。总计划:docs/jdk-toolkit-learning-plan.md;靶进程 JvmToolkitDemo.java。  2

  2. Oracle,《The jps Command》(Java SE 21)。列出目标系统上的 JVM 进程;-l 主类全名或 jar 路径,-v 传给 JVM 的参数,-m 传给 main 的参数。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/man/jps.html 

  3. Oracle,《The jcmd Command》(Java SE 21)。向指定 JVM 发送诊断命令;无参数或 help 列出该进程可用命令。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/man/jcmd.html 

  4. Oracle,《The jinfo Command》(Java SE 21)。查看或修改 Java 进程的配置。-flag name 打印单个 VM 标志。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/man/jinfo.html 

  5. Oracle,《The jstat Command》(Java SE 21)。对已插桩的 HotSpot JVM 采样;-gcutil 为各代占用百分比与 GC 计数。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/man/jstat.html 

  6. Oracle,《The jstack Command》(Java SE 21)。打印 Java 线程的栈跟踪。等价诊断命令为 jcmd <pid> Thread.print。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/man/jstack.html 

  7. Oracle,《The jmap Command》(Java SE 21)。-histo 对象直方图;-dump:live,format=b,file= 导出 hprof。文档写明部分选项在部分环境下不可用,可改用 jhsdb jmap。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/man/jmap.html 

  8. Oracle,《The jconsole Command》(Java SE 21)。JMX 合规的图形监控工具,可连接本地或远程 JVM。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/man/jconsole.html 

  9. Oracle,《The jhsdb Command》(Java SE 21)。Serviceability Agent;jmap --heap --pid 查看堆配置,亦可对 core 使用 --core / --exe。参见:https://docs.oracle.com/en/java/javase/21/docs/specs/man/jhsdb.html