news 2026/10/30 4:58:19

5000 个请求堵在 10 条连接上,jstack 一个都看不见

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
5000 个请求堵在 10 条连接上,jstack 一个都看不见

一个开了虚拟线程的 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.tracePinnedThreadsJDK 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
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/30 4:57:46

07|把 DeepSeek Harness 会话导出成 Markdown,怎样避免覆盖另一段对话?

把 DeepSeek Harness 的会话导出为 Markdown,便于用普通编辑器搜索和查阅。但如果用“创建时间+首句话”命名,两段会话可能得到同一个文件名。第二次导出看似成功,却把第一段对话覆盖了。 我检查过的一份导出代码就存在这个问题:它取创建时间和首条消息的前 20 个字符作为…

作者头像 李华
网站建设 2026/10/30 4:55:40

C语言——数组

一、数组的概念&#xff1a;数组其实就是一类相同的元素的集合&#xff1a;从这个概念我们就可以发现两个有价值的信息&#xff1a;a、数组中存放的是一个或者多个元素&#xff0c;但是数组元素的个数不可以是0在标准的C语言中这个不是合法的普通数组的定义&#xff0c;因为这里…

作者头像 李华
网站建设 2026/10/30 4:52:52

Windows server 2019 AD备域搭建

在Windows server 2019 AD备域搭建 设置备域服务器。 设置固定IP地址 修改AD备域服务器的名称 重复&#xff1a;Windows server 2019 AD域控搭建&#xff0c;的《打开服务器管理器》部分Windows server 2019 AD域控搭建_域控服务器安装步骤-CSDN博客 点击图标&#xff0c…

作者头像 李华
网站建设 2026/10/30 4:52:40

小白程序员快速入门大模型:从Token到Workspace Agent全解析

本文梳理了AI领域近年来的核心概念演变&#xff0c;从Token和Context Window的基础认知&#xff0c;到Prompt Engineering的任务描述&#xff0c;再到RAG的资料检索、Tool Calling的工具调用、MCP的协议整合&#xff0c;深入浅出地解析了Context Engineering的信息流设计、Skil…

作者头像 李华
网站建设 2026/10/30 4:52:20

AI Shell——依托自然语言实现华为云资源编排与应用自动化部署

1. 注册登录AI 开发者空间 登录注册AI 开发者空间&#xff0c;根据实际需求选择体验版/持久化版&#xff0c;然后点「立即进入」。 勾选服务协议和AK/SK授权&#xff0c;然后点「进入」。 2. 对话AI Shell&#xff0c;规划ECS部署 进入AI 开发者空间 - 体验中心&#xff0c;左…

作者头像 李华