CompletableFuture 这个东西,我最早是在一次接口性能优化里被逼着用的。当时有个聚合查询接口,要查用户、查订单、查优惠券、查库存,串行下来得 800 多毫秒,慢到被前端同事天天催。后来用 CompletableFuture 改成并行异步,直接把耗时压到了 260 毫秒左右。那是我第一次真正意识到:Java 并发编程里,Future 只是个开始,CompletableFuture 才是能让你把异步编排玩明白的利器。
这几年看过的 Java 面试题里,CompletableFuture 几乎成了继线程池之后必问的并发考点,尤其是 thenCompose、thenCombine、allOf 这些组合用法,以及异常处理时 exceptionally 和 handle 的区别。不夸张地说,这东西就是一面照妖镜:没真正写过异步编排的人,面试时一开口就是"我可以用 Future.get() 阻塞等待",但真正在项目里做过并行调优的人,说出来的完全是另一套思路。这篇内容就围绕 CompletableFuture 的日常实战展开,适合刚接触异步编程的 Java 开发,也适合准备面试想补并发短板的人,或者正在优化接口耗时、需要处理多任务编排的工程同路。
1. 从 Future 说起:异步编程到底解决了什么问题
在聊 CompletableFuture 之前,有必要先把老牌Future的痛点说清楚。很多新手对异步的理解停留在"用线程池提交一个任务,返回 Future,最后 get() 拿结果"这个层面,但实际项目里的异步场景远比这个复杂。
1.1 Future 的三宗罪
第一宗罪:get()是阻塞的。你提交了任务之后,如果立即调用get(),当前线程会一直等到任务执行完。这段等待时间里,线程做不了任何其他事,本质上又退化成了同步调用,异步带来的吞吐提升全部归零。我在代码审查里见过太多人写了future.get()放在业务主流程里,接口耗时一点没降,线程却白白占着。
第二宗罪:任务之间无法组合。你同时发起了三个异步任务,想让它们都完成之后再汇总结果,用原生 Future 怎么写?只能循环 get(),然后手动拼接。想做"第一个任务完成后自动衔接第二个任务",那更是无从下手,只能在代码里写满繁琐的轮询判断。
第三宗罪:异常处理太原始。Future 的异常只能通过get()时抛出ExecutionException捕获,可一旦涉及回调、多任务组合,这个异常就被包了好几层,排查起来相当痛苦,而且很容易被吞掉。调试的时候你会发现自己加了一堆 try-catch,还是不知道异常到底从哪一层冒出来的。
1.2 CompletableFuture 的四个关键变化
CompletableFuture 有四个核心变化值得你记住,这四个变化基本覆盖了前面 Future 的全部痛点。
- 结果可以被显式完成:你可以通过
complete()方法手动给一个尚未完成的任务设置结果,这在做超时兜底、降级返回时极其有用。 - 支持异步回调:任务完成后可以自动触发
thenApply、thenAccept、thenRun等后续动作,不需要你再手动get()。 - 支持任务编排:
thenCompose解决串行依赖,thenCombine解决并行合并,allOf和anyOf解决批量等待。 - 异常变成了数据流:异常可以通过
exceptionally、handle直接处理和转换,不再需要靠 try-catch 层层包裹。
1.3 用一个生活例子理解编排
我经常举一个吃饭的例子来解释这些 API 的区别。你去餐厅吃饭,单点一个菜就是supplyAsync,点完菜等着上菜就是get()。如果你要求"吃完主菜再上甜品",这是thenCompose。如果你同时点了红烧肉和米饭,两边做好再一起吃,这是thenCombine。如果你点了一桌菜,全部上齐才开动,这是allOf;只要有一道招牌菜先上就开吃,这是anyOf。
把生活场景映射到代码设计之后,你就不会觉得 CompletableFuture 的 API 很抽象了,它本质上就是描述任务之间依赖关系的一套语法。
2. 核心 API 逐一拆解:从创建任务到异常兜底
这章是代码密度最高的一部分,我尽量把每个 API 的使用场景和坑都说透。所有示例代码我都基于 Java 8 及以上版本写,因为 CompletableFuture 本身就是 Java 8 引入的,高版本没有额外的新语法依赖。
2.1 创建异步任务:supplyAsync 与 runAsync 怎么选
这两个方法的核心区别是任务有没有返回值。
// 有返回值,需要返回一个结果 CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> { // 模拟远程调用 return "订单数据"; }); // 无返回值,适合执行耗时的写入、通知、日志等操作 CompletableFuture<Void> future2 = CompletableFuture.runAsync(() -> { // 模拟写日志 System.out.println("执行写操作"); });选择原则其实很简单:如果你后续还需要这个任务的结果,就用supplyAsync;如果你只是想让一个任务在后台执行,不关心它的返回值,比如发起通知、更新缓存、记录审计日志,那就用runAsync。我看到不少人在不需要返回值的时候依然用supplyAsync,然后回调用thenAccept忽略结果,多此一举,还增加了类型推断的复杂度。
还需要注意一点:这两个方法重载版本都支持传入自定义线程池。如果你不传,会走默认的
ForkJoinPool.commonPool(),这个坑后面第 4 章会专门展开讲。
2.2 回调三兄弟:thenApply、thenAccept、thenRun
这三个方法长得像,用起来也像,但很多人分不清楚。我用一个统一的任务模板来说明。
CompletableFuture<String> future = CompletableFuture .supplyAsync(() -> "原始数据") .thenApply(data -> data + " 处理后") // 有入参有返回值 .thenAccept(result -> System.out.println(result)); // 有入参无返回值 // .thenRun(() -> System.out.println("任务结束")); // 无入参无返回值thenApply:拿到上一个任务的结果,经过某种加工之后返回一个新结果。它适合做数据转换、字段拼接、对象映射。thenAccept:拿到上一个任务的结果,但不需要返回新值,适合做消费型操作,比如打印、落库、发消息。记住,它返回CompletableFuture<Void>。thenRun:既不接收上一个任务的结果,也不返回新值,纯粹是"上一个任务跑完了,接下来跑这个",适合做完成通知、状态标记。
一个容易踩的坑:这三个方法名都带"then",默认由驱动调用的线程来执行回调。如果你前面的任务是在主线程创建的,而任务由线程池执行,回调的线程归属会有变化。想精确控制回调线程,就要用带Async后缀的版本,比如thenApplyAsync,并显式传入线程池。这个"Async 后缀意味着什么"是面试里特别喜欢问的点。
2.3 异常处理三板斧:exceptionally、handle、whenComplete
异步任务最容易出现的问题就是异常被吞。CompletableFuture 提供了三种异常处理姿势,切忌用一个覆盖另一个。
// 1. exceptionally:只有发生异常时才触发,返回一个兜底值 CompletableFuture<String> f1 = CompletableFuture .supplyAsync(() -> { if (Math.random() > 0.5) { throw new RuntimeException("模拟异常"); } return "正常结果"; }) .exceptionally(ex -> { System.out.println("捕获异常: " + ex.getMessage()); return "兜底结果"; }); // 2. handle:无论成功还是失败都会触发,根据是否有异常决定处理逻辑 CompletableFuture<String> f2 = CompletableFuture .supplyAsync(() -> "正常结果") .handle((res, ex) -> { if (ex != null) { return "错误分支返回"; } return res.toUpperCase(); }); // 3. whenComplete:只感知结果或异常,不改变结果值 CompletableFuture<String> f3 = CompletableFuture .supplyAsync(() -> "正常结果") .whenComplete((res, ex) -> { if (ex != null) { System.out.println("出错,但异常会继续传递"); } else { System.out.println("任务完成,结果是: " + res); } });我的使用习惯是:exceptionally用于"出错了给个默认值,流程继续走"的场景,适合做降级;handle用于"无论成败都要对结果做分支处理"的场景,适合做数据映射;whenComplete只用来打日志、记录指标,因为它吞不掉异常,异常会原样传给下游,千万别想在whenComplete里做降级而忘记返回兜底值,结果异常该抛还是抛。
3. 实战:搭建一个订单聚合查询接口
背景设定很简单:你有一个订单详情页,需要同时展示用户基础信息(用户服务)、订单主数据(订单服务)、商品快照(商品服务)、优惠券抵扣(营销服务)和库存状态(库存服务)。串行调用这五个接口,假设每个服务平均耗时 150ms,总耗时就是 750ms 起步。这在压测时会成为明显的瓶颈。
3.1 串行方案的痛感
串行方案用代码写出来逻辑倒是清楚,但问题非常直观:接口响应时间等于所有依赖服务耗时的总和。线上的 QPS 一高,线程池就被这些"等待任务"占满了,后续请求全部排队。我见过一个老系统,一次订单详情请求要 3 秒多,最后发现不是数据库慢,而是五个远程调用老老实实排着队执行。
3.2 用 CompletableFuture 改造为并行聚合
改造的思路很简单:五个互不依赖的远程调用并发执行,然后等它们全部返回之后汇总。这里用到的是allOf加join()的组合。
public OrderDetailVO queryOrderDetail(String orderId) { // 自定义线程池,理由见第 4 章 ExecutorService pool = ThreadPoolConfig.getOrderDetailPool(); CompletableFuture<UserVO> userFuture = CompletableFuture .supplyAsync(() -> userClient.getUserInfo(orderId), pool); CompletableFuture<OrderVO> orderFuture = CompletableFuture .supplyAsync(() -> orderClient.getOrderInfo(orderId), pool); CompletableFuture<ProductVO> productFuture = CompletableFuture .supplyAsync(() -> productClient.getProductSnapshot(orderId), pool); CompletableFuture<CouponVO> couponFuture = CompletableFuture .supplyAsync(() -> marketingClient.getCouponInfo(orderId), pool); CompletableFuture<StockVO> stockFuture = CompletableFuture .supplyAsync(() -> stockClient.getStockStatus(orderId), pool); CompletableFuture<Void> all = CompletableFuture .allOf(userFuture, orderFuture, productFuture, couponFuture, stockFuture); // 阻塞等待所有任务完成 all.join(); OrderDetailVO vo = new OrderDetailVO(); vo.setUser(userFuture.getNow(null)); vo.setOrder(orderFuture.getNow(null)); vo.setProduct(productFuture.getNow(null)); vo.setCoupon(couponFuture.getNow(null)); vo.setStock(stockFuture.getNow(null)); return vo; }这里我用getNow(null)而不是get(),是因为allOf.join()已经保证所有任务正常完成,只要没有异常抛出,每一个 Future 里必然有结果,getNow不会真的返回 null,它只是语义上更轻量。当然,如果你想严谨一点,可以在每个任务里补充exceptionally返回一个默认空对象,再配合getNow使用,聚合接口就永远不会因为单个下游服务故障而整体失败。
3.3 增加超时与兜底:fail-fast 还是 fail-safe
线上环境最怕的是下游服务假死:既不返回结果,也不抛异常,连接一直挂着。CompletableFuture 在 Java 9 之前没有原生的超时方法(Java 9 提供了orTimeout和completeOnTimeout),所以如果你想兼容 Java 8,就得自己封装超时逻辑。
public static <T> CompletableFuture<T> within(CompletableFuture<T> future, long timeout, TimeUnit unit) { CompletableFuture<T> result = new CompletableFuture<>(); // 当原 future 完成时,把结果或异常传递给 result future.whenComplete((data, ex) -> { if (ex != null) { result.completeExceptionally(ex); } else { result.complete(data); } }); // 超时则主动让 result 以异常结束 result.orTimeout(timeout, unit); return result; }如果你的项目版本已经是 Java 9 以上,直接使用future.orTimeout(500, TimeUnit.MILLISECONDS)就行,底层原理就是上面这段封装做的事。至于兜底策略,我建议根据接口语义决定:用户详情和订单主数据属于关键字段,超时宁可失败也不能给空数据;优惠券和库存则可以做降级,超时返回"无优惠、有库存"的空对象,保证页面可用。
4. 线程池选型:别让默认线程池拖垮你的服务
CompletableFuture 最容易被吐槽"生产环境不敢用"的原因,往往不是 API 有问题,而是线程池用错了。默认情况下的坑非常隐蔽,我把它单独拿出来讲。
4.1 默认 ForkJoinPool.commonPool() 的坑
不传线程池,CompletableFuture 会使用ForkJoinPool.commonPool()。这个池在大多数应用里是共享的,它有两个问题。
第一,并行度不是由你控制的,它默认是 CPU 核数减 1。如果你的服务部署在 4 核机器上,公共池只有 3 个线程。3 个线程去扛所有类目的 CompletableFuture 异步任务,高并发下任务会大量堆积。第二,它和你的业务线程池之间很容易产生依赖问题。如果你的 Web 容器线程阻塞等待 commonPool 里的任务完成,而 commonPool 里的任务又在等某个本应执行的任务释放线程,就可能出现死等。尤其在压测场景里,这种"池中池"的问题非常难排查。
4.2 自定义线程池的参数与经验值
生产环境我是建议一定显式传入线程池。参数怎么定?没有银弹,但有公式和实测调整的空间。
public class ThreadPoolConfig { public static ExecutorService getOrderDetailPool() { int cpuNum = Runtime.getRuntime().availableProcessors(); ThreadPoolExecutor executor = new ThreadPoolExecutor( cpuNum * 2, cpuNum * 4, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000), new ThreadPoolExecutor.CallerRunsPolicy()); return executor; } }- 核心线程数:I/O 密集任务建议
CPU核数 * 2起步,如果单次任务时间较长(远程调用普遍在 50ms 以上),可以适度增加。很多框架会把推荐值写成核数 / (1 - 阻塞系数),实际算出来会偏高,我自己的经验是从 2 倍核数开始压,逐步往上调,观察线程活跃度和任务队列积压情况。 - 最大线程数和队列:如果你是内存型服务,队列别设置太长,1000 已经不算少。一旦队列堆满,任务被拒绝或者走降级策略,比无限排队更容易发现问题。
- 拒绝策略:
CallerRunsPolicy是我个人偏好。当线程池满的时候,任务由调用方线程执行,这个策略不会丢任务,只是在突发流量下会让调用线程变慢,给系统一个自我保护的机会。
4.3 线程池隔离与命名规范
另一个重要实践是线程池隔离。订单聚合用的线程池,和异步通知用的线程池,不要共用一个。一个下游服务拖慢,不能把其他所有异步链路都拖死。命名上也建议用公司统一的线程工厂,给线程加可读性前缀,比如"order-aggregate-pool-thread-1",而不是默认的"pool-1-thread-1"。线上排查线程 dump 的时候,线程名就是你的第一线索。
5. 组合 API 与复杂编排:thenCompose、thenCombine、allOf 实战
如果只是单任务异步,CompletableFuture 和 Future 差距不大。真正拉开差距的,是它对任务编排的支持。这一章我把三种最常见编排场景串起来讲。
5.1 串行依赖:thenCompose
需求:先查询用户 ID,再用用户 ID 查询订单列表。第二个任务依赖第一个任务的结果,天然串行,但要写成异步的。
CompletableFuture<List<OrderVO>> resultFuture = CompletableFuture .supplyAsync(() -> getUserToken(userId)) .thenCompose(token -> CompletableFuture.supplyAsync(() -> orderClient.listOrders(token), pool));thenCompose和thenApply的区别是:thenApply的返回结果会被嵌套一层 CompletableFuture,而thenCompose会把返回的 CompletableFuture 平展开,避免CompletableFuture<CompletableFuture<List<OrderVO>>>这种嵌套地狱。你也可以理解为它类似函数式编程里的flatMap。
5.2 并行合并:thenCombine
需求:下单前,同时获取用户地址和商品实时价格,两个结果都拿到后计算最终支付金额。
CompletableFuture<AddressVO> addressFuture = CompletableFuture .supplyAsync(() -> userClient.getDefaultAddress(userId), pool); CompletableFuture<BigDecimal> priceFuture = CompletableFuture .supplyAsync(() -> productClient.getRealTimePrice(productId), pool); CompletableFuture<BigDecimal> payAmountFuture = addressFuture .thenCombine(priceFuture, (address, price) -> { // 地址和价格同时就绪后,执行运费与价格运算 BigDecimal freight = address.isFreeShipping() ? BigDecimal.ZERO : BigDecimal.valueOf(10); return price.add(freight); });thenCombine适合两个任务之间没有依赖、但最终要合并结果的场景。它还有一个变体thenAcceptBoth,区别只是合并后有没有返回值。如果两个任务返回的是不同类型,用thenCombine完全没有问题,泛型参数天然支持异构类型。
5.3 批量等待:allOf 与 anyOf 的选择
这两个方法名字里都有"Of",但语义相反。
allOf:等待所有任务完成,返回CompletableFuture<Void>。它本身不给结果汇总,你要自己通过getNow或join从各 Future 里取结果,第 3 章的聚合接口就是标准用法。anyOf:只要其中一个任务先完成,就会触发后续逻辑,返回的是先完成的那个任务的结果,类型是Object,需要你手动强转。
anyOf的实战场景很明确:比如你同时请求多个城市节点的库存信息,只要能拿到第一个可用节点的响应,就可以直接继续往下走,其他节点任务不必继续等待,必要时可以cancel()掉。再比如容灾设计,多数据源同时查询,谁先返回就先用谁。
CompletableFuture<Object> firstHit = CompletableFuture.anyOf( CompletableFuture.supplyAsync(() -> queryFromCache(key), pool), CompletableFuture.supplyAsync(() -> queryFromRemote(key), pool));需要注意,当先完成的任务以异常结束时,anyOf返回的 future 也会以异常结束,但是这个异常可能不是最先抛出的那个,实际异常可能藏在你没有等待的某个 future 里。所以用anyOf时要格外小心,不要依赖异常信息做精细判断,它更多适用于"谁成功先取谁"的抢答语义。
5.4 一个复杂编排的完整示例
我现在把串行、并行、批量等待放在同一个场景里演示。需求是:APP 首页加载用户信息,同时并行加载轮播图列表和公告列表;轮播图加载完后,再根据第一张轮播图的跳转类型去加载对应的运营活动页数据。这就把supplyAsync、thenCompose、thenCombine、allOf全部串起来了。
public HomePageVO buildHomePage(String userId) { ExecutorService pool = ThreadPoolConfig.getHomePagePool(); // 并行执行基础数据加载 CompletableFuture<UserVO> userFuture = CompletableFuture .supplyAsync(() -> userClient.getUserInfo(userId), pool); CompletableFuture<List<BannerVO>> bannerFuture = CompletableFuture .supplyAsync(() -> bannerClient.getBannerList(), pool); CompletableFuture<List<NoticeVO>> noticeFuture = CompletableFuture .supplyAsync(() -> noticeClient.getNoticeList(), pool); // 轮播图就绪后,串行加载运营活动 CompletableFuture<ActivityVO> activityFuture = bannerFuture .thenCompose(banners -> { if (banners == null || banners.isEmpty()) { return CompletableFuture.completedFuture(null); } String jumpType = banners.get(0).getJumpType(); return CompletableFuture.supplyAsync( () -> activityClient.getActivityByType(jumpType), pool); }); // 所有任务都完成后,汇总结果 CompletableFuture.allOf(userFuture, bannerFuture, noticeFuture, activityFuture).join(); HomePageVO vo = new HomePageVO(); vo.setUser(userFuture.getNow(null)); vo.setBanners(bannerFuture.getNow(null)); vo.setNotices(noticeFuture.getNow(null)); vo.setActivity(activityFuture.getNow(null)); return vo; }这个示例基本覆盖了日常接口能遇到的绝大多数编排场景。如果你的业务里出现比这个更复杂的依赖关系,比如 DAG 类型的分层编排,我反而建议考虑异步编排框架,而不是继续在 CompletableFuture 上手工叠加,因为可读性和维护成本会急剧上升。
6. 常见问题与排查思路
写完代码只是开始,维护才是大头。我把这几年在线上排查 CompletableFuture 相关问题遇到的典型案例整理成了一份速查表,每一类都很容易踩。
6.1 任务莫名不执行
现象:代码看起来没问题,但某个 CompletableFuture 分支里的日志一直没打印。
排查思路:
- 检查是否在任务链路中使用了
orTimeout或completeOnTimeout,超时触发后,原任务不会真的被取消,它只是被标记为已完成,底层线程里的逻辑可能还在默默执行,只是没人关心结果了。 - 检查自定义线程池的队列是否堆满。如果任务进了队列但一直没有线程执行,不是 CompletableFuture 的问题,是线程池配置问题,结合线程池监控看活跃线程数。
- 检查回调链上是否有人调用了
cancel()。cancel()之后,下游回调不会触发,而且不会抛出异常,非常隐蔽。
6.2 异常被静默吞掉
现象:任务链路里明明有异常发生,但业务方感知不到,就只是某个数据字段为空。
排查思路:
- 先确认整个链路里有没有用
whenComplete打日志。这个我强调过,whenComplete不会吞异常,异常会继续往下传,如果你只在最外层看了get(),可能中间层已经用exceptionally做了兜底,导致外层看到的永远是兜底值。 - 提醒各位:如果某个 Future 被完全丢弃,没有任何后续操作,那么它的异常会被
ForkJoinPool内部静默处理,日志里几乎看不出来。我习惯在所有异步任务入口或出口都加一条"task done: {}, ex: {}"的日志,宁可日志多一点。
6.3 超时设置的两个常见误区
误区一是认为orTimeout能终止任务。它不能,它只能让等待方尽快释放。底层线程如果卡在远程调用上,该等多久还是等多久,只是结果不再有消费者。
误区二是超时时间设置不区分上下游。我见过一个接口,对本地缓存查询给了 5 秒超时,对远程服务也给了 5 秒,完全没考虑不同依赖的真实耗时分布。正确的做法是先看历史 P99 耗时,把超时时间设成 P99 的 1.5 到 2 倍,既能容忍正常波动,又能快速暴露故障。
6.4 排查技巧与速查表
排查 CompletableFuture 问题,我第一件事永远是看线程 dump。线程名是关键,如果你定义了可读性强的线程池前缀,直接搜索线程名就能定位到申请了任务却未完成的位置。第二件事是看任务数量的累计指标,建议至少统计提交任务数、完成任务数、失败任务数三个指标,用 Micrometer 或公司自带的监控体系都行。最后我整理了一份问题对照表:
| 现象 | 可能原因 | 建议处理 |
|---|---|---|
| 任务不执行 | 线程池队列堆积或池已 shutdown | 检查线程池监控,拒绝策略是否生效 |
| 异常无日志 | 中间exceptionally兜底或 Future 被丢弃 | 链路入口和出口必打日志 |
| 接口长时间无响应 | 未设置超时或超时时间过大 | 按 P99 耗时设置orTimeout |
| 结果只有部分字段 | allOf后用了getNow(null)但没有兜底异常分支 | 给每个子任务增加exceptionally |
| 内存飙升 | 大量无界队列堆积异步任务 | 使用有界队列并定义拒绝策略 |
| 回调线程和预期不符 | 未使用 Async 后缀方法 | 明确是否指定回调线程池 |
这套速查表不一定能覆盖所有问题,但它能帮你快速收敛排查范围。对我而言,CompletableFuture 最难的从来不是 API 背不出来,而是在真实项目里出了问题之后,你能不能在最短时间内判断出是线程池的问题、编排逻辑的问题还是下游服务的问题。这需要大量的真实调用和数据支撑,光靠看文档是学不来的。
最后分享一个我自己的小习惯:每次写完一段 CompletableFuture 编排逻辑,我都会强制自己回答三个问题——如果某个下游服务超时了,我的代码会怎样表现?如果任务被拒绝执行,我的业务会怎样表现?如果回调线程意外被中断,我的状态流转会怎样表现?这三个问题回答清楚了,异步代码才算真正立住了。