上周我负责的订单服务被运营人员一通吐槽,说大促期间接口经常超时。我看了眼监控,发现线程池被打满了,请求全在排队。翻代码发现项目里用了一个固定大小20的线程池去查外部服务,一个查询要依次调库存、物流、优惠三个接口,每个接口耗时差不多200ms,这样一个任务就得600ms。20个线程一卡,其他请求全堵住。
本来想提高线程数,但数据库连接池有限制,改配置还得重启。后来想起Java 21早就出了虚拟线程,就把那个线程池换成了虚拟线程,代码改动量小到我自己都吃惊。这里把我的实战过程完整记录下来,不是抄官方文档,是真正踩过坑以后的经验。
虚拟线程到底是什么?说白了就是JVM管理的轻量级线程
我们平时用的线程是操作系统线程,创建、切换、阻塞都挺贵。虚拟线程不一样,它由JVM调度,可以在普通线程(载体线程)上挂载和卸载。当虚拟线程遇到IO阻塞时,JVM会自动把它从载体线程上卸下来,载体线程就能去跑别的虚拟线程,等IO结束了再挂回去。
好处就是:你可以创建成千上万个虚拟线程,不用怕把内存搞爆。而且代码写起来特别像普通线程,不需要重启架构。
最基础的用法:Thread.ofVirtual()
直接看代码,创建一个虚拟线程跑一个任务:
Thread thread = Thread.ofVirtual()
.name("virtual-request-")
.start(() -> {
System.out.println("跑在虚拟线程里");
});
thread.join();
就这么简单。ofVirtual() 返回一个构建器,你可以设置名字和起始任务。如果你更习惯使用线程池,可以用这个:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> result = executor.submit(() -> "异步结果");
System.out.println(result.get());
}
Executors.newVirtualThreadPerTaskExecutor() 会为每次任务创建一个新的虚拟线程,没有池化的概念。很多人会问:没有线程池做复用,性能会不会差?实际上虚拟线程创建成本极低,不值得复用。反而复用虚拟线程会得不偿失。
实战案例:一个订单查询接口的改造
假设我们有一个接口,需要并行查三个外部服务,原来代码用的是固定线程池:
ExecutorService pool = Executors.newFixedThreadPool(20);
public OrderVO getOrderDetail(String orderId) throws Exception {
Future<InventoryVO> inventoryFuture = pool.submit(() -> inventoryService.query(orderId));
Future<LogisticsVO> logisticsFuture = pool.submit(() -> logisticsService.query(orderId));
Future<PromotionVO> promotionFuture = pool.submit(() -> promotionService.query(orderId));
OrderVO vo = new OrderVO();
vo.setInventory(inventoryFuture.get());
vo.setLogistics(logisticsFuture.get());
vo.setPromotion(promotionFuture.get());
return vo;
}
线程池大小是20,但是 200个请求同时进来,每个请求占用一个线程去等待三个外部服务,线程马上耗尽。我改成虚拟线程之后,每个请求都自己创建一个虚拟线程,代码变成这样:
public OrderVO getOrderDetail(String orderId) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<InventoryVO> inventoryFuture = executor.submit(() -> inventoryService.query(orderId));
Future<LogisticsVO> logisticsFuture = executor.submit(() -> logisticsService.query(orderId));
Future<PromotionVO> promotionFuture = executor.submit(() -> promotionService.query(orderId));
OrderVO vo = new OrderVO();
vo.setInventory(inventoryFuture.get());
vo.setLogistics(logisticsFuture.get());
vo.setPromotion(promotionFuture.get());
return vo;
}
}
try-with-resources 确保所有虚拟线程执行完毕,才关闭executor。注意这里 close() 方法会等待所有任务完成,符合我们的语义。
实际压测效果很明显。原来20个线程,每秒只能通过大约30个请求(因为每个请求要占三个线程等600ms)。换用虚拟线程后,同时可以处理的虚拟线程数几乎不受限制,每秒能通过的请求直接涨到90左右。而且我没有改任何数据库连接池,因为真正占用的还是只有三个外部服务连接。
坑一:千万别用虚拟线程池化
我看到有人还在用 Executors.newFixedThreadPool(1000) 那种方式去包虚拟线程,这完全没必要。你可以把虚拟线程理解为“用完即丢”,创建成本几乎是零。如果你把它们池化,反而限制了并发能力,而且代码看起来不伦不类。正确用法就是每次任务创建一个虚拟线程。
坑二:synchronized会让你踩坑
如果你在虚拟线程里用了 synchronized 代码块,并且这个同步块里有IO阻塞操作,那么虚拟线程不会自动卸载,它会钉住载体线程。这可能会导致载体线程被占满。解决办法很简单:尽量用 ReentrantLock 代替 synchronized,或者让 synchronized 块保持很短,里面别做IO。
举一个我碰到的例子:一个旧代码里方法加了 synchronized,里面调了一个远程服务。换成虚拟线程后,并发一高反而出现了一些线程阻塞。排查半天,最后发现在 synchronized 块内调用了外部接口。改成 ReentrantLock 后问题就消失了。
坑三:ThreadLocal要小心
在普通池化线程里,ThreadLocal通常用于传递上下文,比如用户信息、traceId。但虚拟线程数量巨大,而且很多是短生命周期,如果你用了ThreadLocal忘记清理,会占用大量内存。尤其是容器里常用的 ThreadLocal<Map> 这种,每个虚拟线程都会存一份,最后内存就爆了。
替代方案是使用 ScopedValue,这是Java 24里面的新玩意,Java 21用户暂时还是少用ThreadLocal为妙。如果实在要用,记得在 finally 里 remove。
坑四:不要依赖虚拟线程的执行顺序
虚拟线程虽然是顺序创建的,但调度顺序不受保证。代码里如果隐含有先后依赖,记得用 Future.get() 或者手动等待。不要用 Thread.sleep() 去强行控制,这不靠谱。
如何把现有项目平滑迁移到虚拟线程
我第一次迁移没有直接把所有线程池都推翻,而是从几个关键业务开始试点。步骤大概是:
先找代码里的 ExecutorService 声明,看它们是用来做并行调用还是做异步任务。对于那种“一个任务拆成多个子任务,最后合并结果”的场景,最适合改虚拟线程。对于真正的后台定时任务,比如每5分钟跑一次的批处理,没必要改。
把普通的 newFixedThreadPool(20) 直接替换成 Executors.newVirtualThreadPerTaskExecutor(),然后删除原来的线程池变量。如果你原来用 ThreadPoolExecutor 自定义了队里和拒绝策略,改成虚拟线程后这些统统不需要了,代码还更简单。
改完以后把依赖 synchronized 的代码顺手检查一遍,尤其是那些在同步块里调RPC、HTTP的地方。这种地方是最容易埋雷的。
然后测试环境压一波接口,看看RT和吞吐量的变化。我自己的项目改造完以后,吞吐量确实上去了,但内存占用也稍微高了一点点,其实主要是虚拟线程的栈区,不过比起它带来的并发能力提升,这点内存完全值。
总结一下我的真实感受
虚拟线程并没有改变Java的并发模型,它只是让“每个任务一个线程”变成了一种可落地的选项。以前我们要不断调线程池大小、调队列长度、处理拒绝策略,现在这些通通不用管。你把“一个任务对应一个线程”这套写出来,剩下的交给JVM。
现在我的订单服务已经全面切到虚拟线程了,代码看着清爽,线上也稳定。如果你还在用Java 8或11,也许会觉得这些和自己没关系。但升级到Java 21其实没那么困难,项目里大多数代码都能无缝跑通。我觉得这波升级,值。

