☰
Java 21虚拟线程压崩网关?从线程池冲突到高并发迁移实战
2026/9/28 8:52:42 网站建设 项目流程

先说结论:我最近把一个基于 Spring Boot 3.5 的 API 网关服务从 Java 17 升到 Java 21,顺手在配置里打开spring.threads.virtual.enabled=true,结果上线后不到两小时,网关就被压崩了。集群 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 的maximumPoolSize=20,Apache 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 -format=json /tmp/threads_1.json sleep 5 jcmd <pid> Thread.dump_to_file -format=json /tmp/threads_2.json

隔几秒抓两次,对比虚拟线程数量的增长趋势,同时看它们的栈顶状态。如果两次快照里java.lang.VirtualThread的数量翻倍,而且大量线程都集中在同一个锁或连接池获取逻辑上,那基本可以锁定问题方向。

JFR 是更高效的手段。Java 21 提供了几个专门针对虚拟线程的事件,我一般会开这样一组:

jcmd <pid> JFR.start name=vt_debug settings=profile filename=/tmp/vt.jfr

然后到 JFR 面板里重点看几个事件:

  • jdk.VirtualThreadStart和jdk.VirtualThreadEnd:统计虚拟线程创建和销毁速率。
  • jdk.VirtualThreadPinned:标记虚拟线程钉扎事件。
  • jdk.VirtualThreadSubmitFailed:虚拟线程提交失败,通常是底层平台线程不足。

发现钉扎事件后,再配合-Djdk.tracePinnedThreads=full启动参数,会在日志里把钉扎的堆栈打印出来,定位到具体的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 稳定在 4500,P99 只涨到 150ms,内存和 GC 都很平稳。

这个对比说明得很清楚:虚拟线程本身没有让系统变差,变差的是它把并发压力传导到了旧资源边界上。你需要在虚拟线程环境下重新设计“资源边界”,而不是简单换个执行器就完事。

4. 如何安全地从传统线程池迁移到虚拟线程

4.1 改造执行器:不该用固定线程池的地方就别用

最直接的一步是把那些纯粹用于扩大并行度的固定线程池,换成虚拟线程执行器。如果只是想在网关里并行调用多个下游接口,可以这样:

ExecutorService downstreamExecutor = Executors.newVirtualThreadPerTaskExecutor();

然后在并行调用的时候,用这个执行器提交任务:

List<Callable<Result>> callables = buildDownstreamCalls(request); List<Future<Result>> futures = callables.stream() .map(downstreamExecutor::submit) .toList();

这里要提醒一点:newVirtualThreadPerTaskExecutor()创建的执行器,用完后应该保持长期复用,而不是每个请求都 new 一个。它的好处是任务执行完对应的虚拟线程会自动销毁,不需要你操心线程回收。

如果你用的是 Spring 的@Async,且项目的 Spring Boot 版本是 3.2 以上,可以继续用spring.threads.virtual.enabled=true,它会让 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代码块里做阻塞式调用,会把载体线程钉住。最简单的替代方案是换成ReentrantLock:

private 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.version>21</java.version> </properties>

如果你的 pom 里还单独配置了maven-compiler-plugin,最好把source和target都改成 21,或者直接用<release>21</release>,避免混用导致的诡异行为。

4.6 灰度策略与回归验证

虚拟线程这种级别的基础设施改动,绝对不建议全量直接上线。我梳理出一套相对稳妥的灰度流程:

  1. 先升级到 Java 21,但保持平台线程模式,验证运行稳定性和兼容性。
  2. 开启-Djdk.tracePinnedThreads=full,跑一轮压测,收集钉扎堆栈。
  3. 按钉扎堆栈逐项清理synchronized和 ThreadLocal 风险点。
  4. 在测试环境开启虚拟线程,用真实下游延迟做压测,观察虚拟线程数量和内存变化。
  5. 单集群灰度发布,观察至少一个流量周期,重点看线程数、GC、下游错误率。
  6. 逐步扩大到全量,期间保留回滚方案。

这套流程看着繁琐,但对线上稳定性来说是必要的。

5. 常见问题速查与避坑清单

5.1 线上问题排查速查表

我结合这次事故,把虚拟线程和传统线程池冲突中最常见的问题整理成了一张表,方便你快速对照:

现象可能原因排查手段处理方案
CPU 不高但内存增长快虚拟线程数量失控或 ThreadLocal 堆积抓线程转储,对比两次快照限流、清理 ThreadLocal、改用显式上下文
大量 RejectedExecutionException传统线程池队列满查看固定线程池的拒绝策略替换为虚拟线程执行器或改信号量
大量 VirtualThreadPinned 事件synchronized 内阻塞开启 tracePinnedThreads换 ReentrantLock
请求积压但平台线程数正常Tomcat 线程池满查看 Tomcat 指标确认是否误以为虚拟线程未生效
编译警告源发行版 21Maven 编译参数不一致查看 pom.xml配置统一 release 21

5.2 线程池阻塞队列选择的一点经验

过去用ThreadPoolExecutor的时候,阻塞队列的选择是个很经典的问题:无界队列容易内存爆炸,有界队列容易触发拒绝策略,SynchronousQueue又要求调用方一直等待。在虚拟线程模式下,我建议把问题分层解决:

  • 如果你要限制代码中某一段的并发量,优先用信号量,而不是固定线程池。
  • 如果你只是想把任务丢给虚拟线程异步执行,直接用虚拟线程执行器,不要再用阻塞队列做缓冲。
  • 如果某些遗留代码必须用ThreadPoolExecutor,那就老老实实设计有界队列和拒绝策略,并在兜底逻辑里把被拒绝任务写入可靠的重试通道。

不要想着“虚拟线程很轻,可以把队列调无界”,那相当于把风险推给内存,早晚会出事。

5.3 “虚拟线程已启用”不等于“所有线程都是虚拟线程”

Spring Boot 的spring.threads.virtual.enabled=true只对嵌入的 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 的虚拟线程才能真正变成你的底气,而不是你事故报告里的标题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询