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();         // 阻塞

上线后发现两个问题:

  1. P99 延迟异常高。某个下游服务偶发慢响应时,get() 会把整个聚合接口拖死,线程池里的工作线程被大量占用,形成雪崩。
  2. 代码没法优雅地做降级。想在"库存服务超时 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),结果沿着边流动,某个节点完成时自动触发下游节点。

graph TD A[商品详情请求] --> B[CompletableFuture 编排层] B --> C[商品信息 CF] B --> D[库存 CF] B --> E[价格 CF] B --> F[促销 CF] C --> G[thenCombine 合并] D --> G E --> H[thenCombine 合并] F --> H G --> I[最终组装] H --> I I --> J[返回前端] D -.超时200ms.-> K[orTimeout + exceptionally 降级] K --> I

这张图就是 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 提供了更好的结构化并发。
graph LR A[异步需求] --> B{复杂度} B -->|简单并行| C[CompletableFuture] B -->|流式/背压| D[Reactor] B -->|JDK21+ IO密集| E[虚拟线程] C --> F[注意线程池隔离] D --> G[注意调度器选型] E --> H[注意 pinning 问题]

最佳实践与避坑指南

坑 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,或在链尾收集失败信息。

最佳实践清单:

  1. 线程池隔离:不同业务用不同线程池,避免相互影响。参考 Hystrix/Sentinel 的舱壁隔离思想。
  2. 每个下游调用都带超时和降级,用 orTimeout + exceptionally 组合。
  3. 链尾必挂兜底:exceptionally 或 handle 收尾。
  4. 避免在回调里阻塞,用 *Async。
  5. 用 thenCompose 扁平化嵌套 CF,别写出 CF>。
  6. 监控:记录每个阶段的耗时和失败率,CompletableFuture 的链路对 APM 不友好,手动埋点。

总结

回顾全文的几个核心认知:

  1. CompletableFuture 的本质是一个可编排的状态机,volatile Object result 单字段承载所有状态,Completion 栈实现无锁回调注册,postComplete 是触发引擎。
  1. 同步回调有"快速路径":源已完成时在调用线程直接执行,这是性能优势也是坑源。耗时操作必须 *Async。
  1. commonPool 是共享资源,IO 密集型任务必须用隔离的线程池。
  1. 超时、降级、异常处理是异步编程的三件套,缺一不可。orTimeout 不取消底层任务,要留意资源泄漏。
  1. JDK 21+ 的虚拟线程正在改变游戏规则。当阻塞变得廉价,"用同步代码写异步逻辑"重新变得有吸引力。CompletableFuture 未来更多会用于编排而非执行。

延伸思考:CompletableFuture 的依赖图本质上是一个轻量级的协程调度器,只是它没有真正的挂起/恢复能力。如果你理解了它的栈结构和触发机制,再去看 Kotlin 协程的 Continuation、Loom 的 Continuation,会发现思想是相通的——都是把"等待"这件事从线程里剥离出来,变成一种可调度的数据结构。这是异步编程二十年演进的底层主线。