上个月公司搞了个限时秒杀活动,活动页需要同时拉取商品信息、库存、用户优惠券、物流预估、推荐列表等十几个维度的数据。上线第一版的时候,页面加载竟然要将近一分钟,用户投诉电话直接打爆了客服。排查下来发现瓶颈不在数据库,也不在下游服务,而是我们自己的Java服务在并发编排上写了太多锅碗瓢盆的等待逻辑。后来切到Java 21的虚拟线程,响应时间直接从60秒降到了3秒以内。趁这次经历还热乎,把整个过程记下来。
原来的写法是怎么把性能吃光的
先描述一下活动页的逻辑:前端发一个请求过来,后台要调用大概20个独立的下游接口,每个接口返回时间在200毫秒到800毫秒之间。这些接口之间没有依赖关系,理论上可以全部并发调用,最慢的那个返回了整体结果也就出来了。
问题出在我们当时的实现方式上。团队习惯用CompletableFuture配合一个固定大小的线程池来做并发编排,大概长这样:
// 旧版本代码
ExecutorService executor = Executors.newFixedThreadPool(10);
List<CompletableFuture<Object>> futures = tasks.stream()
.map(task -> CompletableFuture.supplyAsync(() -> callDownstream(task), executor))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
线程池大小设成了10,表面上看20个任务分两批执行,每批等最慢的那个800毫秒,加起来也就1.6秒。但实际跑起来根本不是这么回事。下游接口偶尔会出现超时重试,某些任务一卡就是三四秒,把线程池里的线程全部占满,后面的任务只能干等。更要命的是,线程池满了之后,连Tomcat的请求处理线程都被拖住,整个服务的吞吐量断崖式下跌。
我们也试过把线程池调到50甚至100,但平台线程的创建和切换开销太大了,内存也扛不住。调大之后线程上下文切换的CPU消耗反而让响应时间变得更长。这是个典型的「线程池两难」问题。
虚拟线程解决的根本问题
Java 21正式发布的虚拟线程,最核心的概念就是把线程的调度从操作系统层面移到了JVM层面。一个虚拟线程在等待IO(比如等待下游接口返回)的时候,底层的载体线程(也就是平台线程)会被释放去执行其他虚拟线程。这样几万个虚拟线程可以同时跑在几十个平台线程上,而不会互相阻塞。
这意味着我们可以给每个下游调用都分配一个独立的虚拟线程,20个任务就创建20个虚拟线程,它们几乎同时发出请求,然后全部进入等待状态。载体线程的数量很少,切换成本也低,不会出现线程池耗尽的问题。再也不用纠结线程池大小设多少了。
迁移到虚拟线程的代码改动小到让人不敢相信。Spring Boot 3.2已经内置了对虚拟线程的支持,只需要在配置文件里加一行:
spring.threads.virtual.enabled = true
然后原来用线程池的地方全部换成虚拟线程执行器:
// 新版本代码
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
List<Future<Object>> futures = tasks.stream()
.map(task -> executor.submit(() -> callDownstream(task)))
.toList();
for (Future<Object> future : futures) {
future.get(); // 这里阻塞的是虚拟线程,不消耗平台线程
}
核心变化就是把newFixedThreadPool换成了newVirtualThreadPerTaskExecutor。其他业务代码一行没改。部署上去之后第一次压测,20个并发任务场景下的响应时间从之前的60秒降到了2.8秒,基本等于最慢那个下游接口的响应时间。
更进一步的优化:StructuredTaskScope
单纯换成虚拟线程已经解决了主要问题,但Java 21还提供了一个更适合这种场景的工具:StructuredTaskScope。它能让我们用结构化的方式管理一组并发任务,并且支持在第一个任务失败时立即取消其他任务,或者在所有任务完成后统一收集结果。
我们活动页的场景用StructuredTaskScope改写后,代码逻辑更清晰:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// 同时提交所有下游调用
Subtask<ProductInfo> productTask = scope.fork(() -> fetchProductInfo());
Subtask<InventoryInfo> inventoryTask = scope.fork(() -> fetchInventory());
Subtask<CouponInfo> couponTask = scope.fork(() -> fetchCoupons());
Subtask<LogisticsInfo> logisticsTask = scope.fork(() -> fetchLogistics());
Subtask<RecommendInfo> recommendTask = scope.fork(() -> fetchRecommendations());
// ... 其他十几个任务
scope.join(); // 等待所有任务完成
scope.throwIfFailed(); // 任意一个失败就抛异常
// 所有结果都成功拿到,组装返回
return new ActivityPageResult(
productTask.get(),
inventoryTask.get(),
couponTask.get(),
logisticsTask.get(),
recommendTask.get()
);
}
这种写法有几个好处:
- 生命周期自动管理。try-with-resources确保所有子任务在代码块结束时都已完成或被取消,不会出现线程泄漏。
- 失败快速传播。任何一个子任务失败,整个scope会立刻感知并取消其他还在跑的任务,避免做无用功。
- 可读性高。以前用
CompletableFuture编排十几个任务,代码缩进能超过四层,现在扁平化的结构谁都能一眼看懂。
数据库连接池的坑:虚拟线程放大了一直存在的问题
换上虚拟线程之后,性能确实暴涨,但测试环境跑着跑着突然开始报无法获取数据库连接。查了半天才发现,虚拟线程把并发量提上去之后,数据库连接池的默认大小(比如HikariCP默认10个连接)成了新的瓶颈。
以前用10个平台线程的时候,同时最多也就10个数据库查询在跑,连接池刚好够用。现在虚拟线程一口气发出20个甚至更多请求,如果每个请求都要查数据库,连接池瞬间就被打满。
我们的解决方案分了三步:
- 增大连接池。把HikariCP的
maximumPoolSize从10调到30。这一步最简单,但不能无脑加大,得看数据库的承受能力。 - 引入本地缓存。那些跟用户无关的公共数据(比如商品基础信息、物流模板),在服务启动时预热到Caffeine本地缓存里,减少数据库访问。
- 拆分数据源。把高频查询的表和低频查询的表分到不同的连接池,避免互相挤占资源。
调整完之后这个错误就没再出现过。但也算学到一课:虚拟线程不会创造新的瓶颈,但会把旧系统里原本被平台线程数量掩盖的短板全部暴露出来。
线程局部变量的兼容问题
另一个在实际迁移中碰到的问题是ThreadLocal。我们项目里用ThreadLocal传递请求上下文(用户ID、traceId之类)。虚拟线程跟平台线程不同,它的生命周期很短,用完就回收,但ThreadLocal的值如果没清理,可能会被下一个复用同一个载体线程的虚拟线程读到。
虚拟线程的载体线程是固定的几个,所以一个虚拟线程在执行过程中可能会切换载体线程。这意味着ThreadLocal的值不一定能在整个虚拟线程生命周期里保持一致。
解决办法是用Java 21同期引入的ScopedValue来替代ThreadLocal:
// 旧的ThreadLocal方式
private static final ThreadLocal<String> traceIdHolder = new ThreadLocal();
// 新的ScopedValue方式
private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
ScopedValue.where(TRACE_ID, currentTraceId).run(() -> {
// 在这个代码块里,TRACE_ID的值始终是currentTraceId
// 不管虚拟线程切换到哪个载体线程
doBusinessLogic();
});
ScopedValue是绑定到代码块的,不是绑定到线程的,完美适配虚拟线程的调度模型。而且它的语义更明确,不会出现忘记清理的情况。
压测数据对比
上线前我们在同样配置的机器上做了三组对比测试,每组模拟500个并发用户访问活动页:
| 方案 | 平均响应时间 | P99响应时间 | 最大并发线程数 | CPU使用率 |
|---|---|---|---|---|
| 固定线程池(大小10) | 34.2秒 | 61.8秒 | 10 | 18% |
| 固定线程池(大小100) | 12.5秒 | 28.3秒 | 100 | 47% |
| 虚拟线程(无限制) | 1.9秒 | 2.7秒 | 约500个虚拟线程 | 22% |
| 虚拟线程+ScopedValue | 1.8秒 | 2.6秒 | 约500个虚拟线程 | 21% |
虚拟线程方案不仅在响应时间上碾压了线程池方案,CPU使用率反而更低。原因很简单:线程池方案里平台线程大量时间花在等待IO上,白白占用CPU调度资源。虚拟线程把等待时间从CPU上剥离了出去,真正干活的时候才占用载体线程。
什么时候不适合用虚拟线程
虚拟线程也不是万能药。有两种场景需要警惕:
- 纯CPU密集型任务。如果任务一直在做计算,没有IO等待,虚拟线程的优势完全发挥不出来。这种情况下用传统线程池更合适,因为虚拟线程的调度反而成了额外开销。
- 频繁synchronized的代码块。虚拟线程在执行
synchronized块时会把底下的载体线程一起锁住,导致其他虚拟线程无法使用这个载体线程。如果大量虚拟线程争同一个锁,性能反而可能比平台线程更差。这种情况需要把synchronized替换为ReentrantLock。
好在我们活动页的场景是典型的IO密集型,而且代码里几乎没有显式加锁的地方,所以迁移过程没有触发这些问题。
迁移清单:一步步来,别一把梭
从这次经验出发,我整理了一个虚拟线程迁移的实操步骤,供参考:
- 确认运行环境。Java 21以上,Spring Boot 3.2以上。如果是旧版本Spring Boot,也可以手动配置虚拟线程,但用内置开关最省心。
- 找出IO密集型任务。把那些大部分时间花在等待数据库、下游接口、消息队列的地方圈出来,这些是迁移收益最大的。
- 先把线程池换成虚拟线程执行器。这一步风险最低,改完就有效果。
- 排查ThreadLocal。全局搜索项目里的
ThreadLocal,逐个评估是否需要迁移到ScopedValue。 - 压测观察连接池。数据库连接池、Redis连接池、HttpClient连接池,全部检查一遍,该加大的加大。
- 处理synchronized。如果性能不如预期,排查是否有
synchronized竞争,有的话换成ReentrantLock。
写在最后
这次活动页的优化经历让我对虚拟线程有了更具体的认识。它不是什么黑科技,只是在底层把线程调度这件事重新做了一遍,但带来的影响波及了整个并发编程模型。以前写Java并发代码总要掂量线程池大小、任务队列长度、拒绝策略,脑子里得时刻绷着一根资源管理的弦。虚拟线程出现之后,并发编程一下子变简单了——有多少任务就开多少虚拟线程,把精力集中在业务逻辑上。
当然,虚拟线程替代不了分布式架构、缓存策略这些更高层面的设计,但在单服务内部,它把以前需要精心调优才能压出来的性能,变成了一件开箱即用的事情。如果你的项目还在用传统的线程池扛高并发,强烈建议在下一个迭代窗口里试试这条路,改造成本比想象中低得多。

