一个开了虚拟线程的 Spring Boot 服务,HikariCP 连接池 10 条,大约 5000 个请求在途,延迟在往上爬。你抓起线程栈,看到 43 个平台线程,大部分是调度器的 worker,停在ForkJoinPool.awaitWork,CPU 几乎没动。
你要找的那 5000 个请求,可能一个都没进这个文件。
这不是段子。从 JDK 21 到 25,jstack和jcmd <pid> Thread.print打出来的传统线程栈里,虚拟线程不会被列出来。你能读到的只有 carrier,也就是承载虚拟线程运行的那些平台线程。跑在虚拟线程上的服务一旦堆积,栈文件里看起来一切正常,只有延迟在涨。
传统栈里看不到什么
jstack输出的是平台线程,包括 carrier。虚拟线程在等 I/O 或者别的资源时会 unmount,把 carrier 还回去,让 carrier 去干别的活。
于是就出现了那个对不上的画面:请求在排队,carrier 却闲着。CPU 低、worker 空转,和一个巨大的积压完全不矛盾。
调度器用的是ForkJoinPool,默认并行度跟着 JVM 可见的处理器数走。可以用jdk.virtualThreadScheduler.parallelism去调,但在搞清楚请求到底在等什么之前,别碰它。Foojay 上那篇文章的作者 Felipe Maschio 说得直接:多开几个 worker,帮不了排在同样那 10 条数据库连接后面的请求。
JDK 24 之后,传统栈里会多一行信息。carrier 的栈下方会跟着打印它当前挂载的那个虚拟线程,形如Mounted virtual thread #212。更早的版本只有一句Carrying virtual thread #212。无论哪种,都只覆盖"正在挂载"的那一个。剩下几千个已经 unmount 的,得换一个命令。
该打的是 Thread.dump_to_file
这条命令从 JDK 21 就有:
jcmd<pid>Thread.dump_to_file /tmp/dump.txt几个容易踩的点。文件是 JVM 自己写的,路径属于目标进程的文件系统,容器里尤其要留意。默认不覆盖已有文件,要覆盖得加-overwrite;排障时更建议每次换个文件名,留着好对比。
加-format=json出 JSON 格式。官方文档给的两种取值是text和json。
输出内容随版本变。JDK 21 到 24 给线程 ID、名字、一个virtual标记和调用栈。JDK 25 补上了状态、时间戳和锁信息,一条 JDK 25 的记录长这样:
#412 "" virtual WAITING 2026-10-14T19:03:11.489120400Zat java.base/java.lang.VirtualThread.park(VirtualThread.java:582)... at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:179)at com.example.orders.OrderRepository.load(OrderRepository.java:48)名字是空的,虚拟线程没名字很常见,应用想加可以自己加。这条栈里能看到的东西是:请求卡在拿连接。
拿到文件之后不用先丢进什么分析工具,两条命令就能给出量级:
grep-cE'^#[0-9]+ ".*" virtual'dump.txtgrep-c'HikariPool.getConnection'dump.txt在那篇文章的示例里,这两个数分别是 5004 和 4962。也就是说,5004 个虚拟线程里有 4962 个卡在同一个地方。第二条命令数的是匹配行数,同一个线程里这个方法可能出现多次,所以随手打开几条栈确认一下调用路径。
怎么从数字推出该查什么
数量只是入口。真正有用的是那个换算:如果每个请求持有连接 18 毫秒,10 条连接清掉 5000 个请求的积压,在没有新请求进来的前提下大约要 9 秒。
这个数算不出 p99,但给了个量级,告诉你要查的是连接持有时间,不是线程池大小。
顺着往下查的是慢查询和事务边界。一个连接在无关的 HTTP 调用期间被占着,就把它后面所有请求的可用容量都抽走了一部分。数据库要么还扛得住更高并发,要么已经在挣扎。栈文件负责定位队列在哪,连接池和数据库的指标负责解释为什么。
作者那句总结值得原样抄下来:「Virtual threads make waiting cheaper. The database still has to do the work.」虚拟线程让等待变便宜了,数据库该干的活一件没少。
缓了几分钟的故障,30 秒后再打一份。两份对比看大分组,看分组内部的线程 ID 有没有换。
如果同样的请求还在同一个地方等,去查为什么不往前走。如果 ID 换了但分组还是那么大,说明请求在进来也在完成,队列在被不断填满。这两种情况只看总数的话几乎一模一样。
5004 变成 5011 没什么好解读的。真正要看的是连接获取时间有没有在涨,吞吐有没有开始跟不上到达速率。他原话是这么说的:「I’ve been fooled by a single dump more than once, and the second one has always been cheap.」被单份 dump 骗过不止一次,而第二份的代价一直很低。
状态字段也要同样小心。WAITING在做 I/O 的服务里是正常的,它本身不说明虚拟线程有没有释放 carrier。RUNNABLE也不能拿来证明 CPU 打满了。先看栈、看涉及的资源,再去对那些能解释等待的指标。
什么时候该用 JSON
一堆请求全卡在 HikariCP 上,文本够了。需要按 executor 或者结构化并发的 scope 分开看时,JSON 才派上用场,因为它按容器分组。JDK 25 里,挂载中的虚拟线程还会带上 carrier 的 ID:
{"container":"java.util.concurrent.ThreadPerTaskExecutor@3f1e7a2c","parent":"<root>","owner":null,"threads":[{"tid":"412","time":"2026-10-14T19:03:11.489120400Z","virtual":true,"name":"","state":"RUNNABLE","stack":["..."],"carrier":"37"}],"threadCount":"1"}配两条 jq,一条数带 carrier 的虚拟线程,一条列出每个容器的规模:
jq'[.threadDump.threadContainers[].threads[] | select(.virtual == true and .carrier != null)] | length'dump.json jq-r'.threadDump.threadContainers[] | "\(.threadCount)\t\(.container)"'dump.json栈是在线程继续跑的过程中采集的,所以那个 carrier 计数不是"某一瞬间有多少 carrier 在忙"的测量值。容器分布才是应该先看的东西。
文件旁边记得记下 JDK 版本,上面这些字段在老版本里没有。另外还要确认没设-Djdk.trackAllThreads=false,这个开关会让一部分虚拟线程直接从 dump 里消失。
容器里的三个坑
jcmd要打进目标进程,路径也属于那个进程的文件系统,容器环境下这一条会连环出错。
第一个坑是路径。kubectl exec进去打到/tmp/dump.txt,文件在容器里,不在你本机,还得再kubectl cp出来。写进 EmptyDir 或者挂出来的卷里能省一步。
第二个坑是权限。JVM 自己写文件,进程用户得有目标目录的写权限。最小化镜像里常见的情况是/tmp不可写或者整个根文件系统只读,命令会失败得莫名其妙。
第三个坑是进程消失。Pod 被 OOMKill 或者滚动更新掉了,文件跟着没了。真要做留存,得让探针把 dump 写到持久卷,或者干脆在启动参数里就挂上 JFR 持续录制。
(待核实:各家 APM 与可观测性产品对虚拟线程栈的支持程度,本文未逐一核实,选型前建议自己打一份 dump 往里灌一遍看解析结果。)
Pinning 这件事,JDK 24 前后是两套
请求在等连接,和虚拟线程把 carrier 钉住,是两种不同的故障,查法也不一样。
后一种要看VirtualThread.parkOnCarrierThread,那是虚拟线程 park 但没释放 carrier 的路径。在 JDK 21 到 23 上,这段代码会走这条路:
synchronized(lock){returnhttpClient.send(request, BodyHandlers.ofString());}栈里对应的部分:
#37 "" virtualjava.base/jdk.internal.misc.Unsafe.park(Native Method)java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:675)java.base/java.lang.VirtualThread.park(VirtualThread.java:593)... com.example.price.PriceService.lookup(PriceService.java:32)到了 JDK 24,情况变了。JEP 491 重写了 JVM 对synchronized的实现。
按 JEP 原文的说法,问题出在记账对象上:JVM 记录的是哪个平台线程持有 monitor,而不是哪个虚拟线程。虚拟线程跑在 carrier 上,JVM 就把 carrier 记成 monitor 的持有者。要是允许虚拟线程在synchronized方法里 unmount,调度器很快会把另一个虚拟线程挂到这个空出来的 carrier 上,而那个新线程会因为 carrier 的缘故被认成持有该 monitor,互斥就没了。所以 JVM 以前主动禁止这种 unmount。
JEP 491 的做法是让虚拟线程能独立于 carrier 去获取、持有和释放 monitor,mount 和 unmount 时做相应记账。阻塞获取 monitor 会 unmount 并释放 carrier,Object.wait()及其定时版本同理。生效版本是 JDK 24。
于是那句老建议可以退休了:不必为了避免 pinning 而把synchronized迁成ReentrantLock,已经迁过去的也不用改回来。
但 JEP 自己写的是"消除几乎所有(nearly all)情况",后面还留了三类:解析符号引用时在类加载上阻塞、在类初始化器里阻塞、等待其他线程初始化某个类。这几种都跟栈上的 native frame 或者在 JVM 内阻塞有关。另外,如果虚拟线程通过 native 方法或 Foreign Function & Memory API 调进 native 代码,而那段 native 代码又回调 Java 并做了阻塞操作或阻塞在 monitor 上,仍然会 pin。
诊断手段跟着换了一轮
| 手段 | 状态 |
|---|---|
jdk.tracePinnedThreads | JDK 24 移除,设了也没用 |
jdk.VirtualThreadPinned(JFR 事件) | JDK 21 起默认开启,阈值 20 ms;JEP 491 后保留并增强,带 pin 原因与 carrier 身份 |
jdk.VirtualThreadStart/End | 默认关闭 |
jdk.VirtualThreadSubmitFailed | 默认开启 |
jcmd Thread.dump_to_file | 列出平台线程 + 虚拟线程;不含对象地址、JNI 统计、堆统计 |
jdk.tracePinnedThreads被移除有个具体理由:它会在执行关键代码时打印栈轨迹,本身有副作用。
一过一不过的 pinning,用 JFR 比用几份 dump 靠谱,它能给出频次和时长:
jfr print--eventsjdk.VirtualThreadPinned recording.jfr找到parkOnCarrierThread是那条 park 路径的有力证据。找不到不等于没有别的形式的 pinning。
该改的是事故手册
这篇文章给出的改动很朴素:服务用了虚拟线程,抓栈的时候除了传统 dump,再多打一份Thread.dump_to_file。在有人开始对着空闲的 carrier 下结论之前,把请求的栈留下来。
值得顺手记进手册的还有几点。文件写在目标进程的文件系统里,容器里要确认路径和写权限。JDK 版本跟着文件一起存,字段差异比想象的大。JSON 格式目前各家分析工具支持得参差不齐——作者在文里提到自己的 ThreadMine 直到本周为止还会静默丢掉每一条#22 "" virtual,是写这篇文章对着 JDK 21 到 25 的真实 dump 核对时才发现,JSON 格式到现在也还没接进去。
这段自曝比正文更值得看。虚拟线程 2023 年就进了 JDK 21,到 2026 年,围绕它的排查工具链仍然有这种级别的漏洞。
为什么这类文章到今天才有人写
虚拟线程进 LTS 是在 JDK 21,2023 年 9 月。但真正在生产上铺开是往后推了一两年的事:Web 框架的默认开关、连接池和驱动的适配、团队对结构化并发的熟悉程度,每一环都要时间。
结果就是采用曲线和踩坑曲线之间有个时间差。迁移期遇到的问题大多集中在"能不能跑",等到服务真的稳定跑起来了,才会遇到"跑慢了怎么查"。而这时候大家手上第一反应还是jstack,因为那是过去二十年形成的肌肉记忆。
同样的滞后也出现在工具侧。分析工具要跟上,得先有人拿真实 dump 去喂,而这个量级的真实 dump 直到最近两年才多起来。上面那个"静默丢行"的 bug 能活到 2026 年,原因就在这儿。
(待核实:Spring Boot 各版本默认启用虚拟线程的具体版本号,本文未回官方 release notes 逐条核对。)
不知道的部分:数字是示意,工具也会错
有一点必须说清楚:上面那组 5004、4962、18 毫秒、9 秒,作者在原文里明确标注了是示意性的(illustrative),用来演示方法,不是某次真实故障的实测数据。引用它们是为了讲清楚换算逻辑,不能当成任何线上环境的基线。
同样地,JEP 491 里也写明了性能上的取舍:退出 monitor 时可能需要把虚拟线程排回调度器,这一步目前不如"退出 monitor 直接 unpark 一个平台线程"高效。
三个能验证的动作
一是今晚就检查事故手册,凡是用了虚拟线程的服务,抓栈步骤里有没有Thread.dump_to_file。这个改动十分钟能做完。
二是把 JFR 的jdk.VirtualThreadPinned事件开起来,确认阈值还是默认的 20 ms(它默认开着,但录制未必开着)。
三是下次出问题,别只看一份 dump 就下结论。隔 30 秒再打一份,看线程 ID 有没有换。这一步不需要任何新工具。
来源
- What jstack Misses When Your Java Service Uses Virtual Threads — Foojay,Felipe Maschio,2026-10-27
- JEP 491: Synchronize Virtual Threads without Pinning — OpenJDK
- JEP 444: Virtual Threads — OpenJDK
- Virtual Threads(JDK 25 官方文档) — Oracle
- JDK 25 jcmd 命令参考 — Oracle