本节目标:读懂线程转储里的状态、栈帧与锁,并知道平台线程和虚拟线程分别该用什么命令。
适用版本:JDK 25(Temurin 25.0.5+8);jcmd Thread.print;Thread.dump_to_file -format=json。
承接的问题
libraryd 有两个明显的线程场景:归还队列由 4 个固定工作线程消费,HTTP 请求由虚拟线程处理。接口变慢、CPU 却不高时,第一反应是抓线程栈,看大家卡在哪里。
但这里有一个 JDK 21 之后经常踩的坑:jstack 和 jcmd Thread.print 默认只列平台线程。虚拟线程不在输出里,哪怕有 1000 个虚拟线程正停在 socket read,jstack 也可能只给你十几个 ForkJoinPool-worker。用旧命令得出「没有线程在跑」的结论,方向就错了。
这一节先看线程栈里到底有什么,再用一个 1000 虚拟线程的进程对比两种转储方式,最后把命名规范和抓取脚本落到 libraryd。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,默认 G1。
线程栈里有什么:状态、帧与锁
一个 Java 线程的栈由栈帧组成,每个方法调用压一帧。帧里有局部变量表、操作数栈、动态链接和返回地址。线程转储就是把每个线程当前的帧序列打印出来,再附上状态和锁信息。
先看一个平台线程。libraryd-worker-0 在 BlockingQueue.take() 上等待:
"libraryd-worker-0" #25 [26627] daemon prio=5 os_prio=31 cpu=0.79ms elapsed=223.45s
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park(java.base@25.0.5/Native Method)
- parking to wait for <0x0000000424c18ae8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.park(java.base@25.0.5/LockSupport.java:369)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@25.0.5/AbstractQueuedSynchronizer.java:520)
at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@25.0.5/ForkJoinPool.java:4364)
at java.util.concurrent.ForkJoinPool.managedBlock(java.base@25.0.5/ForkJoinPool.java:4310)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@25.0.5/AbstractQueuedSynchronizer.java:1752)
at java.util.concurrent.LinkedBlockingQueue.take(java.base@25.0.5/LinkedBlockingQueue.java:435)
读这份栈的四个点:
- 状态:
WAITING (parking)表示无限期等待,常见于LockSupport.park、Future.get、BlockingQueue.take。TIMED_WAITING是带超时的等待(sleep、poll(timeout)),BLOCKED是等监视器,RUNNABLE才是正在跑或可运行。 - 栈顶:第一帧是当前执行点。
Unsafe.park本身没有信息量,往下一帧看是谁调用它——这里是LinkedBlockingQueue.take,说明线程在等队列元素,不是死锁。 - 锁信息:
- parking to wait for <地址>是parkBlocker,指向ConditionObject。它不是「锁被谁持有」,只是「在等哪个条件」。 - 线程属性:
#25是 JVM 内部线程号,cpu=0.79ms是累计 CPU 时间,elapsed=223.45s是存活时间。CPU 很低、elapsed 很长,符合空闲消费者。
状态判断要结合栈顶,不能只看单词。TIMED_WAITING 可能是正常的 sleep(1) 轮询,也可能是 socketRead 超时;BLOCKED 才是监视器竞争。第 8.2 节会专门讲锁。
jcmd Thread.print -l 会额外打印 java.util.concurrent 的 ownable synchronizers(ReentrantLock、Semaphore 等)。监视器锁的 waiting to lock / locked 在默认输出里就有,-l 补的是 AQS 那一层。抓锁问题时两个都抓一份,成本很低。
死锁检测:JVM 能替你找监视器环
监视器死锁不需要人肉追栈,JVM 会在转储末尾自动检测。两个线程以相反顺序锁两本书:
public class DeadlockDemo {
private static final Object BOOK_A = new Object();
private static final Object BOOK_B = new Object();
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> {
synchronized (BOOK_A) {
sleep(200);
synchronized (BOOK_B) { System.out.println("t1 got both"); }
}
}, "loan-desk-A");
Thread t2 = new Thread(() -> {
synchronized (BOOK_B) {
sleep(200);
synchronized (BOOK_A) { System.out.println("t2 got both"); }
}
}, "loan-desk-B");
t1.start();
t2.start();
t1.join();
t2.join();
}
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
运行后抓 jcmd <pid> Thread.print,末尾会出现:
Found one Java-level deadlock:
=============================
"loan-desk-A":
waiting to lock monitor 0x0000000cad04a4c0 (object 0x000000035fc18210, a java.lang.Object),
which is held by "loan-desk-B"
"loan-desk-B":
waiting to lock monitor 0x0000000cad04a3e0 (object 0x000000035fc18200, a java.lang.Object),
which is held by "loan-desk-A"
这段文本直接给出环:A 等 B 持有的锁,B 等 A 持有的锁。注意它只覆盖 监视器死锁;ReentrantLock、Semaphore 造成的死锁不一定出现在这段文本里,需要看 Thread.print -l 的 AQS 信息,或者用 JFR 的 jdk.JavaMonitorEnter / jdk.ThreadPark 事件还原等待链。
libraryd 的借阅和归还如果以相反顺序锁两本书,就会复现这个形状。统一加锁顺序(比如按 ISBN 排序)比事后检测更有效;检测只能在卡住之后告诉你原因,不能防止卡住。
JSON 线程容器:虚拟线程在哪里
构造一个混合进程:4 个平台工作线程消费队列,1000 个虚拟线程通过 Executors.newVirtualThreadPerTaskExecutor() 创建并睡 10 分钟。
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
public class ThreadDumpDemo {
public static void main(String[] args) throws Exception {
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
for (int i = 0; i < 4; i++) {
Thread worker = new Thread(() -> {
try {
while (true) { queue.take(); }
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "libraryd-worker-" + i);
worker.setDaemon(true);
worker.start();
}
var executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 1000; i++) {
executor.submit(() -> {
try { Thread.sleep(600_000); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
System.out.println("pid=" + ProcessHandle.current().pid());
Thread.sleep(600_000);
executor.close();
}
}
先看旧命令:
jcmd <pid> Thread.print | grep -c 'loan-vt-'
本机输出:
0
Thread.print 的输出里有 libraryd-worker-0..3、10 个 ForkJoinPool-1-worker-N、VirtualThread-unblocker,但没有一条虚拟线程的业务栈。Thread.getAllStackTraces().size() 也只返回 22,1000 个虚拟线程一个都不在里面。
虚拟线程要用 JSON 转储:
jcmd <pid> Thread.dump_to_file -format=json /tmp/vol4-d/threads/threads2.json
本机输出:
Created /tmp/vol4-d/threads/threads2.json
文件 706 KB,结构是 threadDump.threadContainers[].threads[]。用 Python 统计容器:
import json
d = json.load(open("threads2.json"))
for c in d["threadDump"]["threadContainers"]:
th = c.get("threads", [])
print(c["container"], len(th), sum(1 for t in th if t.get("virtual")))
本机输出(容器名截断):
<root> 12 0
ForkJoinPool.commonPool/... 0 0
java.util.concurrent.ThreadPerTaskExecutor@7f7351e3 1000 1000
ForkJoinPool-1/... 11 0
java.util.concurrent.ThreadPoolExecutor@266474c2 0 0
total threads: 1023
五个容器对应五类线程:
<root>:主线程、Reference Handler、Finalizer、Common-Cleaner、4 个libraryd-worker等平台线程,共 12 个。ThreadPerTaskExecutor:1000 个虚拟线程,全部virtual=true。这是newVirtualThreadPerTaskExecutor()的容器。ForkJoinPool-1:11 个平台线程,包括 10 个载体 worker 和 1 个delayScheduler。载体本身不是虚拟线程。commonPool、ThreadPoolExecutor:空的容器,结构仍然保留。
一条虚拟线程的记录长这样:
name: "" state: TIMED_WAITING virtual: true
stack: java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:787)
java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:983)
java.base/java.lang.Thread.sleepNanos(Thread.java:507)
名字是空的,因为 newVirtualThreadPerTaskExecutor() 默认不命名。state 是 TIMED_WAITING,栈顶在 parkNanos,说明它在 sleep。如果栈顶在 SocketDispatcher.read,那是在等下游;如果状态是 BLOCKED 且栈里有 monitor enter,才需要回到锁竞争。
JSON 里还有 tid、time 字段;平台线程额外有 parkBlocker,会显示 AbstractQueuedSynchronizer$ConditionObject。虚拟线程的记录里没有 parkBlocker 字段,等待信息在堆上的 StackChunk 和栈帧里,不要拿字段缺失判断「没有阻塞」。
抓取时机与成本
线程转储是瞬时快照。一次 dump 只能说明「这一刻」谁在哪,不能说明「过去 10 秒」谁一直卡着。排障时至少抓三次,间隔 3 到 5 秒:
for i in 1 2 3; do
jcmd "$pid" Thread.print > "/tmp/libraryd-threads-$i.txt"
jcmd "$pid" Thread.dump_to_file -format=json "/tmp/libraryd-threads-$i.json"
sleep 5
done
对比三次的栈顶:
- 三次都在同一帧:稳定阻塞,继续查那一帧的下游或锁。
- 三次位置不同:可能在正常推进,只是慢。
- 一次在
RUNNABLE、两次在BLOCKED:有竞争,但不一定死锁。
成本方面,Thread.print 需要走到安全点,线程多时会有毫秒到几十毫秒的停顿;JSON dump 要遍历线程容器和栈,1000 个虚拟线程的文件约 706 KB。相比堆转储,这个成本可以接受,但不要在高频监控里每秒抓。
Thread.dump_to_file 默认不覆盖已存在的文件,第二次写同一路径会报:
/tmp/vol4-d/threads/threads2.json exists, use -overwrite to overwrite
脚本里要么每次用新文件名,要么加 -overwrite。生产环境建议用时间戳命名,避免把上一次的现场覆盖掉。
在 libraryd 里落地
第一处是平台工作线程的命名。线程名是转储里唯一稳定的业务标识,不要用默认的 pool-1-thread-1:
package com.plume.library.web;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
public final class RequestWorkers {
public static Thread start(int index, BlockingQueue<Long> returnedLoans) {
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
Long loanId = returnedLoans.poll(1, TimeUnit.SECONDS);
if (loanId != null) {
System.out.println("processed loan " + loanId);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}, "libraryd-worker-" + index);
worker.setDaemon(true);
worker.start();
return worker;
}
}
libraryd-worker-N 在 Thread.print 和 JSON 里都能直接看到,和 Javalin 的请求线程、ForkJoinPool 的载体区分得开。线程池提交任务时也要传 ThreadFactory,不要依赖默认命名。
第二处是虚拟线程命名。newVirtualThreadPerTaskExecutor() 不命名,dump 里 1000 条记录全是空名字,只能靠栈区分。需要按请求追踪时,用 Thread.ofVirtual().name("http-", 0) 自己建:
try (var executor = Executors.newThreadPerTaskExecutor(
Thread.ofVirtual().name("http-", 0).factory())) {
executor.submit(() -> handle(request));
}
name("http-", 0) 会生成 http-0、http-1……名字会出现在 JSON dump 和 JFR 的 jdk.VirtualThreadStart 事件里。Javalin 自己创建的请求虚拟线程有自己的命名规则,抓 dump 时先看容器名,不要假设业务线程一定叫某个前缀。
第三处是采集脚本。把它放在 scripts/jvm/thread-dump.sh,由值班人员执行,而不是每次手敲:
#!/bin/sh
set -eu
pid="${1:?usage: thread-dump.sh <pid> <out-dir>}"
out="${2:?usage: thread-dump.sh <pid> <out-dir>}"
stamp="$(date +%Y%m%d-%H%M%S)"
mkdir -p "$out"
jcmd "$pid" Thread.print > "$out/threads-$stamp.txt"
jcmd "$pid" Thread.dump_to_file -format=json "$out/threads-$stamp.json"
jcmd "$pid" GC.class_histogram > "$out/histogram-$stamp.txt"
先抓线程,再抓直方图,顺序不要反:直方图会触发 Full GC,可能改变线程状态。脚本不负责分析,只负责把现场完整留下来。
常见错误与边界
错误一:用 jstack 找虚拟线程。 本机 1000 个虚拟线程时,Thread.print 里匹配 loan-vt- 的结果是 0。虚拟线程必须用 Thread.dump_to_file -format=json,或者 JFR 的 jdk.VirtualThreadStart。
错误二:把 TIMED_WAITING 当成卡死。 sleep、poll(timeout)、parkNanos 都是 TIMED_WAITING,是正常的等待。要看栈顶和等待对象,不要只看状态。
错误三:只抓一次转储。 单次快照无法区分「稳定阻塞」和「恰好路过」。至少三次,间隔几秒,对比栈顶。
错误四:用 Thread.getAllStackTraces() 统计线程数。 它只返回平台线程。本机 1000 个虚拟线程时仍然是 22,拿它做监控会得出完全错误的结论。ThreadMXBean.getThreadCount() 同样不含虚拟线程。
边界:JSON dump 的容器名不是稳定 API。 <root>、ThreadPerTaskExecutor@hash 这些字符串适合人看,不适合写进告警规则。要统计虚拟线程数量,用 JFR 事件或自己维护计数器。
边界:转储文件可能包含敏感信息。 栈帧里会有 SQL、URL、类名,有时还有参数。落盘目录权限要收紧,传输前脱敏。
小结
- 线程栈由帧组成,读栈先看状态,再看栈顶,最后看锁和
parkBlocker;WAITING (parking)不一定是问题。 - 监视器死锁会被 JVM 自动检测,
Thread.print末尾给出Found one Java-level deadlock和持有关系。 Thread.print/jstack默认只列平台线程;本机 1000 个虚拟线程时匹配数为 0。Thread.dump_to_file -format=json按线程容器分组:本机<root>12 个平台线程、ThreadPerTaskExecutor1000 个虚拟线程、ForkJoinPool-111 个载体。- 虚拟线程默认没有名字,dump 里全是空字符串;需要追踪时用
Thread.ofVirtual().name(...)。 - 转储要抓三次对比,脚本按「线程 → 直方图」顺序执行,避免 Full GC 改变现场。
线程栈回答了「谁停在哪」,但没有回答「为什么进不去」。下一节拆开 synchronized 的运行时:对象头、Monitor 和锁膨胀,并用 JFR 看真实的竞争。
阅读导航:上一节:7.3 元空间与直接内存:类生成与堆外泄漏 · 下一节:8.2 锁的运行时实现:对象头、Monitor 与膨胀 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。