上个月公司订单服务突然告警,峰值QPS时线程池队列积压到了几千,接口响应时间从几十毫秒飙到好几秒。运维紧急扩容,加了二十个实例才勉强扛住。事后复盘,问题根源并不在业务逻辑,而是传统的线程模型在面对突发流量时太“重”了:一个请求一个平台线程,池子大小固定,一旦线程耗尽,请求就得排队,即使CPU还很空闲。
恰好那之前我在个人项目里试用过Java 21的虚拟线程(Project Loom),于是提议重构一部分关键路径。迁移过程比预想的简单,效果却很惊人——同样的机器配置,用虚拟线程改造后的服务不仅能撑住两倍以上的峰值,内存占用反而下降了。这篇文字就是那次实践的还原,给同样受困于线程模型的朋友一个可操作的参考。
为什么平台线程不够用了
在讲虚拟线程之前,先回顾一下我们熟悉的线程模型。Java中的Thread实例直接对应操作系统的内核线程,创建成本高、占用内存大(每个线程默认栈大小约1MB),而且线程上下文切换涉及内核态和用户态转换,开销不小。因此,绝大多数应用都采用线程池来复用线程,并严格控制线程数量。
这种模式在处理计算密集型任务时还算称职,但现代Web服务中大部分时间其实是在等待:等待数据库查询、等待远程API调用、等待文件I/O。等待期间,平台线程被阻塞却耗尽珍贵的系统资源,导致能同时处理的请求数被线程池大小死死限制。即便CPU无事可做,请求也只能在队列里排队。
虚拟线程的出现,正是为了解决这个痛点。
虚拟线程是什么
虚拟线程是JDK 21正式引入的轻量级用户态线程。它们是JVM层面的实现,不与操作系统线程直接一一对应。创建虚拟线程的成本极低,几乎可以像创建普通对象一样随意触发。当一个虚拟线程阻塞(比如等待I/O)时,JVM会自动把它从底层平台线程“卸下”,让那个平台线程去运行另一个虚拟线程。等到阻塞解除,虚拟线程会被重新调度到可用的平台线程上继续执行。
这样一来,少量平台线程就能支撑成千上万个虚拟线程,而且切换完全在用户态进行,没有内核开销。这也意味着你不再需要为了控制资源而小心翼翼维护线程池,可以很自然地用“一个任务一个线程”的直白方式编写代码。
用一句不太严谨但直观的话概括:虚拟线程让阻塞操作变得廉价,让同步代码也能达到异步框架的吞吐量。
第一个虚拟线程
用起来非常简单。创建一个虚拟线程并执行:
Thread.ofVirtual()
.name("my-virtual-thread")
.start(() -> {
System.out.println("我在虚拟线程里运行");
});
也可以通过新的Executors.newVirtualThreadPerTaskExecutor()获得一个执行器,每次提交任务都会分配一个新的虚拟线程:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> fetchFromRemote());
executor.submit(() -> fetchFromRemote());
}
这个执行器不需要指定线程数,也不存在队列积压,提交即执行。当然,真正执行仍然受限于内核线程数量,但对上层代码来说完全是透明的。
改造订单服务:从线程池到虚拟线程
我们最早的订单服务基于Spring Boot,使用Tomcat作为嵌入式容器。Tomcat默认用线程池处理请求,最大线程数设为200。流量一上来,200个线程很快被占满,剩余的请求全部涌入等待队列。
重构的第一步,是让Spring Boot使用虚拟线程处理HTTP请求。Spring Boot 3.2(基于Spring Framework 6.1)已经提供了自动配置支持,只需在application.properties中加入一行:
spring.threads.virtual.enabled=true
这会让Tomcat的请求处理线程以及@Async标注的方法都运行在虚拟线程上。如果没有使用Spring Boot 3.2,也可以手动配置:
@Bean
public TomcatProtocolHandlerCustomizer protocolHandlerVirtualThreadCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
这一步改完,请求处理就不再受线程池上限约束了。紧接着,我们检查了服务内部所有可能阻塞的地方——主要是数据库调用和远程服务调用。原来的JdbcTemplate和RestClient都是同步的,在虚拟线程里却可以放心大胆地阻塞,JVM会智能调度,不会白白占用平台线程。
结构化并发:让任务管理更可靠
重构过程中,我们还用上了随虚拟线程一同推出的结构化并发API(StructuredTaskScope)。订单服务有一个场景需要同时调用库存、价格、用户三个接口,然后汇总结果。原来我们用CompletableFuture来组合,但异常处理和取消逻辑写得比较分散。改用结构化并发后,代码清晰了很多:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future inventory = scope.fork(() -> fetchInventory(orderId));
Future price = scope.fork(() -> fetchPrice(orderId));
Future user = scope.fork(() -> fetchUserInfo(userId));
scope.join(); // 等待所有子任务完成
scope.throwIfFailed(); // 任一子任务失败则抛出异常
return new OrderSummary(inventory.resultNow(), price.resultNow(), user.resultNow());
}
这里的fork()会在新的虚拟线程中执行任务,所有子任务的生命周期被限定在try-with-resources块内,作用域结束时会自动等待并取消未完成的任务。相比之前随手开线程的方式,这种“结构化”让代码意图更明确,泄漏线程的风险也降低了。
性能对比数据
我们在测试环境用相同配置的8核16GB机器做了对比。模拟3000并发请求,每个请求内部会 sleep 80ms 模拟数据库查询。
- 传统线程池(200线程):吞吐量徘徊在每秒800左右,CPU使用率不足15%,大量时间花在线程切换和等待上,请求平均延迟超过2秒。
- 虚拟线程模式:吞吐量直接提升到每秒2700以上,CPU使用率稳定在60%左右,平均延迟降到200ms以内。JVM使用的平台线程数量始终保持在20个左右,虚拟线程数量飙升到3000,但内存开销并无明显增加。
这组数据和我们遇到的实际场景基本吻合:虚拟线程尤其擅长处理I/O密集、并发量大的任务,它能释放平台线程的约束,让CPU真正忙起来。
使用虚拟线程要留意的几点
虽然虚拟线程很香,但并不是银弹。
- 不要池化虚拟线程:虚拟线程的设计初衷就是随用随弃,池化它们不仅多余,还可能引入新的问题。直接用
newVirtualThreadPerTaskExecutor或Thread.ofVirtual()即可。 - 避免长时间持有锁:如果虚拟线程在执行
synchronized代码块时阻塞,它绑定的平台线程也会被一并锁住,无法被其他虚拟线程使用。这种情况可以用ReentrantLock替代synchronized,或者将阻塞操作移到锁外。 - 谨防线程局部变量膨胀:因为虚拟线程数量没有上限,滥用
ThreadLocal可能导致内存快速增长。考虑使用作用域值(Scoped Values)作为更安全的替代。 - CPU密集型任务收益有限:如果任务本身纯计算,几乎没有阻塞,虚拟线程并不会带来吞吐量的提升,反而可能因为任务过多导致上下文切换增多。这类任务还是适合用有限线程池控制并发量。
总结
从我们的实际迁移体验来看,虚拟线程最宝贵的地方并不是技术上的“新”,而是思维模式的简化——它让你重新可以用同步、直白的方式写出高并发程序,不再需要在异步回调、响应式流或者复杂的线程池参数之间纠结。Spring Boot、Tomcat、数据库驱动都在迅速适配这一特性,对绝大多数后端开发者来说,切换到虚拟线程几乎不需要修改业务代码。
如果你的服务正面临相似的并发瓶颈,或者被线程池配置折磨已久,虚拟线程值得你花一个下午来试一试。它可能不会让你的代码快十倍,但会让你的服务在面对流量洪峰时,从容得多。

