《Java 虚拟机原理与性能调优》8.1 线程栈与转储:从 jstack 到 JSON 线程容器

jstack 看不到虚拟线程。本节用 1000 个虚拟线程加 4 个平台工作线程实测 Thread.print 与 Thread.dump_to_file,读出 JSON 里的 root、ThreadPerTaskExecutor 与 ForkJoinPool 容器,并给出死锁检测、线程状态判断和 libraryd 的转储脚本。

本节目标:读懂线程转储里的状态、栈帧与锁,并知道平台线程和虚拟线程分别该用什么命令。
适用版本: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 个平台线程、ThreadPerTaskExecutor 1000 个虚拟线程、ForkJoinPool-1 11 个载体。
  • 虚拟线程默认没有名字,dump 里全是空字符串;需要追踪时用 Thread.ofVirtual().name(...)。
  • 转储要抓三次对比,脚本按「线程 → 直方图」顺序执行,避免 Full GC 改变现场。

线程栈回答了「谁停在哪」,但没有回答「为什么进不去」。下一节拆开 synchronized 的运行时:对象头、Monitor 和锁膨胀,并用 JFR 看真实的竞争。

阅读导航:上一节:7.3 元空间与直接内存:类生成与堆外泄漏 · 下一节:8.2 锁的运行时实现:对象头、Monitor 与膨胀 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发