秒杀下单成功之后,系统要做好几件事:生成订单、扣减库存(如果放在下单接口内已经做过,这里可能是记账/对账)、给用户发一张优惠券、给下游推送一条消息通知发货系统。这几件事有的必须先后串起来 (订单生成依赖扣库存成功),有的互不依赖可以并行跑(发优惠券和推消息谁也不用等谁),如果全部塞进一个方法里顺序同步执行,接口响应时间就是这几步耗时的总和------用户会觉得"下单怎么这么慢"。
这就是异步编排 要解决的问题:把一堆有依赖关系、又有并行机会的任务组织起来,尽量缩短总耗时,同时让代码读起来还是一条清晰的逻辑线,而不是一堆嵌套回调纠缠在一起(这正是早期 Future 被吐槽最多的地方)。这一篇按"Future 的局限 → CompletableFuture 怎么补上 → Fork/Join 框架"这条线索展开,最后用秒杀下单场景把常用的编排方法串成一个完整例子。
目录
- [Future 的局限:只能等,不能编排](#Future 的局限:只能等,不能编排)
- [CompletableFuture 入门:创建异步任务与默认线程池的坑](#CompletableFuture 入门:创建异步任务与默认线程池的坑)
- [串联任务:thenApply / thenApplyAsync / thenCompose](#串联任务:thenApply / thenApplyAsync / thenCompose)
- [合并与等待:thenCombine / allOf / anyOf](#合并与等待:thenCombine / allOf / anyOf)
- [异常处理:exceptionally / handle / whenComplete](#异常处理:exceptionally / handle / whenComplete)
- [Fork/Join 框架:分治任务与工作窃取](#Fork/Join 框架:分治任务与工作窃取)
- 实战:秒杀下单的异步编排流水线
- 选型小结
一、Future 的局限:只能等,不能编排
JDK 5 引入的 Future 是异步编程最早的抽象:把任务提交给线程池,立刻拿到一个 Future,主线程可以先去做别的事,需要结果时再调用 get()。
java
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Order> future = pool.submit(() -> createOrder(userId, productId));
// 主线程可以先做别的事......
Order order = future.get(); // 阻塞,直到任务完成才能拿到结果
问题就出在 get() 上:它是阻塞的,一旦调用就只能傻等,没有"任务完成时自动回调我"的机制。这带来几个实际的麻烦:
- 无法优雅地串联多个异步任务。 如果"扣库存"完成后要接着"生成订单",用
Future只能是future1.get()拿到结果后再手动submit()下一个任务------本质上还是阻塞等待,只是把等待的位置往后挪了一下,没有真正的异步链路。 - 无法表达"等多个任务都完成"或"等任意一个完成"。 想等发优惠券和推消息两个并行任务都结束,用
Future得手写future1.get(); future2.get();,挨个等,没有内置的批量等待语义。 - 异常处理别扭。 任务里抛的异常会包装成
ExecutionException在get()时才抛出来,想在异步链路的中间步骤处理异常也没有直接的钩子。
FutureTask 是 Future 的一个具体实现(同时实现了 Runnable),本质上没有解决上面的问题,只是提供了一种可以手动 run() 触发计算的写法。真正解决这些问题的是 JDK 8 引入的 CompletableFuture ,它同时实现了 Future 接口和 CompletionStage 接口,后者才是支持链式编排的关键。
二、CompletableFuture 入门:创建异步任务与默认线程池的坑
创建一个异步任务最常用的是两个静态方法:
java
// 有返回值,异步执行一个 Supplier
CompletableFuture<Integer> stockFuture =
CompletableFuture.supplyAsync(() -> queryStock(productId));
// 无返回值,异步执行一个 Runnable
CompletableFuture<Void> notifyFuture =
CompletableFuture.runAsync(() -> sendNotification(userId));
这里有一个容易踩的坑:如果不显式传 Executor 参数,supplyAsync/runAsync 默认用的是 ForkJoinPool.commonPool()。 这是一个 JVM 全局共享的线程池(默认并行度是 CPU 核数 - 1),Stream 的并行流(parallelStream())、CompletableFuture 的所有异步方法,只要不指定线程池,全都挤在这一个池子里跑。
java
// ✗ 用默认线程池:如果任务里有阻塞IO(查数据库、调HTTP接口),
// 会占用commonPool的线程,间接拖慢应用里所有依赖它的并行操作
CompletableFuture.supplyAsync(() -> queryStockFromDB(productId));
// ✓ 生产代码里传自己的业务线程池,跟公共池隔离
private static final ExecutorService BIZ_POOL = Executors.newFixedThreadPool(20);
CompletableFuture.supplyAsync(() -> queryStockFromDB(productId), BIZ_POOL);
这跟第 8 篇讲的线程池隔离思想是同一个道理:commonPool 是整个 JVM 共用的公共资源,业务代码里凡是涉及阻塞 IO(数据库、RPC、HTTP 调用)的异步任务,都应该传入自己独立的业务线程池,不要让一个慢查询拖累了这个进程里所有依赖公共池的其他功能。只有纯 CPU 计算、不会阻塞的任务,用默认池问题不大。
三、串联任务:thenApply / thenApplyAsync / thenCompose
thenApply :拿到上一步的结果,做一次转换,返回新的 CompletableFuture。
java
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> queryStock(productId), BIZ_POOL) // 返回 Integer(库存数)
.thenApply(stock -> stock > 0 ? "有货" : "无货"); // 转换成 String
thenApply 默认会在"完成上一阶段的那个线程"里继续执行下一步(如果上一阶段已经完成,调用 thenApply 的线程直接同步跑完;如果还没完成,则由触发完成的那个异步线程接着跑)。如果想强制这一步也在指定的线程池里异步执行 ,用 thenApplyAsync(同样支持传 Executor 参数,不传则回落到 commonPool)。
java
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> queryStock(productId), BIZ_POOL)
.thenApplyAsync(stock -> stock > 0 ? "有货" : "无货", BIZ_POOL); // 显式指定在哪个池子跑
thenCompose :当"下一步"本身也是一个返回 CompletableFuture 的异步操作时,该用 thenCompose 而不是 thenApply。
java
// ✗ 用 thenApply,会得到嵌套的 CompletableFuture<CompletableFuture<Order>>,很别扭
CompletableFuture<CompletableFuture<Order>> nested = CompletableFuture
.supplyAsync(() -> deductStock(productId), BIZ_POOL)
.thenApply(success -> createOrderAsync(userId, productId)); // createOrderAsync 本身返回 CompletableFuture<Order>
// ✓ 用 thenCompose,自动"拍平"成 CompletableFuture<Order>
CompletableFuture<Order> flat = CompletableFuture
.supplyAsync(() -> deductStock(productId), BIZ_POOL)
.thenCompose(success -> createOrderAsync(userId, productId));
thenCompose 和 Stream 里的 flatMap 是同一个心智模型 :普通的转换函数返回"裸值"用 map/thenApply,转换函数本身返回"一个容器"(Stream 或 CompletableFuture)时用 flatMap/thenCompose 把嵌套的容器拍平成一层。判断该用哪个的标准很简单:看你传进去的函数返回的是不是 CompletableFuture------是就用 thenCompose,不是就用 thenApply。
四、合并与等待:thenCombine / allOf / anyOf
上面讲的都是"串行依赖"------下一步必须等上一步的结果。但开头提到的"发优惠券"和"推消息"是互相独立、可以并行跑的两个任务,这时候需要的是合并而不是串联。
thenCombine :两个独立的 CompletableFuture 都完成后,用一个 BiFunction 把两者的结果合并成一个新结果。
java
CompletableFuture<Coupon> couponFuture = CompletableFuture.supplyAsync(() -> issueCoupon(userId), BIZ_POOL);
CompletableFuture<Boolean> notifyFuture = CompletableFuture.supplyAsync(() -> sendNotification(userId), BIZ_POOL);
// 两个任务并行跑,都完成后把结果合并成一个字符串
CompletableFuture<String> combined = couponFuture.thenCombine(notifyFuture,
(coupon, notified) -> "优惠券:" + coupon.getId() + ", 通知已发送:" + notified);
allOf :等待多个 CompletableFuture 全部完成 ,但它返回的是 CompletableFuture<Void>,不会自动帮你收集每个任务的结果,需要自己再挨个 .get()(因为已经全部完成,这里的 get() 不会阻塞)。
java
CompletableFuture<Void> all = CompletableFuture.allOf(couponFuture, notifyFuture);
all.join(); // 等全部完成(join()和get()语义一样,但不抛受检异常,Lambda里更好用)
Coupon coupon = couponFuture.join(); // 全部完成后直接取,不会阻塞
Boolean notified = notifyFuture.join();
anyOf :多个任务里任意一个先完成 就返回,返回类型是 CompletableFuture<Object>(因为不知道是哪一个任务先完成、类型也可能不一样),常见场景是"多个数据源查同一份数据,谁先返回用谁的"(比如本地缓存和远程缓存同时查,哪个先给结果就用哪个)。
五、异常处理:exceptionally / handle / whenComplete
异步链路上任何一步抛异常,都会导致异常沿着链路往下传(后续的 thenApply/thenCompose 都不会执行了),直到遇到能处理异常的节点或者被 get()/join() 取出来。三个处理异常的方法容易搞混,按"能不能兜底改结果""能不能同时看到异常和正常结果"来区分:
java
CompletableFuture<Integer> future = CompletableFuture
.supplyAsync(() -> deductStock(productId))
// 只处理异常,返回一个替代值,正常情况完全不触发
.exceptionally(ex -> {
log.error("扣库存失败", ex);
return -1; // 用-1表示失败,替代原本应有的返回值
});
CompletableFuture<Integer> future2 = CompletableFuture
.supplyAsync(() -> deductStock(productId))
// 正常/异常都会走到这里,二选一非null,可以据此决定返回什么
.handle((result, ex) -> {
if (ex != null) {
log.error("扣库存失败", ex);
return -1;
}
return result;
});
CompletableFuture<Integer> future3 = CompletableFuture
.supplyAsync(() -> deductStock(productId))
// 正常/异常都会走到这里,但只是"观察",不能改变最终结果,异常会继续往下传
.whenComplete((result, ex) -> {
if (ex != null) {
log.error("扣库存失败", ex);
}
});
区别一句话记住:exceptionally 只在异常时触发、用来兜底给替代值;handle 正常/异常都触发、可以决定最终返回什么(相当于 thenApply + exceptionally 的合体);whenComplete 正常/异常都触发,但只能"看"不能"改"------原来的结果或异常原样往下传,适合做日志记录、埋点这种旁路操作而不想干扰主链路。
六、Fork/Join 框架:分治任务与工作窃取
CompletableFuture 编排的是"业务上语义不同的多个任务"(扣库存、生成订单、发券),而 Fork/Join 框架解决的是另一类问题:把一个大的计算任务递归拆成许多足够小的子任务并行算,再把子任务的结果合并起来------典型的分治算法思路,比如大数组求和、归并排序、递归遍历文件目录统计大小。
核心是两个抽象类(继承自 ForkJoinTask):
RecursiveTask<V>:有返回值的分治任务,实现compute()方法。RecursiveAction:无返回值的分治任务(比如原地排序),用法一样只是没有返回值。
java
public class SumTask extends RecursiveTask<Long> {
private static final int THRESHOLD = 10_000; // 拆到多小才不再拆,直接算
private final long[] array;
private final int start, end;
public SumTask(long[] array, int start, int end) {
this.array = array; this.start = start; this.end = end;
}
@Override
protected Long compute() {
if (end - start <= THRESHOLD) {
long sum = 0;
for (int i = start; i < end; i++) sum += array[i]; // 足够小,直接算
return sum;
}
int mid = (start + end) / 2;
SumTask left = new SumTask(array, start, mid);
SumTask right = new SumTask(array, mid, end);
left.fork(); // 把左半部分丢给别的线程异步算
long rightResult = right.compute(); // 右半部分自己接着算(避免多fork一次线程切换)
long leftResult = left.join(); // 等左半部分的结果
return leftResult + rightResult;
}
}
// 使用
ForkJoinPool pool = new ForkJoinPool(); // 也可以直接用 ForkJoinPool.commonPool()
long total = pool.invoke(new SumTask(bigArray, 0, bigArray.length));
fork() 把子任务提交给线程池异步执行,join() 等待子任务完成并拿到结果------这一拆一合就是"Fork/Join"这个名字的来源。
它的性能关键在 ForkJoinPool 内部的调度策略:工作窃取(work-stealing)。 每个工作线程维护自己的一个双端队列存放待执行的子任务,正常情况下从队列头部取自己的任务执行;当一个线程把自己队列里的任务都做完了,会去随机挑一个别的线程的队列,从队列尾部"偷"一个任务来执行,而不是空闲等待。这样即使分治拆出来的子任务大小不均匀,整体的 CPU 利用率依然很高,不会出现有些线程闷头干活、有些线程闲着没事的情况。

版本提示 :
ForkJoinPool.commonPool()是 JDK 8 引入的全局共享池,parallelStream()和CompletableFuture默认异步方法都用它。JDK 9 之后CompletableFuture增加了orTimeout()、completeOnTimeout()、delayedExecutor()等超时相关的便捷方法,本篇聚焦 JDK 8 的核心 API,这几个新增方法用法直观,需要时查文档即可。
七、实战:秒杀下单的异步编排流水线
回到开头的场景:扣库存 → 生成订单 必须串行(订单依赖扣库存成功),发优惠券和推消息可以并行(互不依赖),扣库存失败要短路后面所有步骤直接返回失败:
java
public class SeckillOrderPipeline {
private static final ExecutorService BIZ_POOL = Executors.newFixedThreadPool(20);
public CompletableFuture<OrderResult> placeOrder(Long userId, Long productId) {
return CompletableFuture
// 第一步:扣库存
.supplyAsync(() -> deductStock(productId), BIZ_POOL)
// 第二步:扣库存成功才生成订单,用thenCompose串联(createOrderAsync本身返回CompletableFuture)
.thenCompose(deductSuccess -> {
if (!deductSuccess) {
// 失败短路,直接返回一个已完成的失败结果,后面的优惠券/通知步骤都不会执行
return CompletableFuture.completedFuture(OrderResult.fail("库存不足"));
}
return createOrderAsync(userId, productId)
.thenCompose(order -> {
// 第三步:订单生成后,发优惠券和推消息并行跑,用thenCombine合并
CompletableFuture<Coupon> couponFuture =
CompletableFuture.supplyAsync(() -> issueCoupon(userId), BIZ_POOL);
CompletableFuture<Boolean> notifyFuture =
CompletableFuture.supplyAsync(() -> notifyShipping(order.getId()), BIZ_POOL);
return couponFuture.thenCombine(notifyFuture,
(coupon, notified) -> OrderResult.success(order, coupon));
});
})
// 兜底:链路上任何一步抛异常,都在这里统一处理,不让异常裸露到调用方
.exceptionally(ex -> {
log.error("下单异步流水线异常, userId={}, productId={}", userId, productId, ex);
return OrderResult.fail("系统异常,请重试");
});
}
}
这个流水线的耗时模型:总耗时 ≈ 扣库存耗时 + 生成订单耗时 + max(发优惠券耗时, 推消息耗时)------最后两步是并行的,取两者中较长的那个,而不是简单相加。如果同步顺序执行这四步,耗时是四者相加,异步编排在这里能省下"发优惠券"和"推消息"里较短那个的全部耗时。
八、选型小结
| 需求 | 方法 | 关键点 |
|---|---|---|
| 转换上一步的结果(同步值) | thenApply/thenApplyAsync |
转换函数返回裸值,不是CompletableFuture |
| 串联下一步也是异步操作 | thenCompose |
转换函数返回CompletableFuture,自动拍平,避免嵌套 |
| 合并两个独立并行任务的结果 | thenCombine |
类似"zip",两者都完成后用BiFunction合并 |
| 等待多个任务全部完成 | allOf |
返回Void,需要自己逐个.join()取结果 |
| 多个任务任意一个先完成即可 | anyOf |
返回Object,常用于多数据源竞速查询 |
| 只处理异常给替代值 | exceptionally |
正常情况不触发 |
| 正常/异常都要处理并决定最终结果 | handle |
相当于thenApply+exceptionally合体 |
| 正常/异常都要"观察"但不改变结果 | whenComplete |
日志埋点等旁路操作,异常照常往下传 |
| 大计算任务递归拆分并行算 | Fork/Join(RecursiveTask/RecursiveAction) |
工作窃取调度,适合CPU密集型分治算法 |
带走两句话:① 遇到"下一步返回的是不是CompletableFuture"时用thenCompose而不是thenApply,这是避免嵌套 Future 的关键分辨点;CompletableFuture 的默认线程池是全局共享的commonPool,涉及阻塞IO的任务一定要传自定义线程池,不要占用公共资源。② Fork/Join 和 CompletableFuture 不是竞争关系而是分工------CompletableFuture 编排"业务上不同的多个任务",Fork/Join 拆分"同一个大计算任务的子问题",两者甚至可以配合,CompletableFuture 默认异步方法本身就跑在ForkJoinPool.commonPool()上。
下一篇是本系列的收官篇,我们讲虚拟线程与结构化并发------JDK 21 的 Loom 项目带来的虚拟线程,用极低的成本创建数百万个"线程",让阻塞式编程重新变得高效,某种程度上是对这一篇讲的"异步编排"复杂度的一种釜底抽薪式的解法。会讲清楚虚拟线程和平台线程的关系、它解决了传统线程池的什么痛点、结构化并发这个新概念,以及整个系列 14 篇怎么串起来看。