CompletableFuture异步编程实战与原理
引言
先看一个真实的生产事故。
某电商平台的商品详情页聚合接口,需要并行调用 6 个下游服务:商品基础信息、库存、价格、促销、评价、推荐。早期实现用的是 ExecutorService + Future:
Future<Product> productFuture = executor.submit(() -> productService.get(id));
Future<Stock> stockFuture = executor.submit(() -> stockService.get(id));
// ... 剩下 4 个
Product product = productFuture.get(); // 阻塞
Stock stock = stockFuture.get(); // 阻塞上线后发现两个问题:
- P99 延迟异常高。某个下游服务偶发慢响应时,
get()会把整个聚合接口拖死,线程池里的工作线程被大量占用,形成雪崩。 - 代码没法优雅地做降级。想在"库存服务超时 200ms 就返回默认值"这种逻辑上做文章,
Future的 API 根本表达不出来,只能写一堆try-catch和isDone()判断。
换成 CompletableFuture 后,配合 orTimeout、exceptionally、thenCombine,代码从 80 行缩到 30 行,P99 从 1.2s 降到 320ms。
这篇文章不写入门教程。我会从餐厅后厨这个类比切入,然后打开 JDK 源码,把 CompletableFuture 的依赖栈(Treiber Stack)、回调触发机制、线程池选择策略讲透,最后给出三个可直接跑的生产级示例和一份踩坑清单。
核心概念:把 CompletableFuture 想象成餐厅后厨
传统 Future 是什么?你去餐厅点菜,服务员给你一个取餐号(Future),然后你就只能站在窗口死等(get()),菜好了叫你。等待期间你什么都干不了。
CompletableFuture 是什么?你点完菜,服务员给你号码,同时说:"菜好了我直接送到你桌上(回调),而且你可以中途加菜(链式组合),如果厨房做不出来,我自动给你换成别的(exceptionally 降级)。"
技术定义上:
Future:只读的结果容器,只能阻塞等待或轮询isDone()。
CompletableFuture:可写的、可组合的、支持回调的结果容器。它实现了Future和CompletionStage,核心能力是把"结果没到"这件事本身变成一种可以编排的状态。
关键区别在于 CompletionStage 定义的语义:每个阶段(Stage)之间通过 then* 系列方法连接,形成一个有向无环图(DAG),结果沿着边流动,某个节点完成时自动触发下游节点。
这张图就是 CompletableFuture 的价值所在:依赖关系显式化,失败路径可编程,执行线程可控。
源码深度分析
这一节是全文的核心。我们打开 java.util.concurrent.CompletableFuture,看三个最关键的设计。
1. 结果字段:为什么用 Object result 而不是泛型字段
public class CompletableFuture<T> implements Future<T>, CompletionStage<T> {
volatile Object result; // 注意:是 Object,不是 T
volatile Completion stack; // 回调栈的栈顶
// ...
}result 声明为 Object,因为它要同时承载三种语义:
- 正常结果:就是
T类型的实例。
- 空结果:
NIL(一个静态哨兵对象),表示"完成但没有值"(比如thenAccept的返回)。
- 异常结果:
AltResult包装的Throwable。
static final class AltResult {
final Throwable ex;
AltResult(Throwable x) { this.ex = x; }
}
static final AltResult NIL = new AltResult(null);用一个 volatile Object 字段 + 哨兵值,避免了"结果字段 + 异常字段 + 完成标志位"三个字段带来的可见性顺序问题。这是典型的状态压缩技巧:单字段写入天然保证原子性和可见性,读的时候根据类型判断状态。
类比:餐厅后厨的出餐口只有一个窗口。出来的要么是菜(正常结果),要么是"今日无此菜"的牌子(NIL),要么是"厨房失火"的告示(AltResult)。你不需要问"菜好了没"和"有没有出问题"两个问题,看窗口里是什么就知道了。
2. 依赖栈:Treiber Stack 与无锁回调注册
Completion 是所有回调节点的抽象基类,它构成一个Treiber Stack(无锁并发栈):
abstract static class Completion extends ForkJoinTask<Void>
implements Runnable, AsynchronousCompletionTask {
volatile Completion next; // 单向链表,指向下一个节点
abstract CompletableFuture<?> tryFire(int mode); // 触发回调
abstract boolean isLive();
}为什么用栈而不是队列?因为后注册的回调通常是"更外层"的依赖,用 LIFO 顺序触发有利于尽早释放引用、减少栈深度、触发时 cache 友好。更重要的是,入栈用 CAS 无锁实现:
final void push(CompletableFuture<T> f, Completion c) {
// 简化后的逻辑
while (!f.casStack(c, c.next = f.stack)) { // 自旋 CAS 直到成功
// 若栈顶已是相同类型,可合并(如 UniRelay)
}
}入栈时有一个精妙的判断:如果 f.result != null(源 Future 已完成),那么 postComplete 会立即触发,而不是傻等。
final void postComplete() {
CompletableFuture<?> f = this;
Completion h;
while ((h = f.stack) != null ||
(f != this && (h = (f = this).stack) != null)) {
CompletableFuture<?> d; Completion t;
if (f.casStack(t = h.next, h)) { // 弹栈
if (t != null) {
if (t.next != null) // 把剩余节点压回
pushStack(t);
h.next = null;
}
f = (d = h.tryFire(NESTED)) == null ? this : d; // 触发
}
}
}这段代码是整个 CompletableFuture 的心脏。它做的事情是:不断从栈顶弹出回调节点,调用 tryFire 触发它们,被触发的节点又可能产生新的节点,递归地继续。注意 NESTED 这个 mode 参数,它表示"当前正在触发回调的线程里",用来避免重复提交到线程池(如果回调很短,直接在触发线程里跑完更划算)。
3. thenApply 的源码:回调如何被创建
public <U> CompletableFuture<U> thenApply(
Function<? super T,? extends U> fn) {
return uniApplyStage(null, fn);
}
private <V> CompletableFuture<V> uniApplyStage(
Executor e, Function<? super T,? extends V> f) {
// ... null 检查
CompletableFuture<V> d = newIncompleteFuture(); // 新建目标 CF
if (e != null || !d.uniApply(this, f, null)) {
// 源已完成的快速路径失败,创建回调节点
UniApply<T,V> c = new UniApply<T,V>(e, d, this, f);
push(c); // 挂到源 CF 的回调栈
c.tryFire(SYNC); // 尝试立即触发
}
return d;
}关键在 UniApply 的 tryFire:
final CompletableFuture<V> tryFire(int mode) {
CompletableFuture<V> d; CompletableFuture<T> a;
if ((d = dep) == null ||
!d.uniApply(a = src, fn, mode > 0 ? null : this))
return null;
dep = null; src = null; fn = null;
return d.postFire(a, mode); // 触发下游
}uniApply 里做的是关键路径优化:如果源 Future 已经完成,直接执行 fn 并把结果写入 dep,完全不经过线程池。这就是为什么 CompletableFuture 在同步链上性能极好——它有一条"源已完成,直接算"的快速路径。
final <S> boolean uniApply(CompletableFuture<S> a,
Function<? super S,? extends T> f, UniApply<S,T> c) {
Object r; Throwable x;
if (a == null || (r = a.result) == null || f == null)
return false;
tryComplete: if (result == null) {
if (r instanceof AltResult) {
if ((x = ((AltResult)r).ex) != null) {
completeThrowable(x, r); // 异常传播
break tryComplete;
}
r = null;
}
try {
if (c != null && !c.claim())
return false;
@SuppressWarnings("unchecked") S s = (S) r;
completeValue(f.apply(s)); // 执行用户函数
} catch (Throwable ex) {
completeThrowable(ex);
}
}
return true;
}读到这里你应该明白一个反直觉的事实:CompletableFuture 的 thenApply 这类同步回调,如果源已完成,是在调用者线程里直接执行的,不会切换线程。这是性能优势,但也是坑的来源——如果你不知道这一点,可能会在回调里做耗时操作而阻塞调用线程。
4. 线程池选择的三种模式
CompletableFuture 的 *Async 方法有三种执行策略:
| 方法 | Executor 参数 | 执行线程 |
|---|---|---|
thenApply |
无 | 调用线程 / 触发线程(不切换) |
thenApplyAsync |
null | ForkJoinPool.commonPool() |
thenApplyAsync |
指定 executor | 指定线程池 |
commonPool() 的默认并行度是 Runtime.getRuntime().availableProcessors() - 1。在容器化环境(K8s)里这是个巨坑:JDK 8 早期版本读不到 cgroup 的 CPU limit,会按宿主机核数创建线程。JDK 8u191+ 才通过 UseContainerSupport 修复。即便如此,把 IO 密集型任务丢进 commonPool 依然是灾难,因为它默认是为 CPU 密集型设计的。
实战代码
示例 1:商品详情页并行聚合(生产可用)
import java.util.concurrent.*;
import java.time.Duration;
public class ProductDetailAggregator {
// 为 IO 密集型任务定制线程池:核心线程数按 QPS 和 RT 估算
private static final ExecutorService BIZ_POOL = new ThreadPoolExecutor(
32, 64, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(200),
new ThreadFactory() {
private final java.util.concurrent.atomic.AtomicInteger seq =
new java.util.concurrent.atomic.AtomicInteger(1);
@Override public Thread newThread(Runnable r) {
Thread t = new Thread(r, "biz-pool-" + seq.getAndIncrement());
t.setDaemon(true); // 守护线程,避免阻塞 JVM 退出
return t;
}
},
// 关键:队列满了不能让调用线程无限等待,用 CallerRuns 兜底
new ThreadPoolExecutor.CallerRunsPolicy()
);
public ProductDetail aggregate(long skuId) {
// 1. 启动所有下游调用,每个都带超时和降级
CompletableFuture<Product> productF = supplyWithFallback(
() -> productService.get(skuId),
Duration.ofMillis(300), Product.EMPTY);
CompletableFuture<Stock> stockF = supplyWithFallback(
() -> stockService.get(skuId),
Duration.ofMillis(200), Stock.UNKNOWN);
CompletableFuture<Price> priceF = supplyWithFallback(
() -> priceService.get(skuId),
Duration.ofMillis(250), Price.DEFAULT);
CompletableFuture<List<Promotion>> promoF = supplyWithFallback(
() -> promoService.list(skuId),
Duration.ofMillis(300), java.util.Collections.emptyList());
// 2. 组合:价格依赖商品(拿到商品才能算会员价),其余独立
CompletableFuture<Price> finalPriceF = productF.thenCombineAsync(
priceF,
(p, base) -> priceService.applyMemberDiscount(p, base),
BIZ_POOL);
// 3. 全部完成后组装,allOf 只保证"都完成",需要手动 join 取结果
return CompletableFuture
.allOf(productF, stockF, finalPriceF, promoF)
.thenApply(v -> new ProductDetail(
productF.join(), stockF.join(),
finalPriceF.join(), promoF.join()))
.orTimeout(500, TimeUnit.MILLISECONDS) // 整体兜底超时
.exceptionally(ex -> {
// 兜底降级:整体超时时返回缓存或默认页
log.warn("aggregate timeout for sku {}", skuId, ex);
return ProductDetail.fallback(skuId);
})
.join();
}
/**
* 通用模板:异步执行 + 单点超时 + 单点降级
* 注意 orTimeout 会以 TimeoutException 完成 CF,
* 后续 exceptionally 捕获它并返回 fallback。
*/
private <T> CompletableFuture<T> supplyWithFallback(
Supplier<T> supplier, Duration timeout, T fallback) {
return CompletableFuture
.supplyAsync(supplier, BIZ_POOL)
.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS)
.exceptionally(ex -> {
log.warn("downstream failed, use fallback", ex);
return fallback;
});
}
}要点解析:
allOf返回CompletableFuture,不携带结果,必须用join()逐个取。这是新手最容易踩的坑。
orTimeout是 JDK 9+ 引入的。JDK 8 需要自己用ScheduledExecutorService实现,或升级到completeOnTimeout。注意orTimeout不会取消底层任务,只是让 CF 提前以异常完成。
CallerRunsPolicy是兜底:队列满时让调用线程自己跑,避免任务被静默丢弃,同时形成背压。
示例 2:多级降级 + 熔断风格的链式编排
public class MultiLevelFallback {
/**
* 三级数据源:本地缓存 -> Redis -> 远程 DB
* 任何一级成功即返回,全部失败返回默认值。
* 这是 CompletableFuture 相比 Future 最大的表达力优势。
*/
public UserProfile loadProfile(long uid) {
return CompletableFuture
.supplyAsync(() -> localCache.get(uid), CACHE_POOL)
// 本地缓存 miss 时走 Redis(handle 可以同时处理正常和异常)
.handle((local, ex) -> {
if (local != null) return CompletableFuture.completedFuture(local);
return CompletableFuture.supplyAsync(
() -> redis.get(uid), CACHE_POOL);
})
.thenCompose(f -> f) // 扁平化嵌套的 CF
// Redis 也 miss 时走远程 DB
.handle((redisVal, ex) -> {
if (redisVal != null) return CompletableFuture.completedFuture(redisVal);
return CompletableFuture.supplyAsync(
() -> remoteDb.query(uid), DB_POOL);
})
.thenCompose(f -> f)
.orTimeout(800, TimeUnit.MILLISECONDS)
// 三级全挂:返回匿名默认画像,保证页面可渲染
.exceptionally(ex -> {
metrics.counter("profile.fallback").increment();
return UserProfile.anonymous(uid);
})
.join();
}
}关键点:
handle与thenApply的区别:handle无论上一步正常还是异常都会执行,且能同时拿到值和异常;thenApply遇到异常会直接短路跳过。
thenCompose用于扁平化:当回调本身返回CompletableFuture时,用thenCompose避免得到CF。类比> Stream.flatMap。
- 这种"逐级降级"用
Future写会是一坨嵌套try-catch,用CF就是一条链。
示例 3:批量任务 + 限流 + 结果收集(避免 commonPool 陷阱)
public class BatchProcessor {
private final ExecutorService pool;
private final Semaphore limiter; // 控制对下游的并发压力
public BatchProcessor(int concurrency) {
this.pool = Executors.newFixedThreadPool(concurrency, r -> {
Thread t = new Thread(r, "batch-worker");
t.setDaemon(true);
return t;
});
this.limiter = new Semaphore(concurrency);
}
/**
* 批量调用下游接口,限制并发数,收集所有结果。
* 单个失败不影响整体,失败项记为 null。
*/
public <T> List<T> batchCall(List<Long> ids,
Function<Long, T> remoteCall) {
List<CompletableFuture<T>> futures = ids.stream()
.map(id -> CompletableFuture
.supplyAsync(() -> {
try {
limiter.acquire(); // 限流:拿不到许可就阻塞
return remoteCall.apply(id);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new CompletionException(e);
} finally {
limiter.release();
}
}, pool)
.orTimeout(1, TimeUnit.SECONDS)
// 单个失败降级为 null,不影响其他任务
.exceptionally(ex -> {
log.warn("remote call failed for id {}", id, ex);
return null;
}))
.collect(Collectors.toList());
// allOf 只等全部完成,然后逐个 join(此时已完成,join 不会阻塞)
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
return futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
}
}要点:
- 不要用
commonPool做 IO。这里显式指定pool,并用Semaphore做并发限流,避免打爆下游。
allOf(...).join()之后所有 future 都已done,此时join()是非阻塞的(走uniApply快速路径)。
exceptionally返回null是常见降级手段,但调用方必须处理 null,否则 NPE。
方案对比:CompletableFuture vs 其他异步方案
| 维度 | Future | CompletableFuture | RxJava/Reactor | 虚拟线程 + 阻塞 |
|---|---|---|---|---|
| 组合能力 | 无 | 强(then/combine/allOf) | 极强(操作符丰富) | 一般(靠结构化并发) |
| 学习曲线 | 低 | 中 | 高 | 低 |
| 背压支持 | 无 | 无 | 原生支持 | 天然(阻塞即背压) |
| 调试友好度 | 好 | 差(栈丢失) | 差 | 好 |
| 适用场景 | 简单并发 | 中等复杂度编排 | 流式/复杂事件流 | IO 密集、JDK 21+ |
| 生态 | JDK | JDK | 需引入依赖 | JDK 21+ |
结论:
- JDK 8~17 + 少量下游聚合:
CompletableFuture是性价比最高的选择。
- 复杂流式处理、需要背压:上 Reactor/RxJava。
- JDK 21+ 且 IO 密集:直接用虚拟线程 + 阻塞代码往往比
CF更简单,StructuredTaskScope提供了更好的结构化并发。
最佳实践与避坑指南
坑 1:join() 抛的是 CompletionException,不是原始异常
join() 和 get() 的区别:get() 抛受检的 ExecutionException,join() 抛非受检的 CompletionException,原始异常被包在 cause 里。日志打印时要 ex.getCause(),否则只能看到一层包装。
坑 2:commonPool 是共享的,一个慢任务拖垮全局
thenApplyAsync(fn) 不传 executor 就用 commonPool。整个 JVM 共享这一个池,任何地方塞进去的阻塞任务都会影响其他人。生产代码永远显式传 executor。
坑 3:orTimeout 不取消底层任务
orTimeout 只是让 CF 提前以 TimeoutException 完成,底层线程仍在跑。如果要真正取消,需要持有返回的 CF 并 cancel(true),或者用支持中断的库。这是资源泄漏的常见来源。
坑 4:异常被静默吞掉
如果一条链上没有任何 exceptionally / handle / whenComplete,异常就静静躺在 CF 里。allOf 也不抛异常。务必在链尾加兜底,或在 whenComplete 里打日志。
坑 5:回调里做阻塞操作
前面源码分析过,同步回调(thenApply 不带 Async)可能在调用线程或触发线程里执行。在回调里做 DB 查询、HTTP 调用,会阻塞这些线程。耗时操作一律用 *Async + 独立线程池。
坑 6:allOf + join 的异常处理
CompletableFuture.allOf(f1, f2, f3).join();
// 如果 f2 失败,这里抛 CompletionException,但你不知道是哪个失败了正确做法:给每个 future 单独挂 exceptionally,或在链尾收集失败信息。
最佳实践清单:
- 线程池隔离:不同业务用不同线程池,避免相互影响。参考 Hystrix/Sentinel 的舱壁隔离思想。
- 每个下游调用都带超时和降级,用
orTimeout+exceptionally组合。 - 链尾必挂兜底:
exceptionally或handle收尾。 - 避免在回调里阻塞,用
*Async。 - 用
thenCompose扁平化嵌套 CF,别写出CF。> - 监控:记录每个阶段的耗时和失败率,
CompletableFuture的链路对 APM 不友好,手动埋点。
总结
回顾全文的几个核心认知:
CompletableFuture的本质是一个可编排的状态机,volatile Object result单字段承载所有状态,Completion栈实现无锁回调注册,postComplete是触发引擎。
- 同步回调有"快速路径":源已完成时在调用线程直接执行,这是性能优势也是坑源。耗时操作必须
*Async。
commonPool是共享资源,IO 密集型任务必须用隔离的线程池。
- 超时、降级、异常处理是异步编程的三件套,缺一不可。
orTimeout不取消底层任务,要留意资源泄漏。
- JDK 21+ 的虚拟线程正在改变游戏规则。当阻塞变得廉价,"用同步代码写异步逻辑"重新变得有吸引力。
CompletableFuture未来更多会用于编排而非执行。
延伸思考:CompletableFuture 的依赖图本质上是一个轻量级的协程调度器,只是它没有真正的挂起/恢复能力。如果你理解了它的栈结构和触发机制,再去看 Kotlin 协程的 Continuation、Loom 的 Continuation,会发现思想是相通的——都是把"等待"这件事从线程里剥离出来,变成一种可调度的数据结构。这是异步编程二十年演进的底层主线。