Java 虚拟线程与结构化并发:高吞吐服务改造实战
Java 虚拟线程与结构化并发:高吞吐服务改造实战
2026 年的 Java 服务端开发者,大概率已经不再把“线程”当作一种昂贵资源来看待了。从 JDK 21 把虚拟线程(JEP 444)带入正式版本开始,到 JDK 25 把 Scoped Values 转正、结构化并发走完多轮预览,再到 2026 年 JDK 26 让 Structured Concurrency 正式落地,这套组合拳终于从“新特性尝鲜”变成了“生产环境的默认姿势”。但真正把一套跑了五年的线程池服务迁移过去,中间踩的坑远比官方文档里那句 spring.threads.virtual.enabled=true 要多。这篇文章记录的是我们一个日均 2 亿次调用的订单聚合服务,在 2026 年上半年完成的一次完整改造:从容量危机开始,到虚拟线程落地,再到用结构化并发把散落各处的异步逻辑收拢回来,最后是可观测性重建和一份踩坑清单。
一、线程池先撞上了天花板
改造前的架构非常典型:Spring Boot 服务,Tomcat 使用默认的 200 线程池,业务逻辑串行调用三个下游——用户中心(平均 150ms)、额度服务(平均 200ms)、风控服务(平均 250ms)。单个请求光在下游等待上就要花掉 600ms,而线程池只有 200 个线程。
算一笔简单的账:200 ÷ 0.6s ≈ 333 QPS。这就是服务的物理上限。大促期间真实流量峰值到 1400 QPS,于是必然发生的是:Tomcat 队列堆满、请求开始超时、上游重试、流量进一步放大,最后整个服务雪崩。
我们做的第一版补救是加大线程池。Tomcat 线程数从 200 提到 800,队列从 200 提到 2000。结果是 QPS 确实上去了一点,但代价是:每个平台线程默认 1MB 栈空间,800 个线程意味着 800MB 的地址空间被栈占着;线程上下文切换在高峰期每秒超过 20 万次;G1 的暂停时间从 30ms 涨到 120ms。本质上,我们只是把瓶颈从线程数量挪到了内存和调度器上,吞吐量提升不到 30%。
第二版补救是引入 CompletableFuture 把三个下游并行化,理论耗时降到 250ms。但新的问题马上来了:CompletableFuture 默认跑在 ForkJoinPool.commonPool,和业务里其他异步任务互相争抢;超时控制只能在每个 future 上单独写 orTimeout,三个 future 之间的失败传播要靠 allOf + exceptionally 手写;一旦客户端断开,没有任何机制能自动取消已经在跑的下游调用,白白消耗资源。代码从可读的三十行变成了难以维护的八十行,而且每次新增一个下游调用都要重新推理一遍取消语义。
这两次失败让我们意识到:问题不在于线程池调多大,而在于“一个请求占一个线程”这个模型本身,在 IO 密集场景下就是浪费。
二、虚拟线程到底改变了什么
在动手改造之前,先把几个高频误解讲清楚,因为它们直接决定了改造时哪些代码需要动、哪些不需要。
第一,虚拟线程仍然是 java.lang.Thread 的实例,Thread.currentThread()、ThreadLocal、synchronized 全都能用。它不是响应式编程那种“换一套心智模型”的东西,业务代码的书写方式基本不变。
第二,虚拟线程由 JVM 调度,运行在少量载体线程(carrier thread)上。默认载体线程数等于 CPU 核数(可通过 jdk.virtualThreadScheduler.parallelism 调整)。当虚拟线程阻塞在 java.util.concurrent 的同步原语、Socket / NIO 通道、Selector 等可卸载点上时,JVM 会把它从载体线程上“卸载”(unmount),载体线程转去执行其他虚拟线程。这就是为什么一个 4 核机器可以轻松支撑十万个并发虚拟线程。
第三,也是最重要的一点:不要池化虚拟线程。虚拟线程的创建成本极低(一个对象分配),池化只会带来额外的队列和上下文传递开销,得不偿失。你要池化的是连接、是下游并发额度,而不是线程。
第四,需要注意“钉住”(pinning)。如果虚拟线程在 synchronized 块内发生阻塞、或者调用了 native 方法(JNI)、或者执行了文件系统 IO(FileChannel 的某些操作在部分平台上仍会阻塞载体线程),它就无法被卸载,载体线程会被占住。少量 pinning 无所谓,但如果载体线程被大量钉住,整个调度器就会退化成平台线程池。诊断方法是开启 JFR 的 jdk.VirtualThreadPinned 事件,阈值由 jdk.virtualThreadScheduler.pinnedThreshold 控制,或者在启动参数里加上 -Djdk.tracePinnedThreads=full 打印详细的钉住栈。
第五,ThreadLocal 在虚拟线程下依然工作,但代价被放大了。以前 200 个线程意味着最多 200 份 ThreadLocalMap;现在可能同时存在 5 万个虚拟线程,每份 ThreadLocalMap 都持有自己的副本。对于请求级别的上下文传递,应该迁移到 ScopedValue:
private static final ScopedValue<TraceContext> TRACE = ScopedValue.newInstance();
public Response handle(Request req) {
TraceContext ctx = TraceContext.from(req);
return ScopedValue.where(TRACE, ctx)
.call(() -> doHandle(req));
}
// 任意深度调用栈内读取,无需参数透传,作用域结束自动清理
String traceId = TRACE.get().traceId();
ScopedValue 是不可变的、有明确生命周期的,不会像 ThreadLocal 那样因为忘记 remove() 而在虚拟线程长时间存活时造成内存泄漏。
三、第一步改造:把阻塞调用交给虚拟线程
改造的第一阶段目标很朴素:不动业务逻辑,只把执行模型从平台线程换成虚拟线程。
在 Spring Boot 3.x 及以上,一行配置就够了:
spring:
threads:
virtual:
enabled: true
这一行会做几件事:Tomcat(或 Jetty)的请求处理执行器换成虚拟线程执行器;@Async 的默认执行器换成虚拟线程;Spring 管理的 TaskExecutor bean 也切换到虚拟线程实现。对于绝大多数基于 Spring MVC 的同步阻塞服务,这就是全部的改动量。
但这一行配置生效之后,有几个地方必须重新审视:
连接池成为新的瓶颈。 以前 HikariCP 的 maximumPoolSize 是配合线程池大小调的,比如 200 个线程配 50 个连接。现在线程数可能是几万,但数据库连接数绝不能跟着涨。我们最终把 maximumPoolSize 定格在 40,并且明确:连接池的容量应该由数据库能承受的并发决定,而不是由应用线程数决定。当虚拟线程拿不到连接时,它会阻塞在 getConnection() 上被卸载,这本身是安全的,但要在 connectionTimeout 上设置合理的值(我们用的是 800ms),让等待过久的请求快速失败而不是无限堆积。
synchronized 要审。 我们用一个简单的静态分析规则扫描了全仓库的 synchronized 方法,把那些“方法体内包含远程调用或数据库操作”的挑出来。共有 11 处,全部改写成 ReentrantLock:
// 改造前:整个方法体被 synchronized 包住,里面的远程调用会导致载体线程被钉住
public synchronized OrderSnapshot snapshot(String orderId) {
Order order = orderMapper.selectById(orderId); // 阻塞 IO,钉住载体线程
return enrich(order);
}
// 改造后
private final ReentrantLock lock = new ReentrantLock();
public OrderSnapshot snapshot(String orderId) {
lock.lock();
Order order;
try {
order = orderMapper.selectById(orderId);
} finally {
lock.unlock();
}
return enrich(order); // 耗时部分移出临界区
}
顺带一提,改造过程中我们发现 ReentrantLock 的引入还带来了一个额外的好处:临界区变得可见了,很多原本被 synchronized 一把梭保护的代码被迫重新思考“真正需要互斥的到底是什么”。
避免文件 IO 走热路径。 虚拟线程对网络 IO 的卸载支持很好,但对文件系统 IO,尤其是 FileChannel.read 在某些平台上仍会阻塞载体线程。我们把热路径上的本地缓存文件读写改成了异步的 AsynchronousFileChannel,或者干脆挪到独立的平台线程池里执行。
完成这一阶段后,压测数据就已经相当可观:Tomcat 线程数从 800 降到 0(不再有固定的请求处理线程池),单实例吞吐从 320 QPS 提升到 1100 QPS,P99 从 1.8s 降到 480ms,堆内存中线程栈的占用几乎归零。但串行调用下游的问题还在,480ms 里有 350ms 是在等下游。
四、结构化并发:把发散的异步收回来
第二阶段是并行化三个下游调用,这里我们直接上结构化并发,而不是回到 CompletableFuture。
结构化并发的核心思想是:并发任务的生命周期,必须和创建它的代码块生命严格绑定。所有子任务都在一个作用域内 fork,作用域退出时保证所有子任务要么完成、要么被取消。这解决了 CompletableFuture 时代最大的痛点——忘记取消、异常丢失、栈追踪断裂。
public OrderProfile loadProfile(String userId) throws Exception {
try (var scope = StructuredTaskScope.open(
Joiner.<Object>allSuccessfulOrThrow(),
cf -> cf.withTimeout(Duration.ofMillis(800)))) {
Subtask<User> user = scope.fork(() -> userClient.get(userId));
Subtask<Quota> quota = scope.fork(() -> quotaClient.get(userId));
Subtask<Risk> risk = scope.fork(() -> riskClient.get(userId));
scope.join(); // 任一路失败或整体超时,其余子任务自动被取消
return new OrderProfile(user.get(), quota.get(), risk.get());
}
}
这段代码里有几个关键点值得展开:
第一,try-with-resources 保证了作用域的确定性关闭。如果 join() 之后有任何代码抛异常,close() 会取消所有尚未完成的子任务。这是“结构化”二字的字面含义。
第二,allSuccessfulOrThrow() 这个 Joiner 表达的是“全成功才成功,任一失败立即失败并取消其余”。除此之外还有 anySuccessfulResultOrThrow(),适合“多副本竞速”场景。比如同一个商品信息有两个数据源,谁先返回用谁:
try (var scope = StructuredTaskScope.open(
Joiner.<Product>anySuccessfulResultOrThrow())) {
scope.fork(() -> primarySource.fetch(productId));
scope.fork(() -> backupSource.fetch(productId));
return scope.join(); // 拿到第一个成功结果后,另一个自动取消
}
在我们的场景里,这个模式被用在了风控服务上:主通道和降级通道同时发起,谁先返回用谁,P99 直接下降了 180ms。
第三,超时是作用域级别的,不是每个子任务级别的。withTimeout(Duration.ofMillis(800)) 意味着整个作用域最多存活 800ms,到点后所有未完成子任务统一取消。这比 CompletableFuture 里给每个 future 单独挂 orTimeout 要清晰得多,也避免了“三个 800ms 超时叠加成 2.4s”的尴尬。
第四,异常语义是清晰的。scope.join() 抛出的 FailedException 会带上失败的子任务和原因,其余子任务的异常作为 suppressed exception 附加上去。生产环境排查问题时,这条链路比 CompletionException 里嵌套一堆 whenComplete 好用太多。
改造完成后,单请求耗时从 600ms 降到 260ms(三个下游并行,取最慢的风控 250ms 加上少量开销),P99 稳定在 300ms 以内。
五、取消与超时之后,别忘了限流
虚拟线程带来的最大风险,是它太容易让人写出“无限并发”的代码。以前线程池是一道天然的闸门——池子满了请求就排队;现在闸门没了,下游服务会被瞬间打穿。
我们的做法是:每一个跨进程的下游依赖,都配一个信号量,容量由下游服务的承受能力决定,而不是由上游供给决定。
private final Semaphore riskPermits = new Semaphore(64);
public Risk queryRisk(String userId) throws Exception {
if (!riskPermits.tryAcquire(50, TimeUnit.MILLISECONDS)) {
throw new DownstreamOverloadedException("risk 并发已达上限");
}
try {
return riskClient.get(userId);
} finally {
riskPermits.release();
}
}
这里有三个细节:一是用 tryAcquire 而不是 acquire,避免请求无限期排队;二是等待时间给得很短(50ms),因为快速失败配合上游降级比慢速成功更有价值;三是 release() 必须放在 finally,否则一旦异常就会永久泄漏额度——这和连接池泄漏是同一类 bug,但在虚拟线程环境下更难被发现,因为线程不会一直占着。
数据库连接池是同样的道理,只不过 HikariCP 已经帮你实现了信号量语义。需要额外注意的是,任何“有界资源”都必须被显式地有界化:HTTP 客户端的总连接数、Redis 连接池、本地缓存的大小、甚至是日志队列。在平台线程模型里,线程池大小无意中充当了全局背压;虚拟线程把这个隐式保护拿走了,你必须显式地把它加回来。
六、可观测性重建
虚拟线程给监控体系带来的第一个冲击是:线程数指标失去了意义。改造前,jvm.threads.live 稳定在 200 左右,任何偏离都是异常信号;改造后这个数字在 3000 到 40000 之间剧烈波动,属于完全正常的现象。如果告警规则还挂在上面,会天天误报。
我们的调整方案是:
- 把
jvm.threads.live的告警阈值撤销,改为监控jvm.threads.virtual.live的增长速率而不是绝对值; - 用 JFR 事件替代
jstack做线程诊断。jdk.VirtualThreadStart/jdk.VirtualThreadEnd事件默认关闭(因为量大),排查问题时按需开启;jdk.VirtualThreadPinned事件则常驻开启,因为它直接反映调度器的健康度; - 用 async-profiler 采集火焰图时,注意选择支持虚拟线程的采样模式,否则你会看到一堆
ForkJoinPool的帧而找不到真正的业务栈; - 链路追踪的上下文继续走
ScopedValue+ OpenTelemetry 的组合,MDC 仍然可用,但要确保每个虚拟线程的上下文不会因为ThreadLocal持有大对象而放大内存。
另外一个我们花了些时间才适应的变化:线程转储的读法变了。以前 jstack 几百行就能看清全貌,现在可能输出几万行。实用的做法是先用 jcmd <pid> Thread.dump_to_file -format=json 导出,再用脚本按“处于 BLOCKED 状态”或者“持有锁超过 N 秒”过滤,只看异常的那一小撮。
七、实测数据与踩坑清单
改造前后,同一个 4C8G 实例、同一份压测流量的对比:
| 指标 | 改造前 | 改造后 | | --- | --- | --- | | 单实例吞吐 | 320 QPS | 1450 QPS | | P50 延迟 | 620ms | 265ms | | P99 延迟 | 1830ms | 310ms | | 平台线程数 | 800 | 约 30(仅剩调度器与少量专用池) | | 峰值堆内线程栈占用 | 约 780MB | 忽略不计 | | 单请求下游超时率 | 3.1% | 0.04% |
几个值得记录的坑:
坑一:以为改了配置就万事大吉。 我们最初只在 staging 环境打开了 spring.threads.virtual.enabled,压测通过,上线后半小时开始出现周期性卡顿。原因是某个老旧的内部 SDK 在 synchronized 块里做 DNS 解析,把载体线程钉死了。JFR 的 pinning 事件几秒钟就定位到了问题,但如果没有提前打开这个事件,排查会非常痛苦。
坑二:连接池没有跟着调。 虚拟线程让并发请求数暴涨,如果 HikariCP 还是老配置(maximumPoolSize=100,connectionTimeout=30s),会出现大量虚拟线程阻塞在获取连接上,每个都等 30 秒。表现是“CPU 不高、QPS 不高、但延迟奇高”。修法是降低 connectionTimeout 并监控 hikaricp.connections.pending。
坑三:日志框架的异步 appender 成了瓶颈。 Logback 的 AsyncAppender 内部用有界队列,队列满了会丢弃或阻塞。虚拟线程环境下日志写入量没变,但写入的并发度变了,队列参数需要重新评估。我们最终换成了基于 JCTools 的无锁队列实现。
坑四:测试代码里的 Thread.sleep 不再可信。 有些依赖睡眠时序的测试在虚拟线程下表现完全不同,因为挂起和恢复的时序变了。这类测试应该改成基于 CountDownLatch 或 CyclicBarrier 的确定性同步。
八、什么场景下不该用虚拟线程
虚拟线程不是银弹,以下几种情况我们明确保持平台线程:
CPU 密集型任务。 这类任务本来就不会在 IO 上阻塞,虚拟线程的卸载机制派不上用场,反而增加了调度开销。图像处理、加密运算、复杂规则引擎,继续用固定大小的平台线程池。
大量依赖 JNI 的代码。 native 方法调用期间虚拟线程无法卸载,会钉住载体线程。如果这类调用占比高,虚拟线程的收益接近于零。
依赖线程身份做隔离的框架。 有些老框架用 Thread.currentThread() 作为 key 做本地缓存或者限流,虚拟线程数量巨大且生命周期短,这类设计会失效甚至内存泄漏。
长时间持有锁的临界区。 虚拟线程在 synchronized 内阻塞会钉住载体线程,如果锁竞争激烈,调度器会迅速退化。
一个实用的判断标准是:如果你的代码里 IO 等待时间占比超过 50%,虚拟线程会有明显收益;低于 20%,收益有限;如果几乎没有 IO 等待,不要用。
九、迁移路线图
如果要把这套经验压缩成一条可执行的路径,我们会这样建议:
第一步,先把 JDK 升到 21 以上(2026 年的新项目直接上 26),但不要立刻开虚拟线程。先跑一段时间,确保基础依赖、agent、profiler 在新 JDK 上都工作正常。
第二步,把 ThreadLocal 请求上下文逐步替换成 ScopedValue,把热路径上的 synchronized 换成 ReentrantLock。这一步可以独立于虚拟线程进行,风险低、收益明确。
第三步,在 staging 环境打开虚拟线程,同时开启 JFR 的 pinning 事件,压测至少 24 小时。重点观察 pinning 次数、连接池等待、下游服务错误率。
第四步,给每个下游依赖配置显式的并发额度(信号量或客户端连接上限),把背压重新加回来。
第五步,把散落的异步调用改写成 StructuredTaskScope,让每个请求的并发结构在代码里一目了然。这一步最难,但收益也最大——它不只是性能优化,更是可维护性的提升。
第六步,调整监控和告警规则,放弃线程数绝对值这类指标,转向延迟分位、下游饱和度和作用域级别的追踪。
结语
虚拟线程真正解决的,从来不是“线程太贵”这个问题本身,而是“为了让线程不贵,我们被迫写下了多少扭曲的代码”。在它出现之前的十年里,我们用回调、用 Future 组合、用响应式流,把顺序的业务逻辑拆成一个个状态机,只为了让有限的线程不要被 IO 等待浪费掉。虚拟线程把“一个请求一个线程”这个最符合直觉的模型还给了我们,而结构化并发则确保了这个模型在并发场景下依然是可推理、可取消、可追踪的。
2026 年的今天,把这两件事一起用起来,一个 4C8G 的实例撑起 1500 QPS 的 IO 密集服务已经不再需要什么黑魔法。真正的工程量在于那 20% 的细节:连接池、信号量、pinning、可观测性。把这些做扎实,剩下的就是享受代码重新变得简单这件事了。