这份清单的口径
本附录的命令都在 macOS + Temurin 25.0.5 上执行过,目标进程是一个 DiagDemo:4 个平台工作线程在 synchronized 块里循环,200 个虚拟线程处于 sleep。除了明确标注「未执行」的命令,命令和输出都是本机原文,路径、PID、耗时随环境变化。
标注约定:
- [已执行]:本机跑过,输出为原文节选。
- [未执行]:只给命令和观察点,不伪造输出。
排障顺序仍然是「先分层,再选命令」:
| 层次 | 先看什么 | 命令 |
|---|---|---|
| 进程 | 进程在不在、启动参数是什么 | jps、jcmd VM.command_line |
| 线程 | 谁在阻塞、锁被谁持有 | jcmd Thread.print、jstack |
| 虚拟线程 | 有多少、卡在哪个栈 | jcmd Thread.dump_to_file -format=json |
| 堆 | 用了多少、谁占大头 | jcmd GC.heap_info、jmap -histo |
| 采样 | CPU 热点、睡眠、锁竞争 | jfr、jcmd JFR.start |
| 字节码 | 方法到底编译成什么 | javap -c -p |
| 依赖 | 模块边界、JDK 内部 API | jdeps |
| 运行时 | 裁剪后的 JDK 能不能跑 | jlink |
进程与 JVM 概览
jps:先确认进程
jps -lv
本机输出(省略无关进程):
314 DiagDemo
347 jdk.jcmd/sun.tools.jps.Jps -Dapplication.home=/Users/bingrong.yan/.jdks/jdk-25.0.5+8/Contents/Home -Xms8m -Djdk.module.main=jdk.jcmd
jps -l 显示主类或 jar 路径,-v 显示 JVM 参数。libraryd 场景:确认容器里跑的是新包还是旧包,以及 -Xmx、--enable-preview 是否真的传给了 JVM。jps 找不到进程时,先查用户是否一致、容器 PID 命名空间是否隔离,不要直接怀疑 JVM。
jcmd:统一入口
jcmd <pid> help
本机输出节选:
The following commands are available:
GC.class_histogram
GC.heap_info
JFR.start
Thread.dump_to_file
Thread.print
VM.command_line
VM.system_properties
jcmd 是首选入口,因为大多数 jstack、jmap、jinfo 的能力都能通过它完成,而且参数更稳定。先 help 看当前 JVM 支持哪些命令,再决定用哪个。
jcmd <pid> VM.command_line
jcmd <pid> VM.flags
jcmd <pid> VM.system_properties | grep -E 'java.version|os.name|user.dir'
命令输出节选(VM.flags 未摘录):
VM Arguments:
java_command: DiagDemo
java_class_path (initial): out
Launcher Type: SUN_STANDARD
java.version=25.0.5
libraryd 场景:线上怀疑「配置没生效」时,先看 VM.command_line 和 VM.system_properties,确认 -D 参数、JAVA_HOME、工作目录,而不是从代码里猜。
线程诊断
jcmd Thread.print 与 jstack
jcmd <pid> Thread.print
jstack <pid>
两者输出格式基本一致。本机抓到的 libraryd-worker:
"libraryd-worker" #25 [28163] daemon prio=5 os_prio=31 cpu=0.81ms elapsed=2.64s
java.lang.Thread.State: BLOCKED (on object monitor)
at DiagDemo.lambda$main$1(DiagDemo.java:18)
- waiting to lock <0x000000035fc184e8> (a java.lang.Object)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090)
观察点:
BLOCKED (on object monitor)表示等监视器,waiting to lock <地址>指向锁对象。WAITING (parking)常见于LockSupport.park、ReentrantLock、Future.get。TIMED_WAITING (sleeping)是Thread.sleep,不是锁问题。- 同一把锁地址被多个线程等待时,找持有者;
jcmd Thread.print -l会打印java.util.concurrent锁信息。
libraryd 场景:接口变慢但 CPU 不高,先抓线程栈,看请求线程是否都卡在 Hikari 获取连接、synchronized 或下游 HTTP 读。
Thread.print 默认只列平台线程。本机 200 个虚拟线程没有出现在输出里,只出现了 VirtualThread-unblocker 这类平台线程。抓虚拟线程必须用 Thread.dump_to_file。
Thread.dump_to_file:包含虚拟线程的 JSON
jcmd <pid> Thread.dump_to_file -format=json /tmp/vol3-d/diag/threads.json
本机输出:
Created /tmp/vol3-d/diag/threads.json
文件结构是 threadDump.threadContainers[].threads[],每个线程有 name、state、virtual、stack。本机统计:
containers: 6
container='<root>' threads= 8 virtual= 0
container='ForkJoinPool-1/...' threads= 11 virtual= 0
container='java.util.concurrent.ThreadPerTaskExecutor@...' threads= 200 virtual= 200
container='java.util.concurrent.ThreadPoolExecutor@...' threads= 4 virtual= 0
total threads: 223
一条虚拟线程的栈顶:
sample virtual name/state/virtual: TIMED_WAITING True
sample first frames: ['java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:787)',
'java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:983)']
libraryd 场景:请求量突增时,平台线程数稳定但延迟上升,用 JSON dump 统计 virtualThreads 数量和状态分布。如果大量虚拟线程停在 parkNanos,是在等超时;停在 socket read,是在等下游;停在 monitor enter,才需要回到锁竞争。默认虚拟线程名为空,建议在 Thread.ofVirtual().name("http-", 0) 里起名字,dump 才好读。
死锁
JVM 能自动检测监视器死锁,并在 Thread.print 的输出末尾给出 Found one Java-level deadlock;直接在 dump 里搜索这段文本即可。ReentrantLock 的死锁不一定被这段文本捕获,需要看 Thread.print -l 里的 waiting to lock 链。libraryd 场景:借阅与归还以相反顺序锁两本书时,统一加锁顺序比事后排查更有效。
堆与类
GC.heap_info
jcmd <pid> GC.heap_info
本机输出:
garbage-first heap total reserved 8388608K, committed 528384K, used 17371K
region size 4096K, 4 young (16384K), 0 survivors (0K)
看 committed 与 used 的差距,以及 young 区大小。libraryd 场景:容器内存告警时,确认堆上限是否按容器设置;MaxRAMPercentage 没配时,JVM 可能按宿主机内存计算。
jmap -histo 与 GC.class_histogram
jmap -histo:live <pid> | head -15
jcmd <pid> GC.class_histogram | head -10
本机 jmap 输出:
num #instances #bytes class name (module)
-------------------------------------------------------
1: 200 468800 jdk.internal.vm.StackChunk (java.base@25.0.5)
2: 8813 397928 [B (java.base@25.0.5)
3: 1872 246120 java.lang.Class (java.base@25.0.5)
4: 8730 209520 java.lang.String (java.base@25.0.5)
9: 200 33600 java.lang.VirtualThread (java.base@25.0.5)
StackChunk 和 VirtualThread 各 200 个,与 200 个睡眠虚拟线程对应。libraryd 场景:内存涨但堆 dump 里看不到业务对象时,先看 StackChunk、Class、byte[];虚拟线程的栈是放在堆上的,数量多时本身就会占内存。
-histo:live 会触发一次 Full GC,生产环境慎用;只想看概况时用 jcmd GC.class_histogram,影响类似。
JFR
命令行启动
java -XX:StartFlightRecording=filename=/tmp/libraryd.jfr,settings=profile,duration=30s \
-jar libraryd-app.jar
本机用 PinningDemo 验证 synchronized 是否 pin:
java -XX:StartFlightRecording=filename=/tmp/vol3-d/diag/pinning.jfr,settings=profile,dumponexit=true \
-cp out PinningDemo
jfr print --events jdk.VirtualThreadPinned /tmp/vol3-d/diag/pinning.jfr | wc -l
输出为 0,事件类型在 metadata 中存在,但 200ms 的 synchronized + sleep 没有产生 pin 事件。
运行中启动
jcmd <pid> JFR.start name=short settings=profile duration=30s filename=/tmp/vol3-d/diag/short.jfr
jcmd <pid> JFR.check name=short
本机输出:
Started recording 1. The result will be written to:
/tmp/vol3-d/diag/short.jfr
Recording 1: name=short duration=30s (running)
JFR.check 在录制结束后会提示找不到该名字,这是正常的;要确认是否还在录,趁 duration 未到时执行。
汇总与采样
jfr summary /tmp/vol3-d/diag/diag.jfr
jfr print --events jdk.ExecutionSample /tmp/vol3-d/diag/diag.jfr | head -40
本机 summary 节选:
Version: 2.1
Chunks: 1
Duration: 10 s
Event Type Count Size (bytes)
jdk.ThreadSleep 185 3515
jdk.ExecutionSample 41 ...
本机 ExecutionSample 节选:
jdk.ExecutionSample {
sampledThread = "libraryd-worker" (javaThreadId = 25)
state = "STATE_RUNNABLE"
stackTrace = [
DiagDemo.lambda$main$1() line: 22
java.util.concurrent.ThreadPoolExecutor.runWorker(...) line: 1090
]
}
libraryd 场景:接口 P99 升高时,先看 ExecutionSample 里 libraryd 自己的帧占比,再看 ThreadSleep、JavaMonitorEnter、SocketRead 事件。不要只看 CPU 采样就断定「没有热点」;阻塞型问题在 CPU 采样里可能完全看不见。
字节码与依赖
javap
javap -p -c out/com/plume/library/domain/Book.class
本机输出节选:
public final class com.plume.library.domain.Book extends java.lang.Record {
private final java.lang.String isbn;
private final java.lang.String title;
private final java.lang.String author;
private final int year;
public com.plume.library.domain.Book(java.lang.String, java.lang.String, java.lang.String, int);
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Record."<init>":()V
4: aload_0
5: aload_1
6: putfield #7 // Field isbn:Ljava/lang/String;
libraryd 场景:确认 record 的规范构造器、equals/hashCode/toString 是否由编译器生成;record 的规范构造器参数名由编译器强制保留,排查普通类反射拿到 arg0 时才需要检查编译是否带了 -parameters。
jdeps
jdeps -s out
jdeps --module-path . --module com.plume.library.domain
本机输出:
out -> java.base
com.plume.library.domain
requires mandated java.base (@25)
com.plume.library.domain -> java.base
libraryd 场景:模块化迁移前,用 jdeps 找对 JDK 内部 API 的依赖;jdeps --jdk-internals 会列出 sun.misc.Unsafe 一类的使用点。第三方自动模块在输出里通常显示为自动模块名,不能直接当成稳定模块名写进 requires。
运行时裁剪
jlink --add-modules java.base --strip-debug --no-header-files --no-man-pages \
--output /tmp/vol3-d/mini-jre
/tmp/vol3-d/mini-jre/bin/java -version
本机输出:
openjdk version "25.0.5" 2026-10-20 LTS
OpenJDK Runtime Environment Temurin-25.0.5+8 (build 25.0.5+8-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (build 25.0.5+8-LTS, mixed mode, sharing)
只含 java.base 的运行时约几十 MB,但 libraryd 需要 java.sql、java.net.http、jdk.crypto.ec 等模块,实际裁剪要用 jdeps --print-module-deps 生成模块列表。jlink 只能裁剪 JDK 模块;应用模块要先打成 modular jar,再用 --module-path 加进去。
启动参数
–enable-preview
javac --release 25 --enable-preview -d out-preview StructuredDemo.java
java --enable-preview -cp out-preview StructuredDemo
本机输出:
dashboard=Dashboard[memberId=M1, activeLoans=[B1, B2], overdueCount=1, totalFineCents=350]
timeout=java.util.concurrent.StructuredTaskScope$TimeoutException elapsedMs=58
编译时漏掉 --enable-preview 的报错:
错误: StructuredTaskScope 是预览 API,默认情况下处于禁用状态。
(请使用 --enable-preview 以启用预览 API)
libraryd 场景:预览特性只能出现在实验分支,构建脚本要把 --enable-preview 同时加到编译和运行;发布产物的启动脚本漏参数,会在类加载阶段直接失败。
–enable-native-access
java -cp out NativeDemo
java --enable-native-access=ALL-UNNAMED -cp out NativeDemo
不加参数的本机输出:
WARNING: A restricted method in java.lang.foreign.Linker has been called
WARNING: java.lang.foreign.Linker::downcallHandle has been called by NativeDemo in an unnamed module (file:/private/tmp/vol3-d/diag/out/)
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled
strlen(libraryd)=8
加上参数后只剩 strlen(libraryd)=8。libraryd 场景:FFM 调用 libisbn 的启动脚本统一加 --enable-native-access=ALL-UNNAMED;模块化后改成 --enable-native-access=com.plume.library.app。这是 JEP 472 的告警,不要等到后续版本拒绝才补。
-Xlog
java -Xlog:gc*:file=/tmp/vol3-d/diag/gc.log -cp out NativeDemo
本机输出节选:
[0.025s][info][gc,init] CardTable entry size: 512
[0.025s][info][gc ] Using G1
[0.026s][info][gc,init] Version: 25.0.5+8-LTS (release)
[0.026s][info][gc,init] CPUs: 10 total, 10 available
[0.026s][info][gc,init] Memory: 32768M
[0.026s][info][gc,init] Heap Max Capacity: 8G
libraryd 场景:容器里怀疑堆设置不对,用 -Xlog:gc*:file=...:time,uptime,level,tags 留下带时间戳的 GC 日志;排查类加载用 -Xlog:class+load=info,排查 safepoint 用 -Xlog:safepoint。日志要滚动,不要无限写满磁盘。
一页式命令表
| 症状 | 命令 | 看什么 |
|---|---|---|
| 进程是否在跑 | jps -lv | 主类、jar、JVM 参数 |
| 启动参数对不对 | jcmd <pid> VM.command_line | -D、-Xmx、预览/native 参数 |
| 请求卡住 | jcmd <pid> Thread.print | BLOCKED、WAITING、锁地址 |
| 虚拟线程卡住 | jcmd <pid> Thread.dump_to_file -format=json | virtual=true 的线程与栈 |
| 内存高 | jcmd <pid> GC.heap_info | committed/used、young 区 |
| 谁占内存 | jmap -histo:live <pid> | 实例数、字节数、StackChunk |
| CPU 高 | jcmd <pid> JFR.start ... + jfr print | ExecutionSample 热点帧 |
| 方法编译成什么 | javap -p -c | 构造器、invokedynamic、桥方法 |
| 依赖了哪些模块 | jdeps -s、jdeps --jdk-internals | java.base、JDK 内部 API |
| 裁剪运行时 | jlink --add-modules | 模块列表、启动验证 |
| 预览 API 失败 | javac/java --enable-preview | 编译与运行参数成对 |
| 原生访问告警 | --enable-native-access | JEP 472 告警原文 |
未在本机执行的命令
以下命令只作为排查选项,本附录没有运行,不提供输出:
jcmd <pid> GC.heap_dump、jmap -dump:format=b,file=...:堆转储,磁盘和停顿成本高。jcmd <pid> VM.native_memory summary:需要启动时加-XX:NativeMemoryTracking=summary。- async-profiler:本机未安装,不作为实测命令;需要火焰图时优先用 JFR + JMC。
jcmd <pid> Thread.print -l的完整锁链:本机只验证了基础Thread.print,-l输出未逐条摘录。
小结
- 先分层再选命令:进程、线程、堆、采样、字节码、依赖、运行时,不要一上来就抓全量 dump。
jps -lv和jcmd VM.command_line用一分钟确认「跑的是不是你以为的那个进程」。- 平台线程看
Thread.print/jstack,虚拟线程必须用Thread.dump_to_file -format=json。 - 内存先看
GC.heap_info,再看jmap -histo;虚拟线程的StackChunk会出现在直方图里。 - JFR 适合定位阻塞和采样热点,
jfr summary先看事件分布,再jfr print看具体栈。 javap、jdeps、jlink分别回答「编译成什么」「依赖什么」「裁完能不能跑」。- 预览参数必须在编译与运行两侧成对出现;原生访问参数只加在运行侧,JEP 472 的告警不是永久状态。
- 没跑过的命令就标注未执行,不要用记忆补输出。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。