
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 怎么选这两个方法的核心区别是任务有没有返回值。// 有返回值需要返回一个结果 CompletableFutureString future CompletableFuture.supplyAsync(() - { // 模拟远程调用 return 订单数据; }); // 无返回值适合执行耗时的写入、通知、日志等操作 CompletableFutureVoid future2 CompletableFuture.runAsync(() - { // 模拟写日志 System.out.println(执行写操作); });选择原则其实很简单如果你后续还需要这个任务的结果就用supplyAsync如果你只是想让一个任务在后台执行不关心它的返回值比如发起通知、更新缓存、记录审计日志那就用runAsync。我看到不少人在不需要返回值的时候依然用supplyAsync然后回调用thenAccept忽略结果多此一举还增加了类型推断的复杂度。还需要注意一点这两个方法重载版本都支持传入自定义线程池。如果你不传会走默认的ForkJoinPool.commonPool()这个坑后面第 4 章会专门展开讲。2.2 回调三兄弟thenApply、thenAccept、thenRun这三个方法长得像用起来也像但很多人分不清楚。我用一个统一的任务模板来说明。CompletableFutureString future CompletableFuture .supplyAsync(() - 原始数据) .thenApply(data - data 处理后) // 有入参有返回值 .thenAccept(result - System.out.println(result)); // 有入参无返回值 // .thenRun(() - System.out.println(任务结束)); // 无入参无返回值thenApply拿到上一个任务的结果经过某种加工之后返回一个新结果。它适合做数据转换、字段拼接、对象映射。thenAccept拿到上一个任务的结果但不需要返回新值适合做消费型操作比如打印、落库、发消息。记住它返回CompletableFutureVoid。thenRun既不接收上一个任务的结果也不返回新值纯粹是上一个任务跑完了接下来跑这个适合做完成通知、状态标记。一个容易踩的坑这三个方法名都带then默认由驱动调用的线程来执行回调。如果你前面的任务是在主线程创建的而任务由线程池执行回调的线程归属会有变化。想精确控制回调线程就要用带Async后缀的版本比如thenApplyAsync并显式传入线程池。这个Async 后缀意味着什么是面试里特别喜欢问的点。2.3 异常处理三板斧exceptionally、handle、whenComplete异步任务最容易出现的问题就是异常被吞。CompletableFuture 提供了三种异常处理姿势切忌用一个覆盖另一个。// 1. exceptionally只有发生异常时才触发返回一个兜底值 CompletableFutureString f1 CompletableFuture .supplyAsync(() - { if (Math.random() 0.5) { throw new RuntimeException(模拟异常); } return 正常结果; }) .exceptionally(ex - { System.out.println(捕获异常: ex.getMessage()); return 兜底结果; }); // 2. handle无论成功还是失败都会触发根据是否有异常决定处理逻辑 CompletableFutureString f2 CompletableFuture .supplyAsync(() - 正常结果) .handle((res, ex) - { if (ex ! null) { return 错误分支返回; } return res.toUpperCase(); }); // 3. whenComplete只感知结果或异常不改变结果值 CompletableFutureString 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(); CompletableFutureUserVO userFuture CompletableFuture .supplyAsync(() - userClient.getUserInfo(orderId), pool); CompletableFutureOrderVO orderFuture CompletableFuture .supplyAsync(() - orderClient.getOrderInfo(orderId), pool); CompletableFutureProductVO productFuture CompletableFuture .supplyAsync(() - productClient.getProductSnapshot(orderId), pool); CompletableFutureCouponVO couponFuture CompletableFuture .supplyAsync(() - marketingClient.getCouponInfo(orderId), pool); CompletableFutureStockVO stockFuture CompletableFuture .supplyAsync(() - stockClient.getStockStatus(orderId), pool); CompletableFutureVoid 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 CompletableFutureT within(CompletableFutureT future, long timeout, TimeUnit unit) { CompletableFutureT 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 查询订单列表。第二个任务依赖第一个任务的结果天然串行但要写成异步的。CompletableFutureListOrderVO resultFuture CompletableFuture .supplyAsync(() - getUserToken(userId)) .thenCompose(token - CompletableFuture.supplyAsync(() - orderClient.listOrders(token), pool));thenCompose和thenApply的区别是thenApply的返回结果会被嵌套一层 CompletableFuture而thenCompose会把返回的 CompletableFuture 平展开避免CompletableFutureCompletableFutureListOrderVO这种嵌套地狱。你也可以理解为它类似函数式编程里的flatMap。5.2 并行合并thenCombine需求下单前同时获取用户地址和商品实时价格两个结果都拿到后计算最终支付金额。CompletableFutureAddressVO addressFuture CompletableFuture .supplyAsync(() - userClient.getDefaultAddress(userId), pool); CompletableFutureBigDecimal priceFuture CompletableFuture .supplyAsync(() - productClient.getRealTimePrice(productId), pool); CompletableFutureBigDecimal 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等待所有任务完成返回CompletableFutureVoid。它本身不给结果汇总你要自己通过getNow或join从各 Future 里取结果第 3 章的聚合接口就是标准用法。anyOf只要其中一个任务先完成就会触发后续逻辑返回的是先完成的那个任务的结果类型是Object需要你手动强转。anyOf的实战场景很明确比如你同时请求多个城市节点的库存信息只要能拿到第一个可用节点的响应就可以直接继续往下走其他节点任务不必继续等待必要时可以cancel()掉。再比如容灾设计多数据源同时查询谁先返回就先用谁。CompletableFutureObject 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(); // 并行执行基础数据加载 CompletableFutureUserVO userFuture CompletableFuture .supplyAsync(() - userClient.getUserInfo(userId), pool); CompletableFutureListBannerVO bannerFuture CompletableFuture .supplyAsync(() - bannerClient.getBannerList(), pool); CompletableFutureListNoticeVO noticeFuture CompletableFuture .supplyAsync(() - noticeClient.getNoticeList(), pool); // 轮播图就绪后串行加载运营活动 CompletableFutureActivityVO 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 编排逻辑我都会强制自己回答三个问题——如果某个下游服务超时了我的代码会怎样表现如果任务被拒绝执行我的业务会怎样表现如果回调线程意外被中断我的状态流转会怎样表现这三个问题回答清楚了异步代码才算真正立住了。