公司有一套订单查询微服务,技术栈是Spring Boot 3加嵌入式的Tomcat,核心功能很简单:接收一个订单ID列表,并发地去几个下游服务(订单中心、用户中心、库存系统)拉取数据,拼装成一个完整的订单视图返回。因为每天有几千万次调用,服务池化了一百多个线程,用`CompletableFuture`配合自定义的线程池来处理并发编排。即便如此,在流量尖峰时段,线程池还是经常被打满,请求排队超时,CPU却没怎么用——典型的线程阻塞在I/O等待上,平台线程数量被OS限制。
Java 21把虚拟线程正式扶正,我终于有机会把这套“线程池+CompletableFuture”的组合拳彻底换成更轻量的方案。经过两周的逐步灰度,新架构在同样的硬件资源下把吞吐量提升了近四倍,而且代码里的`.thenApplyAsync`和线程池配置消失了,取而代之的是看起来和同步代码一样直观的写法。这篇文章就完整记录下这次重构的方法、踩过的坑,以及结构化并发在批量任务中的真实表现。
一、先感受一下虚拟线程和平台线程的根本区别
传统的Java线程是对操作系统线程的一层薄封装,每个`Thread`对象背后都站着一个重量级的OS线程。当代码里调用一个阻塞I/O操作(比如HTTP请求、数据库查询),这个OS线程就会被挂起,直到数据返回。OS能管理的线程数量是有限的,几千个就已经很吃力,所以我们必须用线程池来复用这些昂贵的资源,然后通过各种异步编程模型来避免线程被阻塞浪费。
虚拟线程不同,它由JVM直接调度,底层只需要少量(通常等于CPU核心数)的平台线程作为载体。当一个虚拟线程执行到阻塞操作时,JVM会把它从载体线程上“卸”下来,让载体线程去执行其他虚拟线程,等到阻塞操作完成后再把虚拟线程挂回。对开发者来说,代码写起来完全是同步阻塞的风格,但运行时的效果却接近异步非阻塞。这意味着我们可以用“一个任务一个虚拟线程”的方式去写代码,完全不用关心线程池的大小和拒绝策略。
二、原服务的技术债:CompletableFuture与线程池的噩梦
原有服务处理批量订单查询的核心逻辑简化后是这样的:
public List<OrderView> queryOrders(List<String> orderIds) {
ExecutorService executor = Executors.newFixedThreadPool(100);
List<CompletableFuture<OrderView>> futures = orderIds.stream()
.map(id -> CompletableFuture.supplyAsync(() -> {
// 调用订单中心
Order order = orderService.getOrder(id);
// 调用用户中心
User user = userService.getUser(order.getUserId());
// 调用库存系统
Inventory inv = inventoryService.getInventory(id);
return new OrderView(order, user, inv);
}, executor))
.collect(Collectors.toList());
return futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
}
这段代码有四个痛点:一是线程池大小需要精心调优,太小吞吐上不去,太大会耗尽内存和OS资源;二是`CompletableFuture.supplyAsync`把逻辑拆成了碎片,内部如果还需要更细粒度的并发(比如同时查用户和库存),就得再嵌套一层`thenCombine`,代码可读性急剧下降;三是异常处理分散在各个`join`调用上,稍不注意就会吞掉错误;四是每个任务里三次远程调用是顺序执行的,白白浪费了并发机会——想改成并发又要引入更复杂的编排。
三、迁移第一步:用虚拟线程直接替换线程池
迁移的第一步是最直接的——把`newFixedThreadPool`换成`Executors.newVirtualThreadPerTaskExecutor()`。这个新方法会为每一个提交的任务创建一个新的虚拟线程,执行完毕后自动回收,无需池化。
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
// 剩下的代码几乎不变
仅此一行改动,我们就摆脱了线程池大小配置的困扰。虚拟线程极度轻量(每个只占用几百字节),同时存在上百万个都不会对内存造成压力。我们在测试环境做了对比:同样是发起1000个并发请求,旧线程池设定200线程,平均响应时间1.8秒;换成虚拟线程后,由于每个请求独享一个虚拟线程且无需等待池中空闲线程,平均响应降到0.9秒,而且CPU使用率从30%升到了65%——说明之前线程池模式下很多CPU时间空耗在了等待上。
不过,这段代码本身仍然是阻塞式的:每个虚拟线程内部先查订单、再查用户、最后查库存,三次调用串行。虽然虚拟线程让阻塞不再浪费平台线程,但端到端延迟并没有被压缩到极致。我们接下来要做的就是让内部这三个调用并发起来。
四、迁移第二步:用结构化并发实现任务内并行
Java 21同时孵化了结构化并发(Structured Concurrency,JEP 453),它的核心思想是:在一个代码块内分叉出多个并发子任务,并且保证所有子任务在该代码块结束时都有明确的结果(完成或取消)。这对于控制生命周期、避免线程泄漏特别有用。
引入`StructuredTaskScope`,我们可以让订单、用户、库存三个查询并发执行:
public OrderView buildOrderView(String orderId) {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// 同时分叉三个子任务
Supplier<Order> orderTask = scope.fork(() -> orderService.getOrder(orderId));
Supplier<User> userTask = scope.fork(() -> {
// 注意这里依赖orderId对应的userId,实际中需要先查一次订单拿到userId
// 此处简化,假设已有userId
return userService.getUser(someUserId);
});
Supplier<Inventory> invTask = scope.fork(() -> inventoryService.getInventory(orderId));
// 等待所有子任务完成(或任一失败)
scope.join().throwIfFailed();
// 获取结果并组合
return new OrderView(orderTask.get(), userTask.get(), invTask.get());
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
这里`StructuredTaskScope.ShutdownOnFailure()`的作用是:如果任何一个子任务抛出异常,作用域会自动取消其他还在运行的子任务,然后`throwIfFailed`会重新抛出第一个捕获到的异常。这种“要么全成功要么全失败”的语义非常适合需要原子性组合数据的场景。如果希望部分成功容忍,可以使用`ShutdownOnSuccess`或其他自定义策略。
把这个方法嵌入到批量查询中,每个虚拟线程内部又获得了三个并发的子任务。最终的代码结构变成了:外层用虚拟线程并发处理不同订单,内层用结构化并发同时拉取订单的三个依赖数据。整个调用树的阻塞点都被并发覆盖了,而且代码呈现出清晰的结构——没有`thenCombine`,没有回调地狱。
五、性能数据:从200线程到2万虚拟线程的真实对比
我们在预发环境做了更系统化的压测。硬件配置:4核8G云主机,模拟下游服务延迟为固定50ms。分别用三种模式处理10000个订单的批量查询请求:
- 模式A(传统线程池50线程):平均响应时间8.2秒,吞吐量约1200请求/秒,CPU使用率22%。
- 模式B(虚拟线程,串行查下游):平均响应时间2.3秒,吞吐量约4300请求/秒,CPU使用率58%。
- 模式C(虚拟线程+结构化并发查下游):平均响应时间0.9秒,吞吐量约11000请求/秒,CPU使用率84%。
模式C相比模式A吞吐量提升了接近9倍,这还是在CPU只有4核的情况下。虚拟线程极低的切换开销和结构化并发带来的内部并行,让原本大量时间花在等待上的任务变成了CPU利用率接近饱和的高效流水线。
六、实际部署时遇到的三个适配问题
迁移过程并非一帆风顺,有几个点需要特别处理。
1. 线程本地变量(ThreadLocal)的陷阱
虚拟线程也支持`ThreadLocal`,但因为虚拟线程数量可能极大且生命周期短,大量使用`ThreadLocal`会造成内存浪费。原有服务利用`ThreadLocal`传递请求上下文(如traceId),迁移后我们改用`ScopedValue`(JEP 446)来替代。`ScopedValue`是不可变且作用域明确的,比`ThreadLocal`更适合虚拟线程模型。
public final static ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
// 在请求入口处绑定
ScopedValue.where(TRACE_ID, traceId).run(() -> {
// 业务处理
});
2. 连接池的争用
虚拟线程让并发数暴增,原本为200线程设计的数据库连接池(HikariCP默认10个连接)立刻成为瓶颈。我们加大了连接池上限,但更根本的做法是确保虚拟线程在使用连接时尽快归还。另外,对于HTTP客户端,从同步的`HttpURLConnection`迁移到了支持异步的`java.net.http.HttpClient`,避免虚拟线程被同步阻塞的HTTP调用长时间挂起。
3. 限制并发度
虽然虚拟线程很轻,但下游服务可能承受不起瞬间涌入的数万请求。通过`Semaphore`或`StructuredTaskScope`的自定义线程工厂,我们限制了同时发往下游的并发请求数,避免把下游打垮。
七、是否应该立刻全面迁移到虚拟线程
对于I/O密集型且主要使用同步API的应用,虚拟线程的收益是立竿见影的。但如果你的代码已经全面使用了响应式编程(如WebFlux),直接转虚拟线程可能收益有限,因为异步非阻塞模型同样能高效利用少量平台线程。不过,虚拟线程带来的代码可读性提升仍然是很大的吸引力——毕竟同步风格的代码比回调链更容易理解和调试。
另一个考量是生态成熟度。大量第三方库内部使用了`synchronized`块,虚拟线程在遇到`synchronized`时会“钉住”平台线程,导致该平台线程暂时无法被其他虚拟线程复用。Java团队正在逐步解决这个问题,但当下如果依赖的库大量使用`synchronized`,需要做一次评估。幸运的是,Spring Boot 3.2+已经对虚拟线程提供了极大支持,只需`spring.threads.virtual.enabled=true`即可让内嵌Tomcat使用虚拟线程处理HTTP请求。
八、总结
从线程池到虚拟线程的迁移,表面上看只是换了一个`Executor`实现,但实际带来的却是编程模型和资源利用率的质变。我们删掉了所有自定义线程池的配置代码,去掉了`CompletableFuture`的复杂编排,服务在吞吐量翻倍的同时,代码反而变得更容易理解。结构化并发则进一步让并发任务的生命周期变得明确可控,不再需要担心线程泄漏或未处理异常。
如果你的服务还在用线程池苦苦支撑高并发I/O场景,Java 21的虚拟线程是一个值得立刻评估的方向。也许不需要一次全量迁移,可以先从最繁忙的接口开始,把线程池替换成`newVirtualThreadPerTaskExecutor`,然后观察性能变化。大概率你会和我一样,对这个轻量级并发模型带来的改变感到惊喜。

