
Java 虚拟线程上线后瓶颈从线程池挪到连接池、pinning 与下游限流开了虚拟线程平台线程池的天花板常先消失排队会下移到连接池、锁与 pinning 残留以及无池下游。用运维 checklist 逐层对别继续扩 VT。一、痛点线程池天花板消失排队下移虚拟线程JEP 444JDK 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 连接池有界资源本身就是 semaphoreJDBC 连接池生产里常见 Hikari不会因为开了虚拟线程就变大大量 VT 同时借连接时会在池上排队超时则失败。JDK 25 虚拟线程文档点明一条关键规则连接池本身就充当 semaphore不必再叠一层 Semaphore「保护」同一个池。池该多大、超时怎么收上一篇已经讲过这里不重复。上线后池打满时先看入口并发是否压过了库的会话能力、有没有长事务占着连接再决定要不要动池。2.2 锁与 pinningJDK 21 与 JDK 24 必须分开说别一刀切地说「所有 JDK 21 上 synchronized 都会 pin」。版本差是硬边界版本线synchronized 与 pin诊断开关 / 事件JDK 21JEP 444 时代在synchronized方法/块里阻塞会 pin 载体native / FFM 亦会曾可用jdk.tracePinnedThreadsJFRjdk.VirtualThreadPinnedJDK 24JEP 491synchronized 不再 pin几乎消除因 monitor 导致的 pinjdk.tracePinnedThreads已移除设了也无效残留 pin 除native / FFM回调里再阻塞外还有类加载期间阻塞、类初始化器内阻塞、等待其他线程完成类初始化这几种JEP 称这些很少会出问题JFR 事件保留JDK 25 官方文档观测部分文档只把native方法和 foreign function 列为 pin 场景类加载、类初始化等残留见上一行 JEP 491VirtualThreadPinned默认启用、阈值20msjcmd pid Thread.dump_to_fileJEP 491 还写明不必仅为虚拟线程把现有synchronized改成ReentrantLock新代码仍可优先synchronized需要公平性、可中断获取等再选j.u.c.locks。值班时先看运行时大版本再决定要不要开「去 synchronized」改造。在 24 上为已经消失的 pin 做大面积重构性价比通常不对。若线上仍是 JDK 21pin 排查才回到「持锁做阻塞 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 文档给出的两条日常工具足够先用起来JFRjdk.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 给平台解析时再加-formatjson。把「版本、池指标、JFR pin、转储快照」四条打在同一张值班卡上比单独盯 CPU 利用率更接近真实瓶颈。四、上线 checklist可贴进发布说明期望对齐建议先确认本迭代的目标是提高阻塞 I/O 密集接口的吞吐别拿「开 VT」去承诺单请求延迟下降。版本记账运行镜像的 JDK 是 21 还是 24/25pin 结论与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 与线程转储。把排队留在你看得见、调得动的地方。