去年写完一个基于Netty的网关,单机撑到四万连接,线程池调了一周参数,CPU还是经常飙到90%。后来看到Java 21终于正式发布了虚拟线程,第一反应是“这不就是协程套了个Java的壳吗”。可等我把手上一个老项目的线程池换掉以后,才发现这玩意比我预想的还要实在。
这篇文章不讲虚的,直接用一个真实的订单推送场景来串一遍。先给你看一段常见到不能再常见的Java线程代码,然后我们把里面所有涉及阻塞等待的地方换成虚拟线程,看看到底好在哪。
先从最大的痛点讲起:普通线程有多浪费
传统的线程,本质上是操作系统的线程。创建一个线程,操作系统要给它分配独立的栈空间,还要参与调度,光是上下文切换就得保存一堆寄存器状态。一台普通服务器能开的线程数量,基本就是几千,撑死一万出头。
但更麻烦的问题不是线程数,而是绝大多数线程闲着的时间太长。比如下面这段代码:
public String fetchOrder() {
// 查询数据库,耗时 50ms
Order order = orderDao.selectById(123);
// 调远程库存服务,耗时 80ms
Stock stock = stockClient.query(order.getSkuId());
// 组装返回,耗时 1ms
return buildResponse(order, stock);
}
这段代码执行的时候,大部分时间都在等待数据库返回、等待HTTP接口返回。此时线程池里的线程就傻乎乎地占着资源,什么事也不干,光等。一个线程占1MB栈空间,1000个并发请求差不多就要额外占用1GB内存,这还不算操作系统线程调度开销。
而虚拟线程,Java的说法叫“平台线程背后的轻量级调度单元”。虚拟线程被JDK自己管理,它底层是用一个或几个平台线程来跑任务。当虚拟线程遇到锁、IO、网络等待时,JDK会让出当前平台线程,去执行其他虚拟线程。就这么一变,等待的时间被彻底榨干了。
第一个能跑的例子:几行代码创建一万个虚拟线程
创建虚拟线程的API简单得让人怀疑:
public static void main(String[] args) throws Exception {
// 使用Thread.ofVirtual()工厂方法
Thread vThread = Thread.ofVirtual().start(() -> {
System.out.println("虚拟线程运行中: " + Thread.currentThread());
});
vThread.join();
}
更常见的是用ExecutorService来调度任务,Java 21给ExecutorService提供了新的工厂方法:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
try {
// 模拟一个阻塞操作
Thread.sleep(10);
System.out.println("任务 " + i + " 完成");
} catch (InterruptedException ignored) {
}
});
});
}
这段代码里,Executors.newVirtualThreadPerTaskExecutor() 会为每个提交的任务创建一个新的虚拟线程。注意这里没有线程池上限,你完全可以提交十万个任务。十万个虚拟线程同时sleep 10毫秒,底层只用了几个平台线程。这在以前想都不敢想。
真实案例:把订单推送改造成虚拟线程模型
背景是这样的:我有一个订单状态变更服务,每当订单状态变化,就需要通知用户小程序、更新本地缓存、调用外部物流API。老代码用的是固定线程池,大小20,如果上游接口变慢,请求队列越积越长,最后整个订单服务跟着雪崩。
改造方案很简单:把原来线程池执行的任务改成虚拟线程执行。
先看老代码大概长什么样:
private final ExecutorService pushPool = Executors.newFixedThreadPool(20);
public void pushOrderStatus(Long orderId) {
pushPool.submit(() -> {
// 1. 给用户小程序推送
wxNotify.notify(orderId);
// 2. 更新缓存
cacheManager.update(orderId);
// 3. 调用外部物流API
logisticsClient.sync(orderId);
});
}
我把这个pushPool换成了虚拟线程执行器,只改一行构造:
private final ExecutorService pushPool = Executors.newVirtualThreadPerTaskExecutor();
然后诡异的事发生了:任务不再排队了,因为每次提交都直接新起一个虚拟线程去执行。外部物流API偶尔慢个几秒,后面推送的任务完全不受影响,不会再排队占内存。
因为虚拟线程的创建成本非常低,你尽管提交任务,JDK会在底层平台线程上调度它们。从业务视角来看,每个任务就像有一个专用线程。
别急着欢呼,有几个坑得提前知道
刚上手虚拟线程,最容易踩的就是synchronized关键字。
虚拟线程虽然轻量,但如果代码里用了synchronized来锁一段代码,那么当虚拟线程进入synchronized块并发生阻塞时,JDK没法让出平台线程去执行其他虚拟线程,只能把这个平台线程也卡住。一旦大量虚拟线程同时卡在synchronized上,你的平台线程就全被占满了,后果还是挺难受的。
解决办法是尽量使用ReentrantLock替代synchronized。因为ReentrantLock在阻塞时会调用LockSupport.park,而虚拟线程在park时会自动释放底层平台线程。
另一个坑是:别在虚拟线程里调用一些JNI方法或者本地方法,因为这些方法会阻塞平台线程。如果实在避不开,就用固定线程池单独处理。
还有一个很多人容易误解的地方:虚拟线程不是为了让CPU跑得更快,它是为了解决“等待型”任务的并发瓶颈。如果你的任务全是CPU密集型的,那虚拟线程反而会因为调度开销多一点,没有优势。
结构化并发:顺手把线程管理也规范了
Java 21不单带来了虚拟线程,还带来了结构化并发,就是让代码里创建的子线程和任务,跟编写代码的逻辑结构保持一致。
以前我们处理并发请求,喜欢用ExecutorService.submit()提交多个任务,然后把Future收集起来,最后等待结果。这有个问题:如果其中一个任务报错,你很难把其他的子任务取消掉,子线程可能还挂在那里跑。
结构化并发的思路很简单:用一个作用域来管理所有子任务。看这个例子:
public String fetchMultiData() throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<Order> order = scope.fork(() -> orderDao.selectById(1));
Supplier<Stock> stock = scope.fork(() -> stockClient.query(1001));
scope.join();
scope.throwIfFailed();
return buildResponse(order.get(), stock.get());
}
}
这里的StructuredTaskScope会等待所有fork出来的子任务执行完毕。如果任何一个子任务抛异常,那就快速关闭其他子任务,避免无谓等待。
用虚拟线程执行这些子任务时,每个子任务都是一个虚拟线程,创建开销几乎可以忽略。这种写法不仅读起来舒服,最重要的是不会出现线程泄漏。
我在写库存预扣接口时用了这个,代码直接短了三分之一,原来手动管理Future、try-catch的老套路全都不需要了。
实际压测一下,数字出乎意料
为了看看虚拟线程的威力,我写了一个模拟阻塞接口的Demo:每个请求进来后强制sleep 100毫秒,模拟外部数据库耗时。然后用JMeter压,500线程并发,分别测固定线程池和虚拟线程执行器。
固定线程池(核心线程20,最大线程100):每秒处理大约900个请求,CPU利用率45%。
虚拟线程执行器(每次任务一个虚拟线程):每秒处理大约2500个请求,CPU利用率只有38%。
更夸张的是,当把并发请求数提到5000时,固定线程池直接抛了RejectedExecutionException,而虚拟线程照样每分钟跑2万多个请求。内存占用也只比空闲时多了200多MB。放在以前,5000个并发线程早把内存撑爆了。
当然这只是一个模拟场景,真实业务还会有锁竞争、连接池限制等问题。但至少可以说明,虚拟线程确实能有效提升高并发下的吞吐量。
什么时候不用换?我给个务实建议
如果你的应用已经在用WebFlux或者Vert.x那种响应式模型,代码已经异步到底了,那就不太需要虚拟线程。因为虚拟线程的编程模型是“一个请求一个线程”,跟响应式那种回调地狱完全是两个路子。强切会非常痛苦。
另外,如果你的应用只是内部管理后台,并发量很小,每个请求处理时间很短,换不换都无所谓。虚拟线程不是银弹,它只在你被线程阻塞严重困扰时,才值得付出迁移成本。
但对于像我这边的交易服务,每次请求动辄查多个外部系统,动不动IO等待,真的就是虚拟线程的主场。把线程池换成虚拟线程,代码改动量极小,收益却非常直观。
总结:虚拟线程不是新概念,但值得认真用
Java 21这个版本把虚拟线程带到正式环境,等于给传统Java并发编程续了一波大命。未来写Java服务,或许就像写Go的goroutine一样,不必总想着怎么压榨线程池,直接开一个虚拟线程就完事。
可能有些朋友觉得虚拟线程对标的是“协程”,是Python或Node里的东西。但Java这里有一点不一样:它保持了对现有代码的兼容,几乎不用改任何框架,IO操作就自动变成可并发。这一点是很多语言羡慕的。
如果你手头正好有一个动不动线程池被打满的项目,我建议你花一个下午,把ExecutorService换成虚拟线程版本,跑一遍压测,你会回来点赞的。
真不是玄学,是基础技术升级带来的安全感和自由。

