找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4381

积分

0

好友

575

主题
发表于 11 小时前 | 查看: 4| 回复: 0

《十万个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 的时候堆栈是断的,异常处理散落在各个 thenApplyexceptionally 里,稍微复杂点的业务逻辑读起来就想骂人。

有虚拟线程后,这部分确实可以不写了

虚拟线程线程变便宜了,一个虚拟线程的成本只有几 KB,JVM 可以同时调度几十万甚至上百万个虚拟线程,它们共享底层的少量平台线程(载体线程)。

遇到 IO 阻塞的时候,虚拟线程不会霸占载体线程傻等,而是自动挂起(unmount),让载体线程去跑别的虚拟线程。等 IO 完成了再重新挂载回来继续执行。

所以上面那段回调代码,现在可以直接写成同步风格:

// 虚拟线程下,直接写同步代码
Thread.startVirtualThread(() -> {
    User user = queryUser(userId);         // 阻塞没关系,虚拟线程会自动挂起
    List<Order> orders = queryOrders(user);
    BigDecimal total = calcTotal(orders);
    sendResponse(total);
});

代码直观了不止一个档次,没有回调地狱,没有 thenApplythenApply,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》系列持续更新~




上一篇:AI写了60%代码,研发效率还是没飞?拆解企业级AI编程的核心矛盾
下一篇:为何Nginx和Gateway都需要限流?两道防线保护后端不崩溃
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-7-19 14:45 , Processed in 0.639163 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表