Java 虚拟线程上线后:瓶颈从线程池挪到连接池、pinning 与下游限流
开了虚拟线程,平台线程池的天花板常先消失;排队会下移到连接池、锁与 pinning 残留,以及无池下游。用运维 checklist 逐层对,别继续扩 VT。
一、痛点:线程池天花板消失,排队下移
虚拟线程(JEP 444,JDK 21)把「一请求一线程」重新变便宜:阻塞 I/O 时载体平台线程可以去干别的活,应用侧能同时堆起大量业务单元。官方文档写得很直白:虚拟线程提升吞吐(scale),并不让单个请求变快(speed)。上线后常见的第一反应却是「怎么还没更快」,接着去加线程、加连接、加重试,把压力原样砸到下游。
更常见的失败形态是:旧的第一瓶颈(平台线程池硬顶)消失后,排队点整体下移。以前 Tomcat / 固定线程池把并发卡在几十上百,连接池、远端限流、本地锁竞争被「线程不够」盖住;VT 一开,这些有界资源轮流露头。症状可能是 Hikari 借连接超时、下游 429/超时雪崩,或(在仍会 pin 的版本与路径上)载体线程被长时间占住。
平台线程时代,你在应用容器里看到的「线程池队列堆积」其实是一层天然保险丝。虚拟线程把它拆掉之后,保险丝不会自动长到数据库或下游网关上。发布窗口最容易踩的坑,是把「吞吐上来一点」误读成「可以按 VT 数量等比扩一切池化资源」。连接、文件描述符、下游配额都不会跟着 VT 线性增长。
下面从上线后运维 / 容量的角度,讲三层瓶颈和观测清单。Hikari怎么按库能力定容(公式、fail-fast、勿按 VT 数扩池),见此前专文:Spring Boot 3.5 开虚拟线程后:Hikari 连接池按库能力定容。那套定池教程这里不重复,checklist 里再链回去。
二、三层瓶颈:连接池 / 锁与 pinning / 下游限流
把「VT 打开之后谁先红」拆成三层,值班时按顺序问,比一张笼统的「性能开关」好用。
2.1 连接池:有界资源本身就是 semaphore
JDBC 连接池(生产里常见 Hikari)不会因为开了虚拟线程就变大,大量 VT 同时借连接时会在池上排队,超时则失败。JDK 25 虚拟线程文档点明一条关键规则:连接池本身就充当 semaphore,不必再叠一层 Semaphore「保护」同一个池。
池该多大、超时怎么收,上一篇已经讲过,这里不重复。上线后池打满时,先看入口并发是否压过了库的会话能力、有没有长事务占着连接,再决定要不要动池。
2.2 锁与 pinning:JDK 21 与 JDK 24+ 必须分开说
别一刀切地说「所有 JDK 21+ 上 synchronized 都会 pin」。版本差是硬边界:
| 版本线 | synchronized 与 pin | 诊断开关 / 事件 |
|---|---|---|
| JDK 21(JEP 444 时代) | 在synchronized方法/块里阻塞会 pin 载体;native / FFM 亦会 | 曾可用jdk.tracePinnedThreads;JFRjdk.VirtualThreadPinned |
| JDK 24+(JEP 491) | synchronized 不再 pin,几乎消除因 monitor 导致的 pin | jdk.tracePinnedThreads已移除(设了也无效);残留 pin 除native / FFM回调里再阻塞外,还有类加载期间阻塞、类初始化器内阻塞、等待其他线程完成类初始化这几种,JEP 称这些很少会出问题;JFR 事件保留 |
| JDK 25 官方文档(观测部分) | 文档只把native方法和 foreign function 列为 pin 场景(类加载、类初始化等残留见上一行 JEP 491) | VirtualThreadPinned默认启用、阈值20ms;jcmd <pid> Thread.dump_to_file |
JEP 491 还写明:不必仅为虚拟线程把现有synchronized改成ReentrantLock;新代码仍可优先synchronized,需要公平性、可中断获取等再选j.u.c.locks。值班时先看运行时大版本,再决定要不要开「去 synchronized」改造。在 24+ 上为已经消失的 pin 做大面积重构,性价比通常不对。
若线上仍是 JDK 21,pin 排查才回到「持锁做阻塞 I/O」这条老路径:缩小synchronized范围、把 I/O 挪出临界区,或在热点上改用ReentrantLock。升级到 24+ 之后,同一段代码的优先级应切换:先看 native / FFM(类加载、类初始化相关的残留场景,JEP 491 认为很少会出问题),再看业务锁竞争本身是否过宽。版本没记清楚,最容易出现「改了一周锁,JFR 里 pin 事件却几乎不降」的空转。
2.3 下游限流:Semaphore 对,池化 VT 错
平台线程稀缺时,固定大小线程池常被顺手当成「限并发」工具。虚拟线程廉价之后,这条副作用消失了。JEP 444 与 JDK 25 文档都强调:不要池化虚拟线程;若只是限制对某下游的并发,用专门的Semaphore。
有连接池的路径:调小池即可,勿再叠 Semaphore。
无池的 HTTP / RPC / 自研客户端:在调用前acquire,在finally里release。
下面这段可单独编译,演示「无池下游用 Semaphore 限到 10」。注意它限的是并发许可,和线程池无关:
import java.util.concurrent.Semaphore; import java.util.concurrent.ThreadLocalRandom; public final class DownstreamLimiter { private final Semaphore permits; private final DownstreamClient client; public DownstreamLimiter(int maxInFlight, DownstreamClient client) { if (maxInFlight < 1) { throw new IllegalArgumentException("maxInFlight must be >= 1"); } this.permits = new Semaphore(maxInFlight); this.client = client; } public String call(String path) throws InterruptedException { permits.acquire(); try { return client.get(path); } finally { permits.release(); } } public static void main(String[] args) throws Exception { DownstreamLimiter limiter = new DownstreamLimiter(10, new DownstreamClient()); // 演示:每任务一虚拟线程,靠 Semaphore 而不是线程池限并发 Thread t = Thread.ofVirtual().name("demo-vt").start(() -> { try { System.out.println(limiter.call("/health")); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); t.join(); } } /** 占位下游;生产换成真实 HTTP/RPC 客户端即可。 */ final class DownstreamClient { String get(String path) { return "ok:" + path + ":" + ThreadLocalRandom.current().nextInt(1000); } }反模式对照(结构示意,勿照抄):Executors.newFixedThreadPool(10)再去跑虚拟线程任务,或「为了限流」把 VT 塞进池里。这是用稀缺资源的模型在管廉价线程,和官方采纳指南相反。
可以把两种结构想成同一条队列的两面:固定线程池是「少量工人 + 任务队列」;Semaphore 挡住的虚拟线程是「任务自己就是线程 + 许可队列」。对虚拟线程来说,第二种才对齐「一任务一线程」的模型。限流数值仍然要按下游真实能力定。Semaphore(10) 不是魔法数,只是把曾经藏在线程池大小里的副作用,改成显式、可监控的许可计数。
三、观测:JFR pinned 与 jcmd dump
容量问题要靠观测闭环,不能靠猜。JDK 25 文档给出的两条日常工具足够先用起来:
- JFR
jdk.VirtualThreadPinned:默认启用,阈值20ms。短于阈值的 pin 不会刷屏,长于阈值的值得进值班看板。JDK 24+ 上若仍大量出现,优先排查 native / FFM 路径(类加载、类初始化相关的场景很少见),别再先怪业务里的synchronized。 jcmd <pid> Thread.dump_to_file:可打 text 或 json;转储里包含平台线程与虚拟线程,适合对照「到底是谁堵在借连接 / 等许可 / 等 I/O」。它不是传统jstack的完整替代(地址、JNI/堆统计等不一定有),但对 VT 场景往往更可读。
排障顺序建议钉死:
- 确认 JVM 大版本(21 vs 24+),避免用错 pin 叙事;
- 看连接池活跃 / 等待 / 超时,对照数据库会话与锁;
- 看下游错误率与超时,确认有无 Semaphore(或等价限流)以及是否与池叠床架屋;
- 需要时开 JFR 看
VirtualThreadPinned,并用Thread.dump_to_file抓一张「堵点快照」。
若你只是把平台线程一对一换成虚拟线程、并发任务数并没有数量级上升,JDK 25 官方文档也提醒:把 n 个平台线程原样换成 n 个虚拟线程,收益很小,要转换的是任务。瓶颈转移之前,先确认任务模型是不是已经「每并发任务一 VT」。
观测落地时注意两件小事:一是VirtualThreadStart/VirtualThreadEnd默认关闭,排查「到底造了多少 VT」时要显式打开,避免和默认开启的VirtualThreadPinned混为一谈;二是传统jstack对海量虚拟线程不一定友好,优先用文档推荐的Thread.dump_to_file,需要 JSON 给平台解析时再加-format=json。把「版本、池指标、JFR pin、转储快照」四条打在同一张值班卡上,比单独盯 CPU 利用率更接近真实瓶颈。
四、上线 checklist(可贴进发布说明)
- 期望对齐:建议先确认本迭代的目标是提高阻塞 I/O 密集接口的吞吐,别拿「开 VT」去承诺单请求延迟下降。
- 版本记账:运行镜像的 JDK 是 21 还是 24/25+?pin 结论与
jdk.tracePinnedThreads是否仍有效,按上一节表格勾选。 - 连接池:
maximum-pool-size/connection-timeout是否仍按库能力定容,别按「预期 VT 数」放大;多实例总连接是否低于数据库max_connections安全余量。细项见 上一篇讲 Hikari 定容的文章。 - 双层限流:有连接池的路径不要再套 Semaphore;无池下游单独加 Semaphore(或网关 / 客户端限流)。
- 不建议池化 VT:搜一下
newFixedThreadPool/ 自研池,看有没有在 VT 执行器外又包了一层「限流池」;要限并发,改用 Semaphore,或在业务入口放有界队列。 - 锁改造边界:JDK 24+ 不必仅为 pin 把
synchronized批量改成ReentrantLock;仍要收窄锁范围、避免持锁做 I/O。 - 观测就绪:预发至少跑一轮带
VirtualThreadPinned的 JFR;演练一次jcmd Thread.dump_to_file;面板上要有池等待、下游超时、容器 CPU。 - 回滚旋钮:准备「收入口并发 / 收回池大小 / 下游熔断」,别指望「再开更多虚拟线程」。
发布当天建议把 checklist 打成「红黄绿」,别写成长文:版本与期望(绿/红)、连接池总预算(数字)、下游 Semaphore 或网关限额(数字)、JFR 是否已采集(是/否)。任何一项是红,就不要在同一个窗口里继续放大入口流量。容量结论也要写进复盘:到底是连接、pin 还是下游先饱和,下次扩容才知道该拧哪颗旋钮。
虚拟线程拆掉了平台线程池这层旧天花板;上线后要盯连接池、版本相关的 pinning 残留,以及无池下游的显式限流。定容看库,限流用 Semaphore,观测用 JFR 与线程转储。把排队留在你看得见、调得动的地方。