
在 Java 并发编程里CompletableFuture 算是把异步编程门槛拉低了一个档位的存在。本来我不太想写这个被写烂了的主题但最近连续在两个项目里看到有人把它用成加强版 Future 加回调——该编排的没编排该兜底的没兜底线程池直接用默认的 commonPool最后线上一个接口超时排查了整整两天发现是几百个异步任务把公共线程池打满了。所以这篇不准备堆源码分析而是从一个用它写了三年业务代码的人的角度把 CompletableFuture 常用 API 的取舍、多任务编排思路、异常恢复边界、线程池坑位以及一个完整业务改造案例串起来。如果你想写聚合接口、有超时兜底需求或者单纯想把串行调用改成并行异步这篇文章应该直接给你一套能落地的写法。可能你在面试八股里也见过这个名字但面试问的和业务里真正要用的完全是两码事。1. 从 Future 到 CompletableFuture当年我们是怎么被逼疯的1.1 传统 Future 的实际体验阻塞、轮询、没法组合在 CompletableFuture 之前Java 5 引入的 Future 是我们唯一的异步结果载体。用法很简单往线程池丢一个 Callable拿到 Future然后在需要结果的地方调用 get()。但问题恰恰出在这个 get() 上。ExecutorService pool Executors.newFixedThreadPool(4); FutureString userFuture pool.submit(() - userService.getUserName(userId)); FutureOrder orderFuture pool.submit(() - orderService.getLatestOrder(userId)); // 主线程被阻塞两个任务其实是串行等待 String userName userFuture.get(); Order order orderFuture.get();get() 是阻塞调用任务没完成就一直在那等着。你要是不想阻塞只能自己写循环去轮询 isDone()这不仅是 CPU 空转轮询间隔也不好定——太快浪费 CPU太慢白白增加响应延迟。更麻烦的是异常处理任务执行过程中的异常会被包成 ExecutionException你在调用侧拿到的是一个层层包裹的异常对象定位问题还要一层一层拆开看。还有一点Future 之间完全没有组合能力。一个任务依赖另一个任务的结果你得先 get() 完第一个再往线程池丢第二个。两个独立任务要合并成一个结果手动写回调或者再加一个 Future。业务稍微复杂一点代码就乱成一团。1.2 CompletableFuture 补齐的四块核心能力CompletableFuture 是 Java 8 引入的它同时实现了 Future 和 CompletionStage 两个接口。意思是它既能当 Future 用get、join、cancel 都有又天生具备阶段任务编排的能力。我个人理解它补上了传统 Future 缺的四块拼图回调驱动任务完成后自动触发后续动作阻塞轮询的写法可以彻底丢掉。你不再需要主动去问任务好没好而是告诉框架好了以后干什么。依赖编排一个任务的结果可以作为另一个任务的输入两个并行任务的结果可以合并多个任务可以整体等待——这些都能用声明式写法表达不用自己维护中间状态。异常在链上游走链式回调中任何一步抛异常会被统一带到异常处理节点让成功路径和失败路径在代码里分开。手动完成complete() 可以手动把一个 future 置为完成状态。你可以在异步任务里把它交给另一个线程甚至用外部事件去完成它对异步协议对接、基于回调的 SDK 封装特别有用。这四块能力对应到日常编码就是下面几节要展开的 API。先提醒一句CompletableFuture 方法超级多CompletionStage 就定义了几十个抽象方法但常用的就那么十来个把用法原理搞明白比背方法清单重要得多。2. 核心 API 实战创建、回调、串联的正确姿势2.1 创建任务supplyAsync 和 runAsync别用错了创建 CompletableFuture 的入口有两个// 不需要返回结果记录日志、发消息、清理缓存这类 CompletableFutureVoid f1 CompletableFuture.runAsync(() - { log.info(异步清理过期缓存); }); // 需要返回结果远程调用、查数据库、算一个值 CompletableFutureString f2 CompletableFuture.supplyAsync(() - { return httpClient.get(https://example.com/api/order); });区别就一句话runAsync 接收 Runnable执行完没有返回值supplyAsync 接收 Supplier执行完要把结果交给后续阶段。日常开发 95% 的场景用的是 supplyAsync因为你需要用到结果。这里有个默认行为先记住不传线程池的 supplyAsync/runAsync底层统一用 ForkJoinPool.commonPool() 去跑任务。本地开发没问题一上生产任务一多公共线程池就堵了。这个话题放到第五部分单独说这里先不展开。2.2 thenApply、thenAccept、thenRun回调三部曲怎么选任务创建完之后最常用的就是给这个 future 挂回调。这三个方法很像但用错地方会让代码别扭好几倍CompletableFutureInteger amountFuture CompletableFuture.supplyAsync(() - orderService.getAmount(orderId)); // 想对结果加工并继续传下去 → thenApply CompletableFutureString formatFuture amountFuture.thenApply(amount - 金额 amount); // 只想消费结果不再往下传 → thenAccept amountFuture.thenAccept(amount - log.info(订单金额{}, amount)); // 既不需要输入也不关心结果只等它执行完 → thenRun amountFuture.thenRun(() - log.info(金额查询完成));选择逻辑总结成一张表方法入参类型返回结果典型场景thenApplyFunctionT,R新的 CompletableFuture对结果做转换、加工流水线继续thenAcceptConsumerCompletableFuture用结果做副作用比如日志、存储thenRunRunnableCompletableFuture只关心做完了这个事件比如清理这三个是同步回调版本意思是回调逻辑会在上层任务完成的线程里直接执行。如果回调逻辑本身比较重或者你想严格控制线程池应该用它们的 Async 变体 thenApplyAsync/thenAcceptAsync/thenRunAsync并手动传线程池。我在写业务聚合接口时的经验是回调阶段只要涉及远程调用或重计算一律用 Async 变体避免回调把完成任务的线程池堵住。2.3 thenCompose 和 thenCombine依赖与并行两条路线这是最容易混的一组。一句话区分thenCompose 用于第二个任务依赖第一个任务的结果相当于流式操作里的 flatMapthenCombine 用于两个任务彼此独立最后合并相当于 zip。依赖串联用 thenComposeCompletableFutureString future CompletableFuture.supplyAsync(() - createOrder()) .thenCompose(orderId - CompletableFuture.supplyAsync(() - pay(orderId))); // createOrder 先执行拿到 orderId 之后才启动 pay 任务注意这里如果没有 thenCompose而是用 thenApply你会得到 CompletableFutureCompletableFuture 这种嵌套结构后续处理非常别扭。thenCompose 的作用就是把这个嵌套摊平。并行合并用 thenCombineCompletableFutureUser userFuture CompletableFuture.supplyAsync(() - userService.get(userId)); CompletableFutureGoods goodsFuture CompletableFuture.supplyAsync(() - goodsService.get(goodsId)); CompletableFutureString descFuture userFuture.thenCombine(goodsFuture, (user, goods) - user.getName() 购买了 goods.getName());两个 future 谁先完成不影响最终结果BiFunction 会在两边都完成后拿到各自结果再执行合并逻辑。这种写法天然适合并行查多个数据源再组装。还有一个常见的写法陷阱如果你发现代码里大量出现 thenCompose(() - CompletableFuture.supplyAsync(...)) 这种啰嗦组合建议封装一个工具方法把等某个依赖完成后再异步执行新任务这个动作收敛起来第六部分的实战案例会给出具体封装。3. 多任务编排allOf 聚合与 anyOf 竞速的实用套路3.1 allOf等待 N 个任务全部完成以及怎么优雅取值聚合接口最常见的需求同时发 N 个请求等全部回来之后组装。ListLong goodsIds ...; ListCompletableFutureGoodsVO futures goodsIds.stream() .map(id - CompletableFuture.supplyAsync(() - goodsService.get(id), asyncExecutor)) .collect(Collectors.toList()); CompletableFutureVoid allDone CompletableFuture.allOf( futures.toArray(new CompletableFuture[0]));allOf 返回 CompletableFuture ——注意它本身不携带任何结果只表示全都完成了。拿到结果的方法是让每个任务 future 自己 join()CompletableFutureListGoodsVO resultFuture allDone.thenApply(v - futures.stream() .map(CompletableFuture::join) .collect(Collectors.toList()));这里的 join() 不会真的阻塞太久因为 allOf 已经保证所有任务都结束了此时 join 只是收集返回值。但有两个坑要记住如果某个任务异常完成join() 会抛 CompletionException。allOf 对异常的处理有一个容易误伤的细节哪怕里面只有一个任务挂了整个 allOf 也会以异常完成剩下的任务即使正常返回了你的整体聚合依然会失败。正确姿势是要么给每个子任务都挂 exceptionally 兜底要么在聚合之后的环节用 handle/exceptionally 统一兜底别裸奔 join。提示用 ListCompletableFuture 配合 allOf 是很典型的批处理模式但不要真的去写一个空数组 new CompletableFuture[0] 每次手工建用集合的 toArray 一次性转换即可注意泛型擦除后要强转成 CompletableFuture[]。3.2 anyOf谁先回来用谁的竞速模式anyOf 的语义是只要其中一个完成返回的 future 就完成。最典型的场景是多级缓存回退本地缓存、redis、原始接口同时发起谁先返回就用谁整体延迟取最快路径。CompletableFutureObject future CompletableFuture.anyOf( CompletableFuture.supplyAsync(() - localCache.get(key)), CompletableFuture.supplyAsync(() - remoteCache.get(key)), CompletableFuture.supplyAsync(() - db.get(key), asyncExecutor) ); Object result future.join(); // 返回类型是 Object需要自己强转用 anyOf 有三个细节提醒一下返回类型是 Object拿到之后必须强转所以参与竞速的任务最好约好返回同一类型。anyOf 完成不代表其他任务被取消剩余任务会继续在后台跑完。如果剩余任务有写数据、打日志这类副作用没问题但如果是占线程池的重任务要注意控制并发规模。想做到第一个成功后取消其他任务CompletableFuture 原生 API 做不到需要自己维护 future 列表在 whenComplete 里手动 cancel。如果你的场景确实需要强一致性的竞速可以自己封装一层这个属于进阶玩法。4. 异常与超时兜底exceptionally、handle、whenComplete 的职责边界4.1 异常在回调链上是如何传播的写异步链最容易担心的就是异常去哪了。先明确传播规则链中的每个阶段只要上游异常且当前阶段不是异常处理节点就会跳过当前阶段继续向下游传播直到遇到一个能兜底的节点。CompletableFuture.supplyAsync(() - { throw new RuntimeException(上游炸了); }) .thenApply(s - s 处理) // 不会执行 .thenAccept(s - log.info(s)) // 不会执行 .exceptionally(ex - 兜底值); // 在这里接住异常这段代码里异常会从 supplyAsync 一路穿透 thenApply 和 thenAccept直接掉进 exceptionally。常规回调节点对异常是免疫的不会自己处理只是把异常往后传。这意味着你可以在链的末端统一兜底也可以在链路中间按需接住只要想清楚到底在哪一层恢复。4.2 三个异常处理方法的真实区别对比一下最常用的三个方法触发条件能否改变结果典型用途exceptionally仅当前置阶段异常时触发能需返回同类型恢复值给一个默认兜底值handle成功或异常都触发能可以基于 (result, ex) 返回新值统一分支处理两种状态whenComplete成功或异常都触发不能返回值类型不变日志、监控、资源清理代码示例// exceptionally只有异常才进入给默认值 CompletableFutureInteger f1 CompletableFuture.supplyAsync(() - queryCount()) .exceptionally(ex - 0); // handle成功失败都进参数里区分 CompletableFutureInteger f2 CompletableFuture.supplyAsync(() - queryCount()) .handle((result, ex) - ex ! null ? 0 : result); // whenComplete只记录不改变结果 CompletableFutureInteger f3 CompletableFuture.supplyAsync(() - queryCount()) .whenComplete((result, ex) - { if (ex ! null) log.error(查询失败, ex); else log.info(查询结果 {}, result); });我的使用习惯是默认优先用 exceptionally因为出异常才走兜底逻辑最贴合恢复语义代码分支最少handle 适合那种无论成功失败都要转换一次结果的场景比如统一包装响应对象或者做监视指标whenComplete 只适合做旁路观察别想着在里面修数据因为它的返回值类型锁死了你改的东西不会往上传递。4.3 超时兜底Java 9 的 orTimeout 与 Java 8 的兼容写法异步任务挂在远程调用上最怕的是对方一直不返回。Java 9 给 CompletableFuture 加了 orTimeout 和 completeOnTimeout 两个方法// orTimeout超时后以异常完成 CompletableFuture.supplyAsync(() - httpClient.post(uri, body), asyncExecutor) .orTimeout(2, TimeUnit.SECONDS) .exceptionally(ex - fallbackResponse()); // completeOnTimeout超时后以指定默认值完成 CompletableFuture.supplyAsync(() - httpClient.post(uri, body), asyncExecutor) .completeOnTimeout(fallbackResponse(), 2, TimeUnit.SECONDS);注意这两个是有区别的orTimeout 之后链上是异常状态你得再补一个 exceptionally/handle 才能恢复completeOnTimeout 直接把默认值作为正常结果后面链路不用感知超时。需要记录到底是不是超时用 orTimeout 更合适因为你能捕获 TimeoutException只要兜底结果用 completeOnTimeout 更省事。如果你的项目还在 Java 8没有这两个方法最实用的兼容方案是 get(timeout)CompletableFutureString future CompletableFuture.supplyAsync(() - httpClient.get(uri), asyncExecutor); String result; try { result future.get(2, TimeUnit.SECONDS); } catch (TimeoutException e) { future.cancel(true); // 尽量把任务取消掉释放线程池 result fallbackResponse(); }注意这个方案有一个局限get(timeout) 是在等待方视角的超时future 本身并不会因为超时而自动完成。如果你把 future 传给了别的调用方那边拿到的还是一个未完成状态。所以 Java 8 项目做彻底的超时控制最好再包一层带超时语义的 future 往下传把超时即完成的语义也封装进去。5. 线程池选择别再让所有异步任务挤在 commonPool 里5.1 commonPool 是怎么把线上接口拖垮的不传线程池时CompletableFuture 的所有异步任务都跑在 ForkJoinPool.commonPool() 上。这个池的并行度默认是 CPU 核数减 1计算公式是 Runtime.getRuntime().availableProcessors() - 1。听起来挺合理但对 IO 密集型业务来说就是一个灾难。假设一台 4 核机器commonPool 并行度只有 3。你一个聚合接口发 5 个异步远程调用3 个先占着线程在等网络响应剩下 2 个任务在队列里排队而这个排队时间不受你控制。更糟糕的是JVM 内部很多框架组件也复用这个池比如并行流 parallelStream你这边一打满别的组件也跟着卡问题完全无法隔离。我印象很深的一次事故一个接口平时 60ms某天高峰期突然涨到 5 秒。查了半天发现不是下游慢而是某段代码用 parallelStream 处理集合里面又嵌套了 CompletableFuture.supplyAsync 去调远程两层任务全挤在 commonPool 上线程全部在等 IO处理新任务的线程几乎没有。这种问题不看线程 dump 根本想不到。注意线上问题排查时看到一个线程栈全在 wait/网络 IO而线程名是 ForkJoinPool.commonPool-worker-xx第一反应就该想到是不是有人没传线程池。5.2 自定义线程池的参数配置与正确使用方式我的建议很简单凡是处理业务异步任务一律显式创建一个有业务名的线程池ThreadPoolExecutor asyncExecutor new ThreadPoolExecutor( corePoolSize, // 常驻线程数 maxPoolSize, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲回收时间 new LinkedBlockingQueue(capacity), new NamedThreadFactory(async-biz-), // 一定要给线程起名线上排查看得懂 new ThreadPoolExecutor.CallerRunsPolicy() );线程池参数怎么定取决于任务类型。远程 IO 调用为主的场景我的经验是 core 线程数可以开到 CPU 数的 2 倍左右因为线程大部分时间在等网络响应多一点线程能提高吞吐如果是纯 CPU 计算core 和 max 就不要超过 CPU 核数否则线程切换开销反而拖慢速度。maxPoolSize 跟队列容量要一起设计线程满了之后新任务进队列队列也满了才会开新线程队列容量设得太大maxPoolSize 就成了摆设。命名线程工厂这点特别容易被忽略。用 Executors 默认的线程池线上查线程栈全是pool-3-thread-1你根本不知道这段代码属于哪个业务。自定义 NamedThreadFactory 之后一查线程栈就能定位到async-biz-xxx是哪个链路出来的。用的时候所有入口和回调都显式把线程池传进去CompletableFutureUserVO future CompletableFuture.supplyAsync(supplier, asyncExecutor) .thenApplyAsync(func, asyncExecutor); // 注意带 Async 的回调版本才会用传入线程池再强调一遍thenApply 这类不带 Async 后缀的同步方法不会真正使用你传入线程池去执行回调它会在任务完成的线程里直接跑。想要回调也走独立线程池必须用 thenApplyAsync、thenComposeAsync 这一系列 Async 方法。这是 CompletableFuture API 设计里最容易被忽略的细节也是不少线程池参数配置后根本没生效的真相。6. 实战案例订单详情聚合接口的异步改造全过程6.1 改造前的问题四个远程调用串行跑先还原场景。后端有一个查订单详情的接口需要拿到四类数据订单主信息order 服务、用户信息user 服务、商品明细列表goods 服务、物流信息logistics 服务。第一版是大多数人都会写的串行代码public OrderDetailVO getOrderDetail(String orderId) { Order order orderService.query(orderId); // 约 40ms UserVO user userService.get(order.getUserId()); // 约 60ms ListGoodsVO goods goodsService.list(order.getGoodsIds()); // 约 80ms LogisticsVO logistics logisticsService.getByOrder(orderId); // 约 50ms return buildVO(order, user, goods, logistics); // 总耗时约 230ms }四个调用加起来 230ms看着不高但这是单个请求的耗时。接口 QPS 一高用户体验差异就很明显而且远程调用链越长偶发的慢调用就越容易拖垮整个请求。这种场景正是 CompletableFuture 最值得上也最容易见效的地方。6.2 异步编排改造串行依赖 并行查询 统一兜底分析依赖关系用户、商品、物流这三个查询都得先知道订单主信息但彼此之间互不依赖。正确编排方式是先查订单 → 并行查剩下三个 → 聚合。我的写法是封装一个很小的工具方法把等某个前置 future 完成后再异步执行新任务这个动作收敛起来private T, R CompletableFutureR after(CompletableFutureT prerequisite, FunctionT, R task) { return prerequisite.thenComposeAsync( value - CompletableFuture.supplyAsync(() - task.apply(value), asyncExecutor), asyncExecutor); }然后整个方法可以写成public CompletableFutureOrderDetailVO getOrderDetailAsync(String orderId) { // 1. 先查订单主信息失败给一个可识别的空订单兜底 CompletableFutureOrder orderFuture CompletableFuture.supplyAsync( () - orderService.query(orderId), asyncExecutor) .exceptionally(ex - { log.error(查询订单失败, orderId{}, orderId, ex); return null; }); // 2. 三个互不依赖的查询并行执行 CompletableFutureUserVO userFuture after(orderFuture, order - order null ? null : userService.get(order.getUserId())); CompletableFutureListGoodsVO goodsFuture after(orderFuture, order - order null ? null : goodsService.list(order.getGoodsIds())); CompletableFutureLogisticsVO logisticsFuture after(orderFuture, order - order null ? null : logisticsService.getByOrder(orderId)); // 3. 全部完成后再聚合 return CompletableFuture.allOf(userFuture, goodsFuture, logisticsFuture) .thenApplyAsync(ignore - { Order order orderFuture.join(); UserVO user userFuture.join(); ListGoodsVO goods goodsFuture.join(); LogisticsVO logistics logisticsFuture.join(); if (order null || user null || goods null || logistics null) { throw new DataIncompleteException(订单详情数据不完整, orderId orderId); } return buildVO(order, user, goods, logistics); }, asyncExecutor) .exceptionally(ex - { log.error(订单详情聚合失败, orderId{}, orderId, ex); return buildFallbackVO(orderId); }); }把方法返回值直接设计成 CompletableFuture Controller 层可以自行决定是阻塞等待还是继续编排。如果上游框架要求同步 API在外面加一个 get(3, TimeUnit.SECONDS) 即可把超时控制在调用方。这里有两个细节值得说明after 方法里的 orderFuture 一定是先完成的所以用户、商品、物流三个任务内部不用再操心 orderFuture 的状态代码表达的就是订单好了才查后面的依赖关系一目了然。如果 orderFuture 因为异常兜底返回了 null用户、商品、物流任务会拿到 null从而各自返回 null 完成最终在聚合阶段被 DataIncompleteException 拦下来统一走 fallback。这个流程保证了下游服务出问题时接口不会静默返回一个残缺的 VO。6.3 改造后的收益与复盘时真正容易忽视的细节我在一台 4 核机器上简单压了一下这个接口下游用模拟延迟order 40ms、user 60ms、goods 80ms、logistics 50ms。串行版本平均耗时约 228ms异步编排后平均耗时约 85ms瓶颈从四个调用的总和变成了最慢链路order goods的耗时。收益大约 2.7 倍而且是纯编排层面的优化没动任何下游服务。这个数字看起来很简单但真正上线后有几个细节才是决定成败的线程池隔离业务异步线程池要和框架内部线程池分开避免互相踩踏。commonPool 的问题前面说过了这里再补一句就算用了自定义线程池也要关注拒绝策略。业务峰值突发时CallerRunsPolicy 会把压力回传到调用线程配合超时控制一起用才安全。TraceId 透传远程调用跨线程后MDC 里的 traceId 会丢排错链路直接断掉。我的做法是在包装任务时先取出当前线程的 traceId任务内部设置好了再执行执行完清理。跨线程传 traceId 这种小事等到线上查问题时会救你一命。长耗时的聚合任务必须层层设超时整体 get(timeout) 只是最后一道防线前面每一步远程调用都该有自己的超时和兜底否则一个慢调用会拖住整个链你的 fallback 再快也没用。压测时盯线程池指标CompletableFuture 的编排非常隐蔽单看方法调用很难发现线程池打满建议把 asyncExecutor 的核心线程数、活跃线程数、队列深度接到监控上出现积压能第一时间看到而不是等接口超时才回头查。最后说个我自己的习惯。每次写完一段 CompletableFuture 代码我都会盯着三个地方看一遍依赖关系是不是真的被表达清楚了每个远程调用的超时和兜底在不在所有异步任务是不是都进了同一个命名线程池这三个问题答不上来的代码我基本不敢上生产。CompletableFuture 的技术门槛真的不高真正让代码稳的是这些看起来琐碎但每次都踩得到的细节。