CompletableFuture核心API实战:Java异步编程与接口聚合优化
从一次聚合查询说起。做Java后端的同学应该都写过类似的接口用户查商品详情你得先查商品基础信息然后查库存、查优惠券、查评价如果串行来一次请求的耗时就是四次远程调用耗时相加。早期我用ExecutorService Future试过并行代码虽然能跑但一涉及异常处理、结果合并、多任务编排就非常痛苦。后来项目全面切到CompletableFuture这些问题才真正被解决。这篇文章我会把CompletableFuture的创建、回调、组合、异常处理这四类核心API逐一拆开讲再给一个完整的聚合接口改造案例最后把线上环境踩过的坑和面试经常问的点都整理出来。内容以Java 8为基线JDK 9新增的超时方法也会单独说明。适合刚接触异步编程的Java新人也适合在微服务聚合场景里想优化细节的进阶读者。1. 从Future到CompletableFutureJava异步编程的演进1.1 Future到底哪里不好用先回忆一下老的Future。Java 5引入的java.util.concurrent.Future第一次把“异步计算结果容器”这个概念带给了Java开发者。提交任务到线程池返回一个Future你调用future.get()拿结果。但实际用起来问题很现实。第一个问题是阻塞。future.get()是无条件阻塞的如果你在业务线程里调用它其实和同步调用没太大区别。主线程要么死等结果要么轮询isDone()前者浪费线程后者浪费CPU。第二个问题是没有任务编排能力。先查用户信息再用用户ID查订单最后用订单ID查物流这种带依赖关系的调用链用Future写要么嵌套很深要么就得手动用线程池的submit拆成多个阶段代码可读性很差。第三个问题是异常处理很烂。任务内部抛了异常get()的时候抛出一个ExecutionException你还得自己解包才能拿到真正的异常信息。当年在项目里为了解决这几个问题我试过Guava的ListenableFuture也写过不少CompletableFuture早期版本的替代方案代码越来越长维护成本依然很高。核心原因是Future只解决了“并发执行”的问题没有解决“异步编排”的问题。而业务里大量场景不是简单并发而是“并发 依赖组合 异常兜底”。从Java 8开始CompletableFuture补上了这一环。1.2 CompletableFuture的设计定位CompletableFuture实现了两个接口Future和CompletionStage。前者意味着它仍然可以被当作Future使用兼容旧代码里的get()、cancel()后者才是真正值钱的部分它定义了一整套异步流水线的编排能力。用流水线来类比说明上游的工件是上一步的结果经过一个工位加工后变成下一步的输入中间任何环节出问题可以走旁路两个互不相关的流水线产品可以在最后组装。CompletableFuture主要做了三件事第一提供runAsync/supplyAsync这样的静态工厂方法把计算任务丢进线程池异步执行第二提供一批阶段编排方法thenApply、thenCompose、thenCombine等组合异步结果第三提供exceptionally、handle这些异常恢复机制让链路中某个环节挂了还能返回降级结果而不是整个链路全部崩掉。我在实际使用中的感受是CompletableFuture最大的价值是把异步编程从“回调地狱”里解放出来用接近同步代码的流畅度去描述异步逻辑。你把thenApply链起来写读代码的人基本不需要在脑子里理回调嵌套了。2. 核心API拆解从创建到编排2.1 创建任务runAsync与supplyAsync创建异步任务的两个静态方法是使用入口// 无返回值任务 CompletableFutureVoid f1 CompletableFuture.runAsync(() - { System.out.println(执行异步任务线程 Thread.currentThread().getName()); }); // 有返回值任务 CompletableFutureString f2 CompletableFuture.supplyAsync(() - { return hello; });runAsync接收Runnable执行一个没有返回值的任务返回CompletableFuture supplyAsync接收Supplier执行一个带返回值的任务返回CompletableFuture。常规业务里大部分都是supplyAsync场景因为异步做完通常要把结果带回主流程。这里必须强调一个重载参数问题两个方法都有重载版本第二个参数可以传自定义Executor。如果不传默认使用ForkJoinPool.commonPool()。这个池是JVM内全局共享的默认并行度是CPU核数减一而且它会被parallelStream、其他无参CompletableFuture共同使用。如果你的任务里有RPC调用、数据库查询、外部HTTP这类阻塞IO强烈建议自定义线程池。否则一个慢接口就能把commonPool的线程占满导致JVM里其他所有依赖commonPool的异步任务全部排队甚至互相拖垮。2.2 链式回调thenApply、thenAccept、thenRun获取结果后有三个基础的回调处理接口要掌握。thenApply(Function)拿到上一步的结果转换成另一个结果继续往下传递就像Stream里的map。thenAccept(Consumer)拿到上一步的结果消费它但不产生新结果往下传适合做保存、通知这类操作。thenRun(Runnable)完全不关心上一步的结果只关心“上一步已经执行完了”这个事实适合做结束后的收尾动作。下面这段代码演示了一个完整的流水线CompletableFuture.supplyAsync(() - userId-1001) .thenApply(userId - getUserByUserId(userId)) .thenApply(user - getOrdersByUser(user)) .thenAccept(orders - saveReport(orders)) .thenRun(() - log.info(报告保存完毕));这段代码里supplyAsync拿到userIdthenApply换成User对象再换成Order列表thenAccept去保存报告最后thenRun打印日志。每个方法都聚焦一个动作整个链路读下来非常清晰。这里要单独说一下thenApply和thenApplyAsync的区别这也是面试和线上排查最容易出问题的地方。非Async版本如果上游任务已经完成回调会在当前调用线程同步执行如果上游任务还没有完成回调会注册到上游任务等上游任务完成的那个线程来执行。也就是说非Async版本的回调执行线程是不可预测的可能是某个业务线程也可能是commonPool里的线程。Async版本则严格把后续任务重新提交到指定的线程池执行。所以如果你想精确控制“哪段逻辑跑在哪个线程池”就必须使用Async版本并显式传入Executor。我早期踩过一个坑整个系统大量代码用thenApply而不加Async所有回调都在某个业务的线程里执行这个线程可能还是Netty的IO线程事件循环被回调逻辑堵住导致同一个连接上的其他请求全部排队。后来统一改为thenApplyAsync 自定义业务线程池线程竞争问题才明显缓解。2.3 结果组合thenCombine、thenCompose、applyToEitherthenApply解决的是单条流水线里的串行依赖但业务里经常需要处理任务之间的并联和竞速这一节说三个核心组合方法。thenCombine适合两个相互独立的任务等它们都完成后把两个结果一起交给BiFunction处理。场景非常典型商品详情页面要同时展示基础信息和库存两边互不依赖可以并行请求最后合并成一个VO。CompletableFutureGoods goodsFuture CompletableFuture.supplyAsync(() - getBaseInfo(goodsId), pool); CompletableFutureStock stockFuture CompletableFuture.supplyAsync(() - getStock(goodsId), pool); goodsFuture.thenCombine(stockFuture, (goods, stock) - { goods.setStock(stock.getAvailableCount()); return goods; });thenCompose解决的是依赖型任务的嵌套问题。当前一个异步任务的结果要作为下一个异步任务的输入时如果Function里返回的是CompletableFuture你可能会写出这样的代码future.thenApply(x - CompletableFuture.supplyAsync(...))结果类型会变成CompletableFutureCompletableFuture 需要手动拆一层。使用thenCompose可以直接扁平化成CompletableFuture 。CompletableFutureUser userFuture CompletableFuture.supplyAsync(() - getByUserId(userId), pool); CompletableFutureOrder orderFuture userFuture.thenCompose(user - CompletableFuture.supplyAsync(() - queryOrderByUserId(user.getId()), pool));applyToEither适合竞速场景。比如多机房部署了同样的服务你希望拿先返回的那份数据。第一个任务和第二个任务谁先完成就用谁的结果继续执行。CompletableFutureString aFuture CompletableFuture.supplyAsync(() - request(GroupA), pool); CompletableFutureString bFuture CompletableFuture.supplyAsync(() - request(GroupB), pool); CompletableFutureString fastest aFuture.applyToEither(bFuture, result - result);2.4 异常处理与兜底exceptionally、handle、whenComplete异步链路上只要有一步抛出异常后续的thenApply和thenAccept默认不会继续执行异常会沿着future内部传递。这时候一般用三个方法来兜底。exceptionally(Function)只在异常时触发参数是Throwable需要返回一个与上游类型一致的兜底结果。它相当于“异常了就换一种结果继续”。handle(BiFunction)正常和异常都会执行参数是T result和Throwable ex方法体内判断ex是否为null决定是处理结果还是处理异常返回类型可以自己定义。whenComplete(BiConsumer)和handle类似也是无论成败都会执行但它的返回值无法改变链路结果适合做日志、监控、指标埋点。CompletableFutureInteger ageFuture CompletableFuture.supplyAsync(() - getUserAge(userId), pool) .exceptionally(ex - { log.error(查询用户年龄失败返回默认值0, ex); return 0; }) .whenComplete((age, ex) - { if (ex ! null) { log.warn(链路出现异常age为默认值0); } });这里有一个容易踩的类型坑exceptionally的返回类型必须和上游Future的泛型一致。如果链路上某一步泛型从User变成了Order那里的异常处理返回值就必须是Order写错了编译都过不去。另外如果多个子任务相互独立我建议在每个supplyAsync后立刻单独用exceptionally兜底这样某个子任务失败不会影响其他任务的结果聚合。下面实战章节会展示这种写法。3. 实战改造商品详情聚合接口3.1 业务背景与串行基线假设一个商品详情接口需要返回四个部分商品基础信息、库存、优惠券、评论。四个数据源分属不同的服务或不同的表结构。传统串行实现长这样public ProductDetailVO getProductDetail(Long goodsId) { long start System.currentTimeMillis(); Goods goods goodsService.getBaseInfo(goodsId); Stock stock stockService.getStock(goodsId); Coupon coupon couponService.getCoupon(goodsId); ListComment comments commentService.getComments(goodsId); return buildVO(goods, stock, coupon, comments); }如果每个服务平均耗时100ms串行总耗时就是400ms。用户侧表现是一个详情页要等将近半秒更糟糕的是如果其中有一个服务慢到500ms整体耗时就是500ms以上。我第一次做这种聚合时用的是Future 线程池代码长这样ExecutorService pool Executors.newFixedThreadPool(4); FutureGoods goodsFuture pool.submit(() - goodsService.getBaseInfo(goodsId)); FutureStock stockFuture pool.submit(() - stockService.getStock(goodsId)); FutureCoupon couponFuture pool.submit(() - couponService.getCoupon(goodsId)); FutureListComment commentFuture pool.submit(() - commentService.getComments(goodsId)); Goods goods goodsFuture.get(); Stock stock stockFuture.get(); Coupon coupon couponFuture.get(); ListComment comments commentFuture.get();这段代码虽然把串行变成了并行但问题依然很明显。第一四个get是逐个阻塞的如果第一个get被卡住后面的结果即使早回来了也要等前面的future.get()执行完。第二任意一个Future get抛出异常整个接口直接失败没有任何降级策略。第三代码是命令式的没有体现任务之间的编排关系。3.2 用CompletableFuture做并行与编排把上面逻辑改造成CompletableFuture版本。核心思路是把四个独立任务并行提交每个任务各自做超时控制和异常兜底最后统一聚合。线程池先定义好private static final ThreadPoolExecutor ASYNC_POOL new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(1000), new ThreadFactory() { private final AtomicInteger count new AtomicInteger(1); Override public Thread newThread(Runnable r) { return new Thread(r, product-detail-pool- count.getAndIncrement()); } }, new ThreadPoolExecutor.CallerRunsPolicy() );线程数不建议拍脑袋定。四个子任务都是IO密集型IO等待时线程是阻塞状态不占CPU所以核心线程数可以适当多设。我一般初始化8核心最大16队列1000拒绝策略用CallerRunsPolicy。这个策略特别重要线程池满时任务不会静默丢失而是回退到调用线程执行代价是接口线程可能阻塞一下但至少不会丢弃请求。然后看接口实现public ProductDetailVO getProductDetail(Long goodsId) { long start System.currentTimeMillis(); CompletableFutureGoods goodsFuture CompletableFuture .supplyAsync(() - goodsService.getBaseInfo(goodsId), ASYNC_POOL) .orTimeout(500, TimeUnit.MILLISECONDS) .exceptionally(ex - { log.error(获取商品基础信息失败, ex); return new Goods(goodsId); }); CompletableFutureStock stockFuture CompletableFuture .supplyAsync(() - stockService.getStock(goodsId), ASYNC_POOL) .orTimeout(300, TimeUnit.MILLISECONDS) .exceptionally(ex - { log.error(获取库存失败, ex); return new Stock(0); }); CompletableFutureCoupon couponFuture CompletableFuture .supplyAsync(() - couponService.getCoupon(goodsId), ASYNC_POOL) .orTimeout(500, TimeUnit.MILLISECONDS) .exceptionally(ex - { log.error(获取优惠券失败, ex); return new Coupon(); }); CompletableFutureListComment commentFuture CompletableFuture .supplyAsync(() - commentService.getComments(goodsId), ASYNC_POOL) .orTimeout(800, TimeUnit.MILLISECONDS) .exceptionally(ex - { log.error(获取评论失败, ex); return Collections.emptyList(); }); CompletableFuture.allOf(goodsFuture, stockFuture, couponFuture, commentFuture).join(); ProductDetailVO vo buildVO(goodsFuture.join(), stockFuture.join(), couponFuture.join(), commentFuture.join()); log.info(商品详情聚合耗时{}ms, System.currentTimeMillis() - start); return vo; }allOf返回的CompletableFuture 会在所有子任务都完成时结束join()阻塞等待它完成。这里有个关键设计每个子任务都加了exceptionally兜底所以即使某个服务调用失败对应future也一定会返回一个默认对象后面调用子future.join()再也不会抛ExecutionException。整体接口永远能组装出一个完整VO只是某些字段可能是默认值。这种“部分降级”的体验比整页报错好得多。改造后效果也比较直观四个下游接口并行接口总耗时从原来的400ms下降到约100ms到150ms级别最慢的子任务决定最终耗时而不是所有耗时相加。3.3 orTimeout与completeOnTimeout的选择JDK 9提供了orTimeout和completeOnTimeout两个实用方法都用于超时控制但行为完全不同。orTimeout在超时后会让future进入异常完成状态抛TimeoutException需要配合exceptionally或者handle做兜底。completeOnTimeout则在超时后直接让future以默认值正常完成不走异常分支。我倾向于用orTimeout exceptionally因为这样可以在日志里明确记录“哪个业务超时了”超时和真正业务失败是两种不同的异常类型排查时能区分。completeOnTimeout虽然少写一个异常处理但超时和其他异常混在一起定位问题会少一条线索。注意这两个方法从JDK 9才有。如果项目还停留在JDK 8就得用future.get(timeout, TimeUnit)的变体或者做一个工具方法统一对子任务做超时包裹。我在线上做聚合时会把最大总耗时控制在固定范围每个子任务单独设超时时间再留一点余量给组装逻辑避免出现“子任务都完成了但组装时因为等待某个慢任务导致整体超时”的情况。4. 使用中常踩的坑与排查实录4.1 异常被静默吞掉CompletableFuture的异常不像同步代码那样直接抛出。默认情况下链路上游的异常会沿whenComplete、exceptionally等处理器往下传递如果没有任何处理器异常就存到future内部。除非你显式调用get()或者join()否则异常完全不可见。我在一个数据同步项目里遇到过这个问题线程池用supplyAsync提交了任务任务内部调第三方接口失败代码里没加exceptionally也没对返回的future调用get()结果线上日志一点异常都没有数据却一直不更新。排查了很长时间最后才发现异常被静默吞掉了。解决办法是统一收口。对每个CompletableFuture返回的结果要么在链路末端加whenComplete记录日志要么在提交任务后立刻加exceptionally兜底。我更推荐后者因为可以为每个子任务单独设计降级策略保证单点失败不扩散。4.2 get()和join()的异常类型差异get()声明抛出ExecutionException和InterruptedException调用方需要处理受检异常。join()不声明受检异常异常时抛出CompletionException而且CompletionException包裹的是原始异常。如果捕获到CompletionException要再调用getCause()才能找到真正的根因。这个差异很容易在排查时绕弯子看到stacktrace里是CompletionException以为是join方法本身的问题实际根因在cause里。还有一个细节future.get()如果被中断会收到InterruptedException如果因为超时被取消会拿到TimeoutException。想判断任务失败还是超时要分别捕获。用orTimeout exceptionally时exceptionally里的ex就是TimeoutException可以精确判断超时分支。4.3 非Async方法执行线程不可预测我一直想单独强调一下thenApply和thenApplyAsync的线程调度区别因为这个问题网上很多文章说得不够准确。非Async方法如果上游任务已经完成回调会在当前调用线程同步执行如果上游任务还没有完成回调会注册到上游任务等上游任务完成的那个线程来执行。也就是说这里的“当前调用线程”完全不可控。Async版本则不管上游任务在哪完成都会把新任务重新提交到默认的ForkJoinPool.commonPool中执行除非显式指定线程池。这个细节的后果是如果你在某个核心业务线程里调用future.thenApply(...)而上游任务刚好完成了回调就在那个核心线程里执行。如果回调逻辑里有数据库查询或者RPC调用这个核心线程就被阻塞了。我在Netty服务里遇到过类似问题一个IO线程被一个查询逻辑占住整个连接的其他请求都受影响。所以涉及阻塞IO的回调统一用Async版本并传入自定义线程池这是最稳妥的做法。4.4 allOf、anyOf这两个“收口”方法的边界allOf用于等所有任务完成但返回的CompletableFuture 里不放结果。想要拿到各个子任务的结果必须自己保存子future引用然后单独join取。anyOf是等其中最快的一个任务完成返回CompletableFuture还有一点如果子任务数量特别多allOf内部用的是数组加ForEach方式逐一把子任务添加到CompletionStage依赖上数量大时内存占用会增加。几十个以内没问题如果一次要聚合几百个子任务建议拆批处理避免一次性挂太多回调在同一个future上。4.5 cancel()不会真正取消底层线程很多人以为future.cancel()之后对应的任务线程会被中断。实际上cancel()只是修改了future对象的状态底层线程如果正在执行普通计算或阻塞IO并不会因为cancel被唤醒。线程和任务本身还在继续运行只是结果没人要而已甚至可能占用线程池里的线程资源。需要真正的任务中断得在任务内部主动判断Thread.currentThread().isInterrupted()这就必须让任务代码本身支持中断。在自己的项目里我不依赖cancel做资源回收而是靠超时控制加线程池参数来保护系统资源。线程池队列长度、拒绝策略、核心线程数这些都要在设计阶段想清楚。5. 面试考点与工程落地经验5.1 面试官常问的CompletableFuture问题搜索热词里Java面试相关的内容很多这里把CompletableFuture高频考点列表整理出来都是我梳理过的常见方向面试问题核心考察点建议回答思路Future和CompletableFuture的区别对异步演进的理解Future只能阻塞获取结果CompletableFuture支持编排与回调thenApply和thenCompose的区别对嵌套异步任务的处理thenApply返回Future需要手动拆箱thenCompose自动扁平化exceptionally、handle、whenComplete的区别对异常分支的理解一个只处理异常一个正常异常都处理且能改结果一个正常异常都处理但只消费allOf和anyOf的适用场景对多任务收口的理解allOf等所有完成anyOf等最快完成结果取值方式不同默认线程池是什么为什么不推荐对线程资源管理的理解默认commonPool并行度是CPU核数减一阻塞IO会占满线程用了CompletableFuture之后接口变慢怎么排查对执行线程和超时机制的掌握检查是否用了Async版本是否自定义线程池子任务是否有超时回答时建议多从“为什么”的角度展开。比如default pool的问题如果你能说清楚commonPool的并行度计算公式还有阻塞IO任务会占满线程导致同JVM其他异步任务排队这个回答就会比背概念更有区分度。5.2 工程落地的几个建议第一封装线程池。把线程池定义成静态常量加上自定义线程名比如product-detail-pool-1。线上看线程dump一眼就能定位是哪块业务在跑不然一堆pool-1-thread-1很难排查。第二所有异步任务必须加超时。CompletableFuture本身不要求超时如果一个下游调用一直不返回用到对应future的线程就会被一直占住。JDK 9用orTimeout配合exceptionallyJDK 8用get(timeout)或者在链条末端做超时处理。第三给异步任务加耗时监控。我自己的做法是给每个子任务包一层记录完成时间、耗时、是否异常、异常类型输出到日志和监控系统。接口变慢的时候这些数据能快速帮你定位是哪一个下游服务的问题。第四同一个CompletableFuture结果可以被多个后续任务重复使用。保存future对象然后多次joinjoin是幂等的重复构造future会导致重复执行相同的远程调用。5.3 什么情况下不要用CompletableFuture也不是所有场景都适合用它。如果任务是CPU密集型的纯计算直接用ForkJoinPool或parallel stream更合适没必要再包一层Future。如果调用链非常简单两个串行步骤同步代码本来就够易读。只有当任务之间有依赖或组合关系、涉及远程IO、需要并行和降级的时候CompletableFuture才真正值得使用。还有一点要注意对外接口里尽量不要直接暴露CompletableFuture给调用方。我一般会在service层内部消化所有异步异常返回给上层的是已经降级好的结果。这样上层永远拿到的是正常数据不需要每个调用方都写异常处理逻辑接口契约也更清晰。最后分享一个我个人的体会。CompletableFuture好用但真正要用好它依赖的是对线程池、超时、异常处理这三个基础问题的控制力。它本身只是一个工具箱具体方案还是要结合实际业务去设计。建议新手先跑通本章的聚合接口改造把thenApply、thenCombine、exceptionally这几个方法在真实项目里用一遍再逐步深入源码。用到后面你会发现理清“谁在哪个线程执行”这件事比记住API签名重要得多。