ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java 21虚拟线程压崩网关?从线程池冲突到高并发迁移实战

Java 21虚拟线程压崩网关?从线程池冲突到高并发迁移实战 先说结论我最近把一个基于 Spring Boot 3.5 的 API 网关服务从 Java 17 升到 Java 21顺手在配置里打开spring.threads.virtual.enabledtrue结果上线后不到两小时网关就被压崩了。集群 CPU 没到 50%内存却一路打满下游服务持续 timeout前端流量全部堵在网关层。事后复盘发现真正的问题不是 Java 21而是虚拟线程和传统线程池之间的两个体系在网关这种高并发场景下互相打架。这篇文章就把这次事故的完整过程、根因和迁移经验写清楚希望能帮你少踩几个坑。这个网关不是 BPMN 里的流程网关也不是家庭路由器那种硬件网关而是典型的 API 网关/BFF 服务负责统一接收前端请求、做认证鉴权、聚合下游多个系统。Java 21 和 Spring Boot 3.2 之后官方一直在推虚拟线程社区里也到处是“一个开关就能让 Tomcat 换虚拟线程”的教程。听起来确实香平台线程不够用虚拟线程几十万个不心疼。结果真上了生产才发现“线程够用”不等于“系统扛得住”。这次事故的本质是虚拟线程把并发能力提高了但网关内部那些传统线程池、连接池、ThreadLocal 和同步锁全都成了新的瓶颈。下面我按事故时间线、根因分析、定位手段、修复步骤和避坑清单一条条展开。1. 事故现场升级 Java 21 后网关是怎么崩的1.1 升级动作回顾一个开关带来的“虚假安全感”这个服务在升级前的技术栈是 Spring Boot 3.1 Java 17基于 Spring MVC内嵌 Tomcat。因为有历史包袱网关里有不少阻塞式调用比如用 RestTemplate 调下游接口、用 HikariCP 查配置库。升级当天做的改动其实非常小spring: threads: virtual: enabled: true就这么一行配置加上编译目标从 17 改成 21整个发布包就出来了。当时还特意确认过 Spring Boot 3.5 对 Java 21 的支持没有问题单元测试也跑过了。启动后看日志Tomcat 也确实换成了虚拟线程执行器请求正常处理看起来一切都在掌控中。问题出在上线后第二波流量高峰。首先是监控面板上活跃线程数开始异常增长注意这里说的是虚拟线程数不是平台线程数。以前 Tomcat 默认 200 个线程再怎么打峰值也就是 200 个左右现在虚拟线程像脱缰野马几万个、十几万个不断创建。紧接着堆内存使用率快速抬升老年代 GC 频繁触发下游接口的 P99 延迟从 80ms 一路飙到 3 秒以上最后大量请求直接超时网关几乎处于不可用状态。1.2 压崩的直接表现CPU 不高内存和连接先爆最迷惑人的一点是 CPU 利用率并不高。按传统经验服务被压垮通常伴随着 CPU 打满或者磁盘 IO 拉满但这次 CPU 只有 40% 左右。这说明系统并不是在“拼命计算”而是大量请求被卡在等待状态。我抓了几次线程转储发现了几类非常典型的现象大量虚拟线程处于WAITING或PARKED状态等的是数据库连接池、HTTP 连接池或者某个ReentrantLock。某个固定线程池的阻塞队列里堆了几千个待执行任务线程池本身只有 20 个线程完全消化不过来。每个虚拟线程都带着一份不小的 ThreadLocal 数据几万个虚拟线程同时存在时这部分额外开销直接推高了堆内存。所以“压崩”的更准确描述不是 CPU 过载而是整个系统的线程、连接、内存、队列全部失衡最终表现为雪崩式的超时和拒绝。1.3 第一反应以为是流量突增回滚后才发现另有隐情事故后的第一反应是先把版本回滚到 Java 17 平台线程。回滚后确实恢复了稳定但这反而让我更警惕如果只是流量突增平台线程模式早该先撑不住。现在平台线程模式下 200 个 Tomcat 工作线程都能扛住虚拟线程模式下反而崩了说明问题不在请求量而在虚拟线程和旧有的线程协调机制之间发生了冲突。于是我开始逐个梳理代码里的线程使用点才慢慢看清了真相虚拟线程只是换了一个“跑腿的人”但代码里面那些“休息室”“调度台”还是老一套两边根本接不上。2. 虚拟线程为什么会和传统线程池打起来2.1 虚拟线程的调度原理不是“无敌线程”而是“可挂起的业务员”要讲清楚冲突先得把虚拟线程的调度模型说明白。Java 21 的虚拟线程由 JVM 调度到平台线程上运行你可以把平台线程想象成公司里的工位虚拟线程是拿着任务的业务员。业务员去楼下盖章、等外部接口返回时不需要一直占着工位JVM 会把他先挂起来让另一个虚拟线程用这个工位。这套机制在“虚拟线程自己阻塞”的场景下非常高效它能以极低的内存开销支撑几十万个 I/O 密集的任务。前提是这个“阻塞”必须是 JVM 能感知并释放 carrier 的阻塞比如Thread.sleep、NIO 读写、BlockingQueue.take等。如果代码里用了synchronized同步块虚拟线程在进入阻塞状态时会把整个工位一起带走也就是所谓的“钉扎”pinning这时候它不但不节省线程反而会饿死其他任务。2.2 冲突一传统有界线程池成了新的“瓶颈收费站”网关中有大量代码使用了Executors.newFixedThreadPool或者 Spring 的ThreadPoolTaskExecutor。这类传统线程池的特点是核心线程数、最大线程数、阻塞队列都是硬限制比如ExecutorService downstreamPool Executors.newFixedThreadPool(20);以前平台线程模式下Tomcat 最多同时 200 个请求进入分给 20 个下游线程池也够用。但虚拟线程模式下Tomcat 不再有 200 的上限可能有 5000 个请求同时进来每个请求又都往这个 20 线程的池子里提交任务。结果就是队列积压几千个任务内存上升。请求在Future.get()上等待创建的大量虚拟线程也一起卡住。等待时间超过下游超时阈值后产生重试重试又继续往队列里塞形成恶性循环。这里最坑的一点是传统线程池的拒绝策略通常只是AbortPolicy一旦队列满直接抛RejectedExecutionException。在高并发下这会让本来就脆弱的下游调用雪上加霜。2.3 冲突二连接池和信号量的等待让虚拟线程“空转”网关里大量用到数据库连接池和 HTTP 客户端连接池比如 HikariCP 的maximumPoolSize20Apache HttpClient 的连接池默认 50。虚拟线程让请求并发量轻松上千但连接池还是那个连接池就像高速公路上突然涌入成千上万辆车收费站的窗口还是只有两个。我在线程转储里看到很多虚拟线程都阻塞在HikariCP的connectionRequest锁上或者是 HTTP 客户端连接池的Future.get上。这些等待确实不会占用平台线程但它们会让虚拟线程长期存活每个虚拟线程又带着请求体、上下文信息、ThreadLocal内存和 GC 压力很快就上来了。如果这时候外部依赖持续变慢整个网关会变成一座巨大的“停车场”。这里的教训是虚拟线程能让你轻松发起很多并发操作但不代表下游系统和连接池能承受同样的并发。你仍然需要靠信号量或者熔断器去限制真正的并发穿透否则你会把连接池和下游服务一起压垮。2.4 冲突三ThreadLocal 在高并发虚拟线程下变成内存黑洞传统 Java 服务里ThreadLocal 是传递链路上下文的常用手段比如租户 ID、traceId、用户信息。以前 Tomcat 只有 200 个线程ThreadLocal 的内存开销是可控的。但虚拟线程模式下进请求的可能是几万个短命虚拟线程如果代码里到处用 ThreadLocal而且没及时清理这些对象会跟着虚拟线程一起堆积。更危险的是InheritableThreadLocal。它是传统线程池里为了让子线程继承父线程上下文而设计的虚拟线程环境下这个行为基本不可控经常造成上下文被错误复用甚至内存泄漏。JDK 21 中ScopedValue还是预览特性生产环境不建议强上比较稳妥的做法是把上下文对象显式传递给方法参数或者用一个请求级的上下文对象统一管理而不是依赖线程传递。2.5 冲突四缺少并发控制虚拟线程数量直接失控最后一个核心问题是虚拟线程模式下Tomcat 的server.tomcat.threads.max基本等于废了。以前这个参数是流量洪峰的最后防线线程池满了就排队排队超了就拒绝。现在它不限制虚拟线程数量每个 HTTP 请求都会被分配一个虚拟线程只要入口流量够大虚拟线程数量就会无限增长直到内存先被打爆。这不是虚拟线程设计的问题而是你在使用方式上没有做对应的流量控制。虚拟线程让你没有“线程不够用”的焦虑但你必须用限流器、信号量、熔断器来重新建立资源边界。没有边界再好的技术也会变成灾难。3. 网关场景下的实操定位怎么证明是“冲突”而不是“代码 Bug”3.1 收集证据线程转储 JFR 事件现场定位问题时我最常用的三件套是线程转储、JFR 和进程快照。对虚拟线程问题线程转储一定要用 JSON 格式因为普通jstack格式会把虚拟线程和平台线程混在一起量太大且不好过滤。我一般这样抓jcmd pid Thread.dump_to_file -formatjson /tmp/threads_1.json sleep 5 jcmd pid Thread.dump_to_file -formatjson /tmp/threads_2.json隔几秒抓两次对比虚拟线程数量的增长趋势同时看它们的栈顶状态。如果两次快照里java.lang.VirtualThread的数量翻倍而且大量线程都集中在同一个锁或连接池获取逻辑上那基本可以锁定问题方向。JFR 是更高效的手段。Java 21 提供了几个专门针对虚拟线程的事件我一般会开这样一组jcmd pid JFR.start namevt_debug settingsprofile filename/tmp/vt.jfr然后到 JFR 面板里重点看几个事件jdk.VirtualThreadStart和jdk.VirtualThreadEnd统计虚拟线程创建和销毁速率。jdk.VirtualThreadPinned标记虚拟线程钉扎事件。jdk.VirtualThreadSubmitFailed虚拟线程提交失败通常是底层平台线程不足。发现钉扎事件后再配合-Djdk.tracePinnedThreadsfull启动参数会在日志里把钉扎的堆栈打印出来定位到具体的synchronized方法或代码块。3.2 定位代码里的“传统线程池阻塞点”线程转储里出现的栈信息往往直接指向ThreadPoolExecutor.runWorker和FutureTask.get。当你看到大量虚拟线程阻塞在FutureTask.get时就说明它们正在等某个传统线程池执行任务。我当时的处理方式比较简单粗暴把所有Executors.newFixedThreadPool和ThreadPoolTaskExecutor的创建点全部捞出来逐个改造成虚拟线程执行器或者直接去掉。改造要小心因为这些线程池可能不是单纯“并行执行任务”还承担了限流和资源隔离的工作。比如说你有 10 个下游服务各有各的连接池和超时配置如果一股脑全换成虚拟线程执行器会导致下游服务同时被打爆。所以最合理的做法是保留并发控制的语义但把实现换成信号量或熔断器。3.3 实测数据同一套压测下两种模式的差异为了验证根因我做了一个简单的对照压测。场景是网关收到请求后并行调用两个下游接口每个下游接口模拟 50ms 延迟。客户端并发连接设置为 500压测时间 120 秒。第一组是平台线程模式Tomcat 默认 200 线程 内部固定线程池 20。结果 TPS 大概稳定在 3000 左右但 P99 延迟从 120ms 慢慢涨到 500ms因为 Tomcat 线程池开始排队。第二组是虚拟线程模式但保留内部传统线程池TPS 最高冲到 5000但持续不到 30 秒内部线程池队列开始积压错误率飙升。第三组是虚拟线程模式 内部线程池也换成虚拟线程执行器 下游加了信号量限流TPS 稳定在 4500P99 只涨到 150ms内存和 GC 都很平稳。这个对比说明得很清楚虚拟线程本身没有让系统变差变差的是它把并发压力传导到了旧资源边界上。你需要在虚拟线程环境下重新设计“资源边界”而不是简单换个执行器就完事。4. 如何安全地从传统线程池迁移到虚拟线程4.1 改造执行器不该用固定线程池的地方就别用最直接的一步是把那些纯粹用于扩大并行度的固定线程池换成虚拟线程执行器。如果只是想在网关里并行调用多个下游接口可以这样ExecutorService downstreamExecutor Executors.newVirtualThreadPerTaskExecutor();然后在并行调用的时候用这个执行器提交任务ListCallableResult callables buildDownstreamCalls(request); ListFutureResult futures callables.stream() .map(downstreamExecutor::submit) .toList();这里要提醒一点newVirtualThreadPerTaskExecutor()创建的执行器用完后应该保持长期复用而不是每个请求都 new 一个。它的好处是任务执行完对应的虚拟线程会自动销毁不需要你操心线程回收。如果你用的是 Spring 的Async且项目的 Spring Boot 版本是 3.2 以上可以继续用spring.threads.virtual.enabledtrue它会让 Spring 的默认异步执行器走虚拟线程。但如果你自定义了ThreadPoolTaskExecutor这个自动配置会被覆盖需要手动把 Bean 替换成虚拟线程执行器Bean public Executor applicationTaskExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); }4.2 用信号量重新建立并发边界前面说过虚拟线程模式下传统线程池的最大线程数不能直接作为限流手段了。但网关对下游服务的并发必须有限制否则下游先崩。我建议在调用下游的代码里加一个Semaphore把并发限制在一个合理范围private final Semaphore downstreamSemaphore new Semaphore(500); try { downstreamSemaphore.acquire(); return callDownstream(request); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } finally { downstreamSemaphore.release(); }信号量在虚拟线程里是安全的因为虚拟线程在等待 permit 时不会钉扎平台线程。这个限制配合连接池大小才是虚拟线程模式下真正的资源防线。4.3 消除钉扎把 synchronized 换成 ReentrantLock如果你在压测或 JFR 里看到了VirtualThreadPinned那就要到嫌疑代码里找synchronized。虚拟线程在synchronized代码块里做阻塞式调用会把载体线程钉住。最简单的替代方案是换成ReentrantLockprivate final ReentrantLock lock new ReentrantLock(); lock.lock(); try { // 原来的 synchronized 逻辑 } finally { lock.unlock(); }这个改造对读多写少的场景也适用。如果你的锁粒度非常粗还是先优化锁粒度否则锁竞争本身的瓶颈还在。4.4 ThreadLocal 的迁移策略虚拟线程里不是完全不能用 ThreadLocal但要控制数量和使用场景。我建议网关代码里做三件事把跨线程传递上下文的方式改成显式参数传递比如构造一个RequestContext传给每个服务调用方法。如果必须用 ThreadLocal务必用 try-finally 清理避免虚拟线程池化带来的上下文残留问题。工程上我会给每个网关请求绑定一个唯一的 traceId放在一个统一过滤器中结束的时候立刻 remove而不是让它在整个调用链路上蔓延。代码示例try { TraceContext.set(traceId); handleRequest(request); } finally { TraceContext.clear(); }4.5 编译配置避免“源发行版 21 需要目标发行版 21”的尴尬很多人在升级过程中会遇到下面这个警告java: 警告: 源发行版 21 需要目标发行版 21这不是 Java 21 的坑而是 Maven 编译插件的source和target参数没对齐。正确的做法是统一用release配置同时更新 Spring Boot 的java.version属性properties java.version21/java.version /properties如果你的 pom 里还单独配置了maven-compiler-plugin最好把source和target都改成 21或者直接用release21/release避免混用导致的诡异行为。4.6 灰度策略与回归验证虚拟线程这种级别的基础设施改动绝对不建议全量直接上线。我梳理出一套相对稳妥的灰度流程先升级到 Java 21但保持平台线程模式验证运行稳定性和兼容性。开启-Djdk.tracePinnedThreadsfull跑一轮压测收集钉扎堆栈。按钉扎堆栈逐项清理synchronized和 ThreadLocal 风险点。在测试环境开启虚拟线程用真实下游延迟做压测观察虚拟线程数量和内存变化。单集群灰度发布观察至少一个流量周期重点看线程数、GC、下游错误率。逐步扩大到全量期间保留回滚方案。这套流程看着繁琐但对线上稳定性来说是必要的。5. 常见问题速查与避坑清单5.1 线上问题排查速查表我结合这次事故把虚拟线程和传统线程池冲突中最常见的问题整理成了一张表方便你快速对照现象可能原因排查手段处理方案CPU 不高但内存增长快虚拟线程数量失控或 ThreadLocal 堆积抓线程转储对比两次快照限流、清理 ThreadLocal、改用显式上下文大量 RejectedExecutionException传统线程池队列满查看固定线程池的拒绝策略替换为虚拟线程执行器或改信号量大量 VirtualThreadPinned 事件synchronized 内阻塞开启 tracePinnedThreads换 ReentrantLock请求积压但平台线程数正常Tomcat 线程池满查看 Tomcat 指标确认是否误以为虚拟线程未生效编译警告源发行版 21Maven 编译参数不一致查看 pom.xml配置统一 release 215.2 线程池阻塞队列选择的一点经验过去用ThreadPoolExecutor的时候阻塞队列的选择是个很经典的问题无界队列容易内存爆炸有界队列容易触发拒绝策略SynchronousQueue又要求调用方一直等待。在虚拟线程模式下我建议把问题分层解决如果你要限制代码中某一段的并发量优先用信号量而不是固定线程池。如果你只是想把任务丢给虚拟线程异步执行直接用虚拟线程执行器不要再用阻塞队列做缓冲。如果某些遗留代码必须用ThreadPoolExecutor那就老老实实设计有界队列和拒绝策略并在兜底逻辑里把被拒绝任务写入可靠的重试通道。不要想着“虚拟线程很轻可以把队列调无界”那相当于把风险推给内存早晚会出事。5.3 “虚拟线程已启用”不等于“所有线程都是虚拟线程”Spring Boot 的spring.threads.virtual.enabledtrue只对嵌入的 Servlet 容器和 Spring 的ApplicationTaskExecutor生效。如果你的项目是 Spring Cloud Gateway默认是 WebFlux Netty 架构这个开关并不会让 Netty 的事件循环变成虚拟线程。如果你在网关过滤器里手动提交任务到固定线程池那些任务也还在传统线程池上跑。所以排查问题时先弄清楚你的请求线程到底是什么。最快的方法是看线程名虚拟线程一般带有virtual标记平台线程名字里通常带http-nio或tomcat-exec。不要在没确认的情况下反复调server.tomcat.threads.max那样只是在浪费精力。5.4 虚拟线程没有“核心线程数”概念传统线程池的参数corePoolSize和maximumPoolSize代表了一个资源池的边界很多人用它们来保证最低并发和最高并发。虚拟线程执行器没有这些概念它的一个重要含义是你失去了“用线程数限制并发”的天然能力。如果你在迁移后发现某个业务线程数持续飙升不要硬找参数去限制它应该到调用链路上加信号量或熔断器。5.5 日志和监控的适配虚拟线程数量大、生命周期短如果日志系统里为每个线程都创建独立的资源或做了高开销的 MDC 操作日志本身的处理就会拖慢系统。我在这次事故中还发现日志采集器对线程名的处理也成了一个小瓶颈因为虚拟线程的命名模式比较多样每天生成的新线程名称会撑大某些日志索引。这不是关键问题但建议你在迁移前把日志框架的异步处理能力检查一遍否则压测时会看到很多虚假的日志阻塞错误。6. 写在最后我复盘后的几个体会折腾完这次事故我最大的体会是虚拟线程不是用来替代线程池的而是用来消除线程池对你做“并发抽象”的束缚。它在 I/O 密集型场景下确实好用但前提是你已经清理完了那些依赖线程池做流量缓冲的旧代码。网关这种服务尤其典型它本质上是个聚合层入口并发可以无限高但下游资源永远有限这才是矛盾的核心。我现在再看“升级 Java 21 压崩网关”这个问题已经不会简单怪到版本升级上了。相反我会先问四个问题请求线程是不是真的切到了虚拟线程内部还有没有固定线程池在当瓶颈信号量/熔断器有没有重建ThreadLocal 和同步锁有没有清理如果这四个问题都能给出肯定答案虚拟线程在网关里的体验会非常稳。最后还是那句话虚拟线程是工具不是银弹它帮你抹平了线程数上限却没有帮你抹平下游资源的物理边界。该限流限流该熔断熔断该隔离隔离。把这些边界重新立起来之后Java 21 的虚拟线程才能真正变成你的底气而不是你事故报告里的标题。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进