先诉个苦。上个月我们负责的一个定时任务每天凌晨跑,逻辑很简单:从公司内部订单系统拉取昨天的数据,调用第三方物流平台查询状态,然后更新到本地数据库。订单量大概八千笔。老代码用的是固定线程池,核心线程数设了50,阻塞队列容量给了2000。之前跑了大半年,一直没出什么大问题。
结果上个月活动大促,订单涨到一万六。那天凌晨两点,系统报警,任务到了早上七点还没跑完,并且线程池队列满了,后续任务全部抛出RejectedExecutionException。
我当时看到日志里异常堆栈指向线程池拒绝,第一反应是调大核心线程数到100或者200,然后加大队列。但又想一想,这活儿主要是IO等第三方响应,线程大部分时间都在阻塞,即使调到两百线程,也还会有任务排队。于是通宵加班改代码,活生生用CompletableFuture改了一版异步批量请求,虽然跑完了,但代码写了一坨,维护起来真想骂人。
后来无意中试了Java 21的虚拟线程,我整个人都快裂开了:原来处理这种“高并发阻塞IO”的老大难,现在只需要把每一条调用扔进一个虚拟线程里,让它随便阻塞,爱阻塞多久就阻塞多久,毫无心理负担。花了大概半小时重写,代码简洁到不敢置信。
今天就把这个实验过程记录下来,希望能帮你以后少写点恶心人的异步代码。
先弄明白虚拟线程到底优化了什么
传统线程也叫“平台线程”,是操作系统调度的老底子。创建它、切换它都要陷入内核,开销很大。而且内存方面默认每个线程栈就有1MB左右。想在一台2C4G的服务器上开两万个平台线程,内存直接爆炸。
虚拟线程不是这样。它是由JVM自己调度的“用户线程”,跑在普通的平台线程上。更关键的是,它就是为解决“阻塞时占用平台线程”而生的:当虚拟线程里执行IO操作或者出现LockSupport.park时,JVM会把这个虚拟线程从当前平台线程上摘下来,而平台线程可以去执行其他虚拟线程。也就是说,阻塞变便宜了,挂起一个虚拟线程的开销比挂起一个平台线程小几个数量级。
所以,虚拟线程的应用场景从来不是CPU密集型计算,而是大量阻塞等待的IO型任务。比如RPC调用、HTTP请求、数据库查询、读写文件。你不需要写任何回调,也不需要搞响应式,只用最朴素的同步代码,就能获得超高的并发吞吐,这就是它的价值。
先用老办法做一个性能基准
为了说明问题,我写了一个简洁的demo。假设每天要查询8000个外部接口地址,每个接口模拟耗时100毫秒。因为网络请求大部分时间都在等待对方服务端响,我们用Thread.sleep模拟阻塞。
第一种方式,用固定线程池200个线程提交任务:
public class OldStyleTask {
public static void main(String[] args) throws Exception {
// 模拟几千个外部调用
List<String> apiUrls = IntStream.range(0, 8000)
.mapToObj(i -> "http://thirdpart/order/" + i)
.collect(Collectors.toList());
ExecutorService pool = Executors.newFixedThreadPool(200);
long start = System.currentTimeMillis();
List<CompletableFuture<String>> futures = apiUrls.stream()
.map(url -> CompletableFuture.supplyAsync(() -> callApi(url), pool))
.collect(Collectors.toList());
// 等待所有完成
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
System.out.println("耗时: " + (System.currentTimeMillis() - start) + " ms");
pool.shutdown();
}
private static String callApi(String url) {
try {
// 模拟阻塞网络耗时
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return url + " ok";
}
}
8000个任务,每个阻塞100ms,200个线程并发,理论总耗时=8000*(1/200)*100=4000ms。实际CPU切换一些开销,跑了约4.3秒。如果你在用单线程,那耗时会接近800秒。
再改成虚拟线程
改动非常戏剧性。只需要把Executors.newFixedThreadPool(200)换成Executors.newVirtualThreadPerTaskExecutor()。这一行代码,让每个任务都直接开一个虚拟线程执行,相当于每个任务都有独立“线程”去等响应,还不用池化。
public class VirtualTask {
public static void main(String[] args) throws Exception {
List<String> apiUrls = IntStream.range(0, 8000)
.mapToObj(i -> "http://thirdpart/order/" + i)
.collect(Collectors.toList());
// 关键代码:把原来的线程池换成虚拟线程执行器
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
long start = System.currentTimeMillis();
List<CompletableFuture<String>> futures = apiUrls.stream()
.map(url -> CompletableFuture.supplyAsync(() -> callApi(url), executor))
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
System.out.println("耗时: " + (System.currentTimeMillis() - start) + " ms");
}
}
private static String callApi(String url) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return url + " ok";
}
}
结果怎么着?耗时大约120~130毫秒,基本就是每个接口网络耗时那100ms加上一点创建开销。加速比接近30倍。
你可以试着把数量从8000改成十万,效果更加离谱。固定线程池会排队排到猴年马月,而虚拟线程依然保持总耗时约100ms左右的水平(前提是每台机器的网络并发能力足够)。
别以为虚拟线程就不需要控制并发数
看到这里,你是不是想在自己项目里把所有线程池都换成虚拟线程?我劝你先冷静一下。虚拟线程不是银弹,它有一个很现实的限制:如果同时创建几十万个虚拟线程,且每个都马上发起数据库连接或者外部TCP连接,那么外部系统一定会被搞挂,或者数据库连接池直接爆掉。
虚拟线程本身廉价,但下游服务的连接数是有上限的。所以我们仍然要对并发量做限流,只不过不再是用“线程数”来限制,而是用信号量或者其他限流器。
举个例子,我不想一次性让一万个HTTP请求打爆对方服务,那就可以给调用动作加一个Java信号量:
Semaphore semaphore = new Semaphore(200); // 最多同时200个外部请求
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
List<CompletableFuture<Integer>> futures = ids.stream().map(id ->
CompletableFuture.supplyAsync(() -> {
semaphore.acquireUninterruptibly();
try {
return requestExternal(id);
} finally {
semaphore.release();
}
}, executor)
).collect(Collectors.toList());
这样既能享受虚拟线程的轻量,又能控制下游压力。之前用固定线程池,其实也是用“线程数”间接限流,但代价是遇到高延迟的慢接口时,线程全被占满,新任务排长队。虚拟线程下,你可以把“信号量”设计成更贴近逻辑的阈值,比如外部服务允许的最大QPS,而不是纠结线程池怎么设。
实际业务中的代码改造经验
我拿最先说的那个订单查询任务来做示范,之前分了好几步,每步都要异步编排,现在虚拟线程改写后,代码结构近乎同步代码。
大致思路:用一个虚拟线程执行器,主线程把一个订单id列表里的每一个订单封装成一个java任务,这个任务内部依次做如下操作:
- 通过本地RPC调用订单系统拿订单详情
- 再基于订单详情调用第三方物流status
- 把结果写入本地数据库
因为这三步全部是同步阻塞调用,在虚拟线程里可以老老实实地用返回值拿结果,完全不用写回调状态机。
void processOrder(String orderId) {
try {
// 1. 从订单服务拿详情(网络IO阻塞)
OrderInfo info = orderService.getDetail(orderId);
// 2. 调用第三方物流查询(网络IO阻塞)
LogisticsStatus status = logisticsClient.query(info.getTrackingNo());
// 3. 更新本地库(JDBC阻塞)
orderDb.updateStatus(orderId, status.name());
log.info("order {} updated: {}", orderId, status);
} catch (Exception e) {
log.error("Failed to process order {}", orderId, e);
}
}
启动虚拟线程处理所有订单:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (String orderId : orderIds) {
executor.submit(() -> processOrder(orderId));
}
} // 等待所有虚拟线程结束,不需要shutdownNow,全部任务完成之后线程结束
如果我想在全部任务结束后汇总成功失败的数量,就可以用几个AtomicInteger计数器,也可以用后面讲的“结构化并发”来写。
看到没有,这才是根植于程序员直觉的代码。没有CompletableFuture的各种thenCompose、thenCombine,没有ExecutorCompletionService,代码从上到下,就像在单线程里处理一个订单一样,只不过是以并发方式执行。调试和排查问题都容易得多,因为线程栈上能清晰看到卡在哪一步IO。
结构化并发让批处理更优雅
Java 21还有一个预览特性叫“结构化并发”(Structured Concurrency),后面版本在继续打磨。我现在用起来非常舒服,因为它的API简直是为“批量任务并聚合结果”量身定制的。
过去的并发批处理,我们得用CountDownLatch或者FutureList一堆操作去等待全部子任务。结构化并发提供了一个StructuredTaskScope,可以创建一批虚拟线程,并且把它们的生命周期绑定在一个逻辑代码块上。比如一个任务由两个子任务并行完成,那么两个子任务都结束之后才会返回结果;如果其中一个子任务异常了,另一个子任务会被自动取消。
我用它来批量处理订单,可以写出类似这样的代码:
String processBatch(Collection<String> orderIds) throws InterruptedException {
try (var scope = new StructuredTaskScope<>()) {
List<Future<Result>> futures = orderIds.stream()
.map(orderId -> scope.fork(() -> processOrder(orderId)))
.toList();
// 等待全部子虚拟线程结束
scope.join();
// 逐个收集结果,哪个失败也可以处理
for (Future<Result> f : futures) {
Result r = f.get(); // 因为全部完成,不会阻塞
// 进一步处理
}
return "success";
} catch (ExecutionException e) {
Throwable cause = e.getCause();
// 某个子任务失败了
log.error("批处理中有任务失败", cause);
return "failed";
}
}
这个API的好处是,如果任务中途需要取消(比如用户关闭了页面,或者服务正在关闭),父线程一旦取消,StructuredTaskScope会自动向所有子任务发起interrupt,不会让子线程成为孤儿。原生线程池里你得自己维护Future列表另一个一个cancel,那真是太麻烦了。
再看一下虚拟线程在内存上的巨大优势
我以前开200个平台线程已经小心翼翼。要是一万线程,不仅系统调度得崩溃,光线程栈默认最大就要吃掉差不多10GB虚拟内存。而虚拟线程的栈是在内存中根据需要动态调整的,通常只是几千字节。我用刚才的8000个虚拟线程跑批量任务时,JVM堆外内存只增加了不到20MB。跑了十分钟,平稳没有一点压力。
真的,以前我们哪里敢随意给每个请求发起一个线程,都要用池。现在虚拟线程池都不用,直接每个小任务一个虚拟线程,反正便宜。
坑和注意事项
并不是所有代码都能无脑跑进虚拟线程。如果你在代码里使用了synchronized块,且块内执行了阻塞IO,虚拟线程会被“钉死”在承载它的平台线程上,因为synchronized在JVM层面是Monitor锁,虚拟线程不会释放持有的底层平台线程。这会导致虚拟线程达不到“阻塞自动卸载”的效果。
怎么解?最简单的办法就是尽量不使用synchronized,改用ReentrantLock。但是在spring等框架内部很多工具还在用synchronized,所以你可能会遇到某个虚拟线程一旦调用某个老库就钉死大部分线程。虽然Java团队在后续版本尝试优化synchronized钉死问题,但目前的版本里还是尽量避开。
另一个问题是ThreadLocal。虚拟线程不能使用池化机制,所以ThreadLocal不再适合用作“跨任务缓存”,因为虚拟线程每次新建,根本没有复用本地线程的便利。官方推荐改用ScopedValue(作用域值),这也是Java 21孵化API。但生产上暂时别太依赖,先用静态变量和显式传参更容易把控。
重构完之后的感想
这次升级带来的开发体验是颠覆性的。以前遇到高并发IO,你需要用事件循环或者响应式框架,写一堆不是人类思维的逻辑,现在虚拟线程让你回到正常的同步模型里,同时吞吐量还比之前更高。
我后来用这思路改造了系统里三个最吃并发的老任务,代码行数平均减少30%以上,因为删掉了很多Future的等待和回调。
如果你还在用Java 17,我建议抢先试试Java 21的虚拟线程,单独抽一个非核心业务模块试运行。它会告诉你,什么叫做“清爽又通畅的并发编程”。
以后谁再跟我争论Java是不是老掉牙,我就把这篇文章甩给他。

