《十万个why》系列持续更新中。
Java 21 正式发布虚拟线程之后,社区里隔三差五就有人问:虚拟线程都能开几十万个了,那 CompletableFuture 异步编程是不是可以淘汰了?
我最近在重构一个订单聚合服务时,也认真琢磨过这个问题。结论是:不能扔,但能少写很多。
这俩东西解决的问题有交集,但不完全重叠。搞清楚它们各自的边界,比争论谁取代谁有意义。
CompletableFuture 为什么会被大量使用
答案很简单,就是穷——平台线程太贵了。
一个平台线程默认占 1MB 栈内存,一台 8GB 内存的机器,刨掉堆内存和系统开销,能跑的线程数也就几千个。你不敢给每个 IO 操作都分配一个线程去同步等待,否则线程池瞬间打满,后面的请求全部排队。
所以不得不用 CompletableFuture 把阻塞操作改成非阻塞的回调链:
// 以前不得不这么写,因为线程不够用
CompletableFuture.supplyAsync(() -> queryUser(userId))
.thenApply(user -> queryOrders(user))
.thenApply(orders -> calcTotal(orders))
.thenAccept(total -> sendResponse(total));
这段代码的目的不是为了追求异步风格,而是被迫写成异步风格。用回调链来避免线程阻塞等待,让少量线程能服务大量请求。
代码能跑,但写起来反人类。回调套回调,debug 的时候堆栈是断的,异常处理散落在各个 thenApply 和 exceptionally 里,稍微复杂点的业务逻辑读起来就想骂人。
有虚拟线程后,这部分确实可以不写了
虚拟线程 让线程变便宜了,一个虚拟线程的成本只有几 KB,JVM 可以同时调度几十万甚至上百万个虚拟线程,它们共享底层的少量平台线程(载体线程)。
遇到 IO 阻塞的时候,虚拟线程不会霸占载体线程傻等,而是自动挂起(unmount),让载体线程去跑别的虚拟线程。等 IO 完成了再重新挂载回来继续执行。
所以上面那段回调代码,现在可以直接写成同步风格:
// 虚拟线程下,直接写同步代码
Thread.startVirtualThread(() -> {
User user = queryUser(userId); // 阻塞没关系,虚拟线程会自动挂起
List<Order> orders = queryOrders(user);
BigDecimal total = calcTotal(orders);
sendResponse(total);
});
代码直观了不止一个档次,没有回调地狱,没有 thenApply 套 thenApply,debug 的时候堆栈也是正常的调用链。
这个变化的本质是什么?以前因为线程贵,不得不用异步回调来模拟并发;现在线程便宜了,直接开线程同步写就行了。CompletableFuture 在这个场景下就是穷人的并发方案。
所以纯粹为了避免线程阻塞而写的 CompletableFuture,确实可以用虚拟线程替代。
但 CompletableFuture 不只是异步执行
CompletableFuture 的价值不仅仅是异步执行,它还是一个任务编排工具。这部分能力,虚拟线程目前替代不了,至少替代得不优雅。
并发组合
多个任务并行,全部完成后合并,这是最常见的场景。比如一个用户详情页,需要同时查用户信息、订单列表、积分余额,三个接口并行调用,全部完成后合并结果返回:
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> queryUser(id));
CompletableFuture<List<Order>> orderFuture = CompletableFuture.supplyAsync(() -> queryOrders(id));
CompletableFuture<Integer> pointsFuture = CompletableFuture.supplyAsync(() -> queryPoints(id));
CompletableFuture.allOf(userFuture, orderFuture, pointsFuture).join();
// 三个结果都拿到了,合并返回
UserProfile profile = merge(userFuture.join(), orderFuture.join(), pointsFuture.join());
三个任务并行跑,allOf 等全部完成,然后合并。虚拟线程也能做,但得借助 StructuredTaskScope:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var userTask = scope.fork(() -> queryUser(id));
var orderTask = scope.fork(() -> queryOrders(id));
var pointsTask = scope.fork(() -> queryPoints(id));
scope.join();
scope.throwIfFailed();
UserProfile profile = merge(userTask.get(), orderTask.get(), pointsTask.get());
}
功能上能实现,但 StructuredTaskScope 到目前为止还是预览状态(Preview API)。这个 API 还没定型,后面可能改接口。在生产代码里用预览 API,你得自己掂量一下风险。
而且 StructuredTaskScope 的设计哲学是结构化并发——所有子任务必须在 scope 关闭前完成或取消,不允许“fire and forget”。这在很多场景下是好事,但灵活性不如 CompletableFuture。
竞速模式
三个搜索引擎同时查,谁先返回用谁的结果:
CompletableFuture.anyOf(searchGoogle(), searchBing(), searchBaidu())
.thenAccept(result -> useResult(result));
一行代码搞定。用虚拟线程得上 StructuredTaskScope.ShutdownOnSuccess,代码量多出一倍不止,而且语义表达没有 anyOf 直观。
链式异常处理和降级
这是 CompletableFuture 的看家本领之一:
CompletableFuture.supplyAsync(() -> queryFromMainDB())
.exceptionally(ex -> queryFromBackupDB()) // 主库挂了自动切备库
.thenApply(data -> transform(data))
.handle((result, ex) -> {
if (ex != null) return defaultValue(); // 兜底默认值
return result;
});
整个降级链路一气呵成:先查主库,主库挂了切备库,备库也挂了返回默认值。每一步的异常处理都声明在调用链上,一眼就能看清楚降级策略。
虚拟线程里你得用 try-catch 一层层嵌套:
Thread.startVirtualThread(() -> {
Object data;
try {
data = queryFromMainDB();
} catch (Exception e1) {
try {
data = queryFromBackupDB(); // 主库挂了切备库
} catch (Exception e2) {
data = defaultValue(); // 备库也挂了,给默认值
}
}
Object result = transform(data);
sendResponse(result);
});
功能一模一样,但你告诉我哪个更容易维护?当降级链路有 4、5 层的时候,try-catch 嵌套看着就头疼。CompletableFuture 的声明式写法在这种场景下就是更干净。
声明式超时控制
CompletableFuture.supplyAsync(() -> slowQuery())
.orTimeout(3, TimeUnit.SECONDS) // 3 秒超时抛异常
.completeOnTimeout(defaultValue(), 3, TimeUnit.SECONDS); // 或者超时给默认值
一行搞定超时策略,虚拟线程里也能做,得配合 Future.get(timeout) 或者 StructuredTaskScope 的超时参数,但没有这种声明式写法简洁。
StructuredTaskScope 能否替换 CompletableFuture?
短期内不能完全替换,原因有几个:
StructuredTaskScope 的设计目标和 CompletableFuture 不一样。它强调的是结构化,每个子任务都必须在 scope 内完成,不允许跑出去。这也就意味着你没法做 thenCompose 那种跨异步边界的链式编排。
CompletableFuture 本质上是一个 Promise/Future 抽象,可以在任意位置 complete,可以跨方法传递,可以组合任意多个异步操作。这种灵活性是 StructuredTaskScope 刻意不提供的——因为太灵活容易写出难以理解的并发代码。
简单来说,这两个东西的设计哲学就不一样:
StructuredTaskScope 像是有围栏的操场,安全但活动范围有限
CompletableFuture 像是开放的公路,自由但你得自己注意安全
说在最后
说了这么多对比,落到实际写代码上该怎么选?
简单的异步 IO,用虚拟线程 + 同步写法。 查数据库、调 HTTP 接口、读文件这种场景,直接开虚拟线程同步写就行,代码简单、堆栈清晰、好 debug。不要再为了这种场景去写 supplyAsync().thenApply().thenAccept() 了。
复杂的多任务编排,CompletableFuture 该用还是用。 多个任务并行等待、竞速、降级、超时控制这些场景,CompletableFuture 的 API 就是为这些场景设计的,用起来更简洁也更不容易出错。
两者混用完全没问题。 CompletableFuture 可以提交到虚拟线程的 Executor 上跑:
// 用虚拟线程作为 CompletableFuture 的执行器
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> queryUser(id), executor);
CompletableFuture<List<Order>> orderFuture = CompletableFuture.supplyAsync(() -> queryOrders(id), executor);
CompletableFuture.allOf(userFuture, orderFuture).join();
这样既利用了虚拟线程的轻量级优势,又用上了 CompletableFuture 的编排能力。面对 高并发 业务,这种组合拳目前来看是比较务实的写法。
《十万个why》系列持续更新~