并发07:异步编程

从 CompletableFuture 的编排价值到线程池选择,再到异常处理和超时控制,系统理解 Java 异步编程的设计思想和最佳实践。

字数 1220 阅读时长 ≈ 4 分钟 2026-5-27 2026-7-12
并发07:异步编程

异步编程解决什么问题

CompletableFuture 最适合解决的,不是”让代码看起来高级”,而是多个独立任务可以并行执行、执行完再组合结果。

比如商品详情页要查商品、价格、库存、推荐。同步写法是一段接一段等,总耗时接近所有接口耗时之和。如果这些查询互不依赖,就可以并行:

CompletableFuture<Product> productFuture =
    CompletableFuture.supplyAsync(() -> productClient.get(id), pool);

CompletableFuture<Price> priceFuture =
    CompletableFuture.supplyAsync(() -> priceClient.get(id), pool);

CompletableFuture<Stock> stockFuture =
    CompletableFuture.supplyAsync(() -> stockClient.get(id), pool);

CompletableFuture 聚合查询预算

线程池选择

CompletableFuture.supplyAsync 如果不传线程池,会使用公共的 ForkJoinPool.commonPool()。这在简单 demo 里没问题,在业务系统里要谨慎。

公共线程池是全局共享的。你把阻塞式 HTTP、数据库查询、文件 IO 都丢进去,可能影响同一个 JVM 里其他也依赖公共池的任务。更稳妥的做法是按业务和资源隔离线程池:

ExecutorService detailPool = new ThreadPoolExecutor(
    20,
    40,
    60, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(300),
    namedThreadFactory("product-detail"),
    new ThreadPoolExecutor.AbortPolicy()
);
任务类型线程数建议说明
CPU 密集型接近 CPU 核数避免上下文切换
IO 密集型CPU 核数 * 2 或更高利用等待时间

编排关系

CompletableFuture 的价值在编排。独立任务用并行,依赖任务用串联,多个结果用组合。

CompletableFuture<Detail> detailFuture =
    productFuture.thenCombine(priceFuture, Detail::new)
                 .thenCombine(stockFuture, Detail::withStock);
方法用途返回值
thenApply同步转换新的值
thenApplyAsync异步转换新的值
thenCompose链式异步CompletableFuture
thenCombine组合两个结果新的值
allOf等待所有完成CompletableFuture<Void>
anyOf等待任一完成CompletableFuture<Object>

如果下一个步骤依赖上一个步骤的结果,用 thenApply。如果下一个步骤本身又返回一个 CompletableFuture,用 thenCompose,避免套娃:

userFuture.thenCompose(user -> couponClient.queryAsync(user.id()));

带 Async 和不带 Async 的区别

CompletableFuture 里带 Async 和不带 Async 的方法,不只是名字差异:

  • 不带 Async:后续阶段通常会在完成前一个阶段的线程上继续执行
  • 带 Async:会提交到线程池执行;如果没有指定 executor,会落到公共池

这意味着重处理要显式交给合适线程池:

future.thenApplyAsync(this::heavyTransform, computePool);

但也不要所有阶段都 Async。每加一次异步边界,都可能多一次调度成本和上下文传递成本。轻量转换可以留在当前线程,阻塞 IO 和重计算才需要明确隔离。

超时和异常处理

聚合接口最怕一个非核心下游拖住整体返回。Java 9 之后,CompletableFuture 提供了 orTimeoutcompleteOnTimeout

CompletableFuture<List<Recommend>> recommendFuture =
    CompletableFuture
        .supplyAsync(() -> recommendClient.query(userId), pool)
        .completeOnTimeout(List.of(), 200, TimeUnit.MILLISECONDS)
        .exceptionally(ex -> List.of());

异常处理不能一刀切吞掉,要按业务后果决定:

场景处理方式
推荐数据超时返回空列表
价格接口失败整体失败
库存接口失败展示”暂不可售”

超时预算分配

聚合接口不能给每个分支都随便设置超时。如果入口 SLA 是 800ms,应该按业务优先级拆分预算:

入口总预算:800ms
商品基础信息:300ms,失败则整体失败
价格:250ms,失败则整体失败
库存:200ms,失败则展示暂不可售
推荐:150ms,失败则返回空列表

每个分支的超时、异常和降级都应该反映业务优先级。否则最慢的非核心分支会决定整个接口体验。

常见死锁写法

最危险的写法之一,是在同一个小线程池里提交父任务,父任务再提交子任务并等待子任务:

CompletableFuture.supplyAsync(() -> {
    CompletableFuture<Result> child =
        CompletableFuture.supplyAsync(this::queryRemote, samePool);
    return child.join();
}, samePool);

如果父任务把线程占满,子任务没有线程执行,父任务又一直等子任务,线程池就可能卡死。

解决方式:

  • 能用 thenCompose 编排,就不要在中间 join
  • 不同性质的任务要分线程池
  • 必要等待时设置超时

上线后监控

用了 CompletableFuture,监控不能只看接口总耗时。至少要能看到:

  • 每个下游任务的耗时、超时和异常数
  • 异步线程池的活跃线程、队列长度和拒绝数
  • 聚合接口里各个降级分支是否频繁触发
  • traceId 能不能跨异步线程传递

异步会切线程,很多日志上下文默认不会自动传过去。项目里要明确 MDC 或 trace context 的传递方式。

常见误区

误区一:把 CompletableFuture 当成线程池

它只是任务编排工具,真正执行任务的是 executor。executor 没隔离、队列无界、下游没超时,异步链路一样会被打满。

误区二:异常统一 exceptionally 返回默认值

推荐失败可以返回空列表,价格失败却不能随便返回 0。异常处理要跟业务后果绑定。

误区三:只给最终 get() 设置超时

最终等待超时只能保护调用方,不能保证下游请求停止。HTTP 客户端、RPC 客户端、数据库查询也要有自己的超时。

项目里怎么判断要不要用

  1. 多个任务之间是否真的相互独立
  2. 总耗时是否主要来自 IO 等待,而不是 CPU 计算?
  3. 每个分支是否能定义清楚失败后的业务语义
  4. 是否已经有独立线程池、超时、监控和 trace 上下文传递

如果只是为了”异步而异步”,代码会更难排查;如果是聚合查询、并行 IO、依赖编排,CompletableFuture 才能真正把关系写清楚。