上周帮同事排查一个线上接口慢的问题,业务逻辑很典型:查用户信息、拉订单列表、再算优惠券,三个步骤相互独立,结果代码是老老实实串着写的。一个查询等完再发下一个,接口耗时直接等于三次网络调用之和。改成并行之后,总耗时基本和最慢的那一次持平。这个改造里用到的关键工具,就是标题里这个CompletableFuture,JDK 8就原生提供,不需要引入任何额外依赖。
这篇文章我就围绕CompletableFuture从入门到上手写一遍。从runAsync这种最基础的异步任务创建方式开始,讲到thenApply、thenCombine、allOf这些任务编排手段,最后聊一聊实战里最常见的坑和应对方案。内容适合刚接触异步编程的Java开发,也适合准备面试时需要把这块知识系统梳理一遍的朋友。代码示例都是可以直接跑起来验证的,跟着过一遍基本就能在项目里用起来。
1. 先说清楚:CompletableFuture到底解决了什么问题
CompletableFuture不是凭空冒出来的工具,它解决的是老一套异步编程模型里最让人头疼的几个问题。要搞清楚为什么需要它,得先看看以前用Future写异步代码是什么体验。
1.1 从“线程池+Future”说起
Java 5引入的Future接口,是很多人接触异步编程的第一站。用法很直观:把任务丢给线程池执行,拿到一个Future对象,后面调用future.get()阻塞等待结果。听起来没问题,但实际写起业务来,这个模型很快会露怯。
第一个痛点是获取结果的方式太死板。get()是同步阻塞的,调用它的时候线程只能干等。虽然有isDone()可以轮询,但轮询本身就是一种浪费。如果任务B依赖任务A的结果,你还得先get()到A的结果再提交B,代码写出来像套娃,一层套一层。
第二个痛点是异常处理很麻烦。任务在线程池里抛出异常时,异常并不会直接冒出来,而是被捕获并封装在Future内部。你必须调用get()才会通过ExecutionException看到它。如果中途忘了处理,异常可能白白消失,排错的时候让人一头雾水。
第三个痛点是任务组合能力几乎为零。真实业务里根本没有那么多“单打独斗”的异步任务,更多是“A做完再做B”“A和B同时做凑结果”“做C的时候如果D先完成了就听D的”这类复杂的协作关系。这些逻辑用Future硬写,代码会变成一堆循环加状态判断,维护成本极高。
1.2 CompletableFuture带来的三个变化
CompletableFuture从根本上改变了这套玩法。它不只是“增强版Future”,而是一种更贴近业务表达的方式。
第一,它支持回调式编程,不再需要主动阻塞等待结果。你可以告诉它“结果出来了之后接下来干什么”,任务完成时框架自动触发后续动作。以前的“拉取”模型变成了“监听”模型,代码读起来更像是在描述业务本身,而不是纠结线程细节。
第二,它天生支持任务编排。串行执行、并行合并、只取最快结果、等全部完成,这些场景都有现成方法对应。编排出来的链路可读性非常好,看起来像一条流水线,每一步做什么清清楚楚。
第三,它拥有统一的异常处理通道。异常会沿着任务链传播,你可以选择在链路上任意位置拦截处理,也可以在最末端统一兜底。处理方式非常灵活,不再像Future那样必须原地try-catch。
所以CompletableFuture解决的并不只是“快不快”的问题,更重要的是它让异步代码从“能跑”升级到了“可维护”。这才是它成为Java并发面试必问题目的真正原因,也是它值得花时间吃透的价值所在。
2. 入门第一步:从runAsync正确创建异步任务
CompletableFuture提供了两种最基础的异步任务创建方式,runAsync和supplyAsync。这俩就是整个工具库的入口,先掌握它们才能往下走。
2.1 runAsync和supplyAsync:有返回值和无回值
看名字就能猜出区别。runAsync接收Runnable,任务没有返回值,适合“执行个操作但不需要拿结果”的场景。supplyAsync接收Supplier,任务执行完会返回一个结果。
// runAsync:没有返回值 CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { System.out.println("执行任务,线程:" + Thread.currentThread().getName()); }); // supplyAsync:有返回值 CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> { System.out.println("开始计算,线程:" + Thread.currentThread().getName()); return "计算结果"; });这里有个细节值得说一下。Runnable和Supplier本质上对应了“命令式”和“函数式”两种任务语义。如果你的任务产出一个后续要用到的值,用supplyAsync;如果只是为了“做掉一件事”,比如打个日志、清个缓存、发个通知,用runAsync就够了。
两者的返回值类型也不一样:runAsync返回CompletableFuture<Void>,这个Void代表的是“没有值”,而不是“结果是null”。如果你拿到的是CompletableFuture<Void>,后续调用get()返回的是一个null,但这不代表任务失败了,只是正常完成且没有结果而已。
2.2 线程池选型:不要直接依赖默认的ForkJoinPool
使用runAsync或supplyAsync时,有一个容易被忽略的重载版本:可以传入自定义的Executor线程池。不传的时候,框架会使用ForkJoinPool.commonPool()这个公共线程池来执行。
这个默认池子有一个必须知道的隐患。ForkJoinPool.commonPool的并行度默认为CPU核心数减一,而且它是整个JVM实例共享的。如果系统里所有使用CompletableFuture的代码都不传线程池,大家会挤在同一批线程上干活。更危险的是,如果某个任务里发生了阻塞,比如调了get()、睡了觉、等了锁,这个阻塞会直接占用公共池的线程,拖累其他完全不相干的任务。
还有一个环境相关的问题。在ForkJoinPool这个池的线程里,任务无法触发ForkJoinTask的工作窃取机制,同时也不建议在这个池子里执行任何阻塞型操作。我在实际项目里见过因为调用了Thread.sleep()导致整个公共池线程全部被占满的情况,接口大面积超时,排查了很久才发现根因。
正确的做法是指定一个可控的线程池。线程数根据任务类型来定:IO密集型任务可以多配一些线程,一般取CPU核数 * 2甚至更高;CPU密集型任务线程数建议控制在CPU核数 + 1左右。
ExecutorService executor = new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲线程存活时间 new LinkedBlockingQueue<>(1000), // 等待队列 new ThreadFactoryBuilder().setNameFormat("async-task-%d").build() ); CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> { return "异步结果"; }, executor);这里用到的ThreadFactoryBuilder来自Guava,如果你不想引入Guava,用ThreadFactory的匿名内部类也可以,核心目的是给线程起一个有意义的名字。这样以后线上出问题查线程栈时,一眼就能看出是什么业务提交的任务,而不是看到一堆ForkJoinPool.commonPool-worker-1在那里干瞪眼。
2.3 代码示例:并行查询用户信息与订单列表
用一个贴近业务的例子来巩固一下。假设有一个接口需要返回用户的个人信息和最近的订单列表,两个数据来自不同的服务,可以并行查询。
public UserDetailVO getUserDetail(Long userId) { ExecutorService executor = ThreadPoolConfig.getAsyncExecutor(); CompletableFuture<UserInfo> userInfoFuture = CompletableFuture.supplyAsync(() -> { return userService.getUserInfo(userId); }, executor); CompletableFuture<List<OrderVO>> orderListFuture = CompletableFuture.supplyAsync(() -> { return orderService.getRecentOrders(userId); }, executor); UserInfo userInfo = userInfoFuture.get(); List<OrderVO> orderList = orderListFuture.get(); return new UserDetailVO(userInfo, orderList); }这段代码的关键点是,两个supplyAsync调用连续发起,它们会分别提交到线程池执行,互不等待。最后调用get()时,总耗时取决于两个查询中较慢的那个,而不是两个查询耗时之和。这个例子就是CompletableFuture最基础也最常用的一个场景:并行发起多个独立请求,再汇总结果。
需要注意的是这里的get()会抛出受检异常,实际代码建议用join()替代,join()不会抛出受检异常,遇到异常时会包装为CompletionException抛出,处理起来更自然。后面小节我会专门讲异常处理。
3. 任务编排进阶:把异步任务串成一条流水线
如果只会用supplyAsync开异步任务然后get,那CompletableFuture的威力只发挥了一小半。它真正的核心能力在于编排,也就是把多个异步任务按照业务规则组装成一条任务链。这一节是全文的重点,也是面试题里最常出“八股文”的地方,但我会结合实际场景讲清楚每个方法解决什么具体问题。
3.1 串行回调:thenApply与thenCompose的区分
先说串行场景。A任务完成后,拿A的结果来算B,这是一条最简单的单向依赖链路。CompletableFuture提供了thenApply、thenAccept、thenRun三个方法,看起来都很像,实际语义却完全不同。
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 100) .thenApply(result -> result * 2) .thenApply(result -> result + 1); // 最终结果:201thenApply接收一个Function,拿到上一步的结果后可以计算出一个新结果返回,适用于“结果需要继续传递下去”的场景。它是整个链路中最常用的转换方法。需要注意,这三个方法默认在上一步任务完成的线程上继续执行,也就是说回调不一定会跑到自定义线程池里去。如果你希望回调也走独立线程池,可以使用带Async后缀的版本,比如thenApplyAsync。
thenAccept接收的是Consumer,消费上一步的结果但不产生新结果,返回的是CompletableFuture<Void>。它一般用在链路的末端,比如“拿到数据后打印一下”。thenRun更特殊,它连上一步的结果都不关心,只是“上一个干完了,接着干我自己的”,适用于链路末尾的清理或通知动作。
真正容易搞混的是thenApply和thenCompose。假设你的业务回调本身返回的就是一个CompletableFuture,直接使用thenApply会得到一个嵌套的结果,CompletableFuture<CompletableFuture<String>>。取结果时要先get外层再get内层,非常麻烦。thenCompose就是专门解决这个嵌套问题的,它会把内层的CompletableFuture“摊平”到外层,最终得到CompletableFuture<String>。
// 场景:先拿用户ID,再用ID异步查询订单 CompletableFuture.supplyAsync(() -> 1001) .thenCompose(userId -> orderService.getOrdersByUser(userId)) .thenAccept(orders -> System.out.println("订单:" + orders));这里orderService.getOrdersByUser(userId)返回的是一个CompletableFuture<List<Order>>。用thenCompose接住它,链路就能继续顺畅往下传递,不会被嵌套卡住。可以类比Stream里的flatMap,作用如出一辙,都是为了压扁嵌套结构。
3.2 并行合并:thenCombine与allOf的真实应用
串行编排解决的是依赖问题,但业务里还有一种常见情况:两个任务互不依赖,但最终结果要一起用。这时候可以用thenCombine,它接收两个CompletableFuture,两个都完成后,把各自的结果合并起来交给BiFunction处理。
CompletableFuture<Integer> priceFuture = CompletableFuture.supplyAsync(() -> 500); CompletableFuture<Integer> discountFuture = CompletableFuture.supplyAsync(() -> 80); CompletableFuture<Integer> result = priceFuture .thenCombine(discountFuture, (price, discount) -> price - discount); // 最终结果:420如果用老写法,需要先分别get()两个结果再手动算,代码会多出几行状态管理。用thenCombine写出来,合并逻辑直接定义在方法里,语义一目了然。
thenCombine适合“二合一”的场景。如果你有一批任务需要全部完成后再汇总,就要用allOf。它接收一个CompletableFuture<?>...数组,返回一个新的CompletableFuture<Void>,表示“等所有任务都完成了,这个future才算完成”。
List<Long> userIds = Arrays.asList(1001L, 1002L, 1003L, 1004L); List<CompletableFuture<UserInfo>> futures = userIds.stream() .map(userId -> CompletableFuture.supplyAsync(() -> userService.getUserInfo(userId), executor)) .collect(Collectors.toList()); CompletableFuture<Void> allDone = CompletableFuture.allOf( futures.toArray(new CompletableFuture[0]) ); // allOf返回的future本身不带结果,需要手动从原future里拿 List<UserInfo> users = allDone.thenApply(v -> futures.stream() .map(CompletableFuture::join) .collect(Collectors.toList()) ).join();有个细节官方文档里没强调,但实际用起来一定要记住:allOf返回的CompletableFuture<Void>本身不带任务结果,所有结果仍然保存在传进去的那批future里。所以等待全部完成之后,你还得遍历原future列表逐个join()取值。上面这段代码在Java 8里是常见写法,到Java 16以后可以用toList()简化收集过程,但思路不变。
与allOf相对的还有anyOf。它同样接收一批future,但只要有任意一个先完成,返回的future就会被触发,结果是那个最先完成的任务的值。这个特性很适合做“故障转移”或“竞速”场景。比如配置了多个数据源,可用性无法保证,可以同时查询两个数据源,谁先返回就听谁的。代码写起来非常直观。
3.3 一个完整的订单聚合编排示例
把前面的方法组合到一起,模拟一个稍微复杂点的业务。假设一个订单详情页需要展示:用户信息、订单基本信息、订单关联的商品快照和最新物流状态。其中用户信息和商品快照可以并行获取,物流状态只能依赖订单号,且耗时较长。
public OrderDetailVO getOrderDetail(String orderId) { CompletableFuture<OrderInfo> orderInfoFuture = CompletableFuture.supplyAsync(() -> orderService.getOrderInfo(orderId), executor); CompletableFuture<UserInfo> userInfoFuture = orderInfoFuture.thenCompose(order -> CompletableFuture.supplyAsync(() -> userService.getUserInfo(order.getUserId()), executor)); CompletableFuture<List<GoodsSnapshotVO>> goodsFuture = orderInfoFuture.thenApplyAsync(order -> goodsService.getGoodsSnapshotList(order.getGoodsIds()), executor); CompletableFuture<LogisticsVO> logisticsFuture = orderInfoFuture.thenComposeAsync(order -> logisticsService.getLatestLogistics(order.getLogisticsNo()), executor); OrderInfo order = orderInfoFuture.join(); UserInfo user = userInfoFuture.join(); List<GoodsSnapshotVO> goodsList = goodsFuture.join(); LogisticsVO logistics = logisticsFuture.join(); return OrderDetailVO.builder() .order(order) .user(user) .goodsList(goodsList) .logistics(logistics) .build(); }这段代码的巧妙之处在于,userInfoFuture、goodsFuture、logisticsFuture都依赖orderInfoFuture的结果,但三者之间互不依赖,会自动并行执行。也就是说,整个接口的总耗时为“获取订单信息耗时 + 三个依赖子任务中最长的耗时”。这种利用依赖关系实现的并行,比把所有任务平铺开手工管理状态要优雅得多。
如果手头项目用的是Spring Boot,还可以考虑把thenApplyAsync配合@Async自定义线程池使用,不过那属于框架层面的整合,开箱即用的CompletableFuture已经足够覆盖绝大多数场景。
4. 异常处理与兜底策略:异步编程最容易翻车的地方
异步编程的异常处理和同步代码完全不同。同步代码里try-catch包一下,异常就拦住了。异步任务里异常发生在别的线程,不会主动抛给主线程,如果不做处理,异常会在某个隐秘的角落被吞掉,直到线上出了诡异问题你再回来查,欲哭无泪。
4.1 三种异常处理方法如何选
CompletableFuture提供了exceptionally、handle、whenComplete三个异常处理方法,名字长得像,行为却各不一样。先从exceptionally说起。
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> { if (true) { throw new RuntimeException("业务异常"); } return 100; }) .exceptionally(ex -> { System.out.println("捕获异常:" + ex.getMessage()); return 0; // 返回兜底值 });exceptionally只在链路中发生异常时执行,入参是异常对象,返回值会替代原本应该有的结果。它适合“出错了给一个默认值”的兜底场景。需要注意的是,如果exceptionally返回的兜底值类型必须和正常结果类型一致,否则编译不通过。
handle和exceptionally最大的区别是,它不管成功失败都会执行。入参有两个,第一个是正常结果,第二个是异常对象。两个参数里必然有一个是null,你可以在方法里根据哪个不为null来判断是哪种情况。
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 100) .handle((result, ex) -> { if (ex != null) { System.out.println("异常:" + ex.getMessage()); return -1; } return result * 10; });这个方法的优点是整合了“成功路径”和“失败路径”,避免写两条独立分支。缺点也很明显,如果你需要在成功时和失败时做完全不同的处理且逻辑都比较复杂,handle的方法体会变得很大,可读性会下降。
whenComplete则更像是一个“通知回调”。它执行时拿到结果和异常,但不允许改变结果。也就是说,它适合做日志记录、指标上报、资源清理这类“旁路”操作,告诉你任务结束了,仅此而已。在whenComplete里尝试修改返回值,会发现根本改不掉,它在设计上就不允许干预结果。
三个方法的使用原则我总结如下:只想处理异常并返回兜底值,用exceptionally;成功和失败都要处理但不需要改结果,用whenComplete做旁路;成功失败都要处理且需要统一走一个出口,用handle。带Async后缀的版本可以指定线程池,避免回调执行在线程池线程上影响并发度。
4.2 异常传播与吞异常陷阱
使用CompletableFuture时最隐蔽的问题,是异常在链路中的传播方式。默认情况下,如果链路中间的某个环节抛出了异常,且没有在任何位置调用exceptionally或handle兜底,那么整个链路会以“异常完成”状态结束。此时调用join()会抛出CompletionException,调用get()会抛出ExecutionException。但如果你从头到尾都没调用这俩方法,异常就会被存储在这个future内部,不再向外传播。
这个特性在真实的业务场景里意味着什么?意味着你的异步任务可能已经挂了,但代码没有报错,日志也没有异常堆栈。等到排查问题时,你才发现这个future在某个时间点已经“静默失败”了。所以我强烈建议:在任务链的最末端,一定要加一个兜底处理,把异常打出来,或者用whenComplete记录异常,避免错误被白白吞掉。
CompletableFuture.supplyAsync(() -> { // 业务逻辑 return "result"; }, executor) .whenComplete((result, ex) -> { if (ex != null) { log.error("异步任务执行异常", ex); } else { log.info("异步任务成功,结果:{}", result); } });还有一类典型的踩坑场景是,在thenApply里调用了会抛受检异常的方法。比如Integer.parseInt不会抛受检异常还好,如果要调Thread.sleep()或者解析JSON时抛IOException,代码会直接编译失败。此时要么在lambda里try-catch包装为运行时异常,要么用CompletableFuture的completedFuture加handle组合处理。我见过不少项目在这里图省事直接catch后return null,结果后续所有逻辑都以null值继续运算,最终产生NullPointerException,这类问题排查起来比直接抛异常麻烦得多。
关于异常的传播方向,有一个机制值得留意:异常只会沿着“依赖链”往后续task传播,并不会反向传到发起任务的地方。也就是说,你join()拿结果时抛出来的异常,和你任务里实际抛出的那个异常不是同一个对象,中间隔着一个CompletionException包装。面试官喜欢问这一点,它也是很多人调试异步链路时迷惑的地方。
5. 避坑指南:那些线上才会暴露的问题
前面讲的都是CompletableFuture的正确用法,这一节聊聊我实际项目中踩过的坑,以及常见的线上问题排查思路。这部分内容属于经验总结,平时文档里很少系统讲,但遇到问题的源码级排查基本都是围绕这几个点展开的。
5.1 线程池耗尽与回调线程过载
第一个高频事故是线程池被打满。前面提到过,不传自定义线程池时用的ForkJoinPool.commonPool是整个JVM共享的。一旦某个业务的异步任务里出现阻塞操作,比如RestTemplate同步调用、Thread.sleep()、CountDownLatch.await(),这个线程就会被卡住不释放。阻塞的任务一多,公共池线程全部被占,其他使用CompletableFuture的业务也会跟着遭殃。
即使你自定义了线程池,如果线程数和队列容量设置不合理,同样会爆炸。我见过一个项目给一个异步批量处理任务配了核心线程数200的线程池,业务高峰期直接把数据库连接池打满,整条链路雪崩。线程池参数不是越大越好,要根据任务类型、依赖资源上限、系统负荷做综合评估,并且一定要配合拒绝策略和监控告警。
另一个容易被忽视的是回调线程过载问题。thenApply、thenAccept这些不带Async后缀的回调,默认会执行在“上一步任务完成的线程”上。如果上一步的任务在线程池里执行,回调也会在线程池线程上运行。当一个线程池同时服务大量任务的执行和回调度时,线程池的线程数会被两方面的负载同时占用。如果回调本身比较耗时,线程池的响应能力会快速下降。
解决思路是对耗时回调显式使用thenApplyAsync(task, executor),让回调进入另一个专门的线程池执行,避免执行线程和回调线程互相挤占。虽然多了一次线程切换的开销,但整体可控性和稳定性都会好很多。
5.2 超时控制与任务取消
CompletableFuture系列方法里,默认并没有为“等待结果”提供超时机制。JDK 9引入了orTimeout和completeOnTimeout,但JDK 8依然是很多项目的主力版本。如果你用的还是JDK 8,要想实现超时控制,常用的方案是配合Future.get(timeout, TimeUnit)来做。
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> { // 模拟长时间任务 return "result"; }, executor); try { String result = future.get(3, TimeUnit.SECONDS); } catch (TimeoutException e) { // 超时处理 future.cancel(true); log.warn("异步任务执行超时"); }这里有一个必须强调的细节:future.cancel(true)并不能真正中断一个正在运行的Runnable或Supplier任务。它只是把future的状态标记为“已取消”,底层线程池里的那个线程该执行还是继续执行。如果一个任务本身不响应中断,比如做了 CPU 密集计算,超时后任务还会跑完,只是结果没人要了。所以在设计异步任务时,任务内部要留意中断状态,配合Thread.currentThread().isInterrupted()判断是否需要提前退出。
JDK 9之后用orTimeout会更优雅。它直接作用在CompletableFuture上,超过指定时间后以TimeoutException完成future,配合exceptionally就能覆盖超时兜底。
CompletableFuture<String> future = CompletableFuture .supplyAsync(() -> "result", executor) .orTimeout(3, TimeUnit.SECONDS) .exceptionally(ex -> { if (ex instanceof TimeoutException) { return "超时兜底"; } return "其他异常兜底"; });这种方式比get(timeout)的侵入性小,而且不阻塞当前线程,是编排链路的天然组成部分。
5.3 常见问题排查速查表
我在下面整理了实战中最高频的几类问题、可能的原因和排查方向,供参考。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 接口偶尔超时,且集中在高峰时段 | 线程池被打满,任务在队列里等待过久 | 查看线程池活跃线程数、队列积压量、拒绝次数;确认队列长度设置 |
join()抛CompletionException且没有业务日志 | 链路某环节异常被静默吞掉 | 检查链路所有节点,确认是否有exceptionally兜底;为每个关键节点加whenComplete打印 |
| 所有异步任务都串行执行,没有并行 | 任务间存在隐式依赖,比如共享了同一个CompletableFuture引用 | 检查代码,确认每个supplyAsync是独立的,且没有在构建参数时就触发另一个任务的get() |
使用ForkJoinPool.commonPool后,全网其他异步逻辑变慢 | 某个任务阻塞了公共池线程 | 排查所有不传线程池的CompletableFuture代码;定位后替换为独立线程池 |
结果总是null,但任务似乎执行了 | 用了runAsync却期待有返回值 | 确认使用supplyAsync;查看返回类型是否为CompletableFuture<Void> |
| 任务完成后回调没有执行 | 中途某个exceptionally吞掉了异常,或链路中使用了whenComplete但误以为它会阻断 | 打印链路完整状态;用peek思路在关键节点打日志辅助定位 |
这张表不一定覆盖所有场景,但线上排查时如果先往这几个方向筛一遍,大概率能快速缩小范围。
6. 关于CompletableFuture,我的一些使用体会
文章写到这,内容基本覆盖了从runAsync到任务编排的完整链路。最后分享一点我个人在实际项目里的体会。
CompletableFuture是一个上限很高、下限也很低的工具。用得好,代码简洁优雅、性能提升明显;用得不好,线程池耗尽、异常被吞、排查困难,各种问题接踵而至。我的建议是:初学阶段先把它当作“增强版Future”来用,只做并行执行和get()汇总,踩一遍基础用法后再逐步引入编排能力。不要一上来就写复杂的thenCompose嵌套链,那在出现问题时很难阅读和调试。
另外,不要小看线程池的作用。CompletableFuture的执行效率和稳定性很大程度上取决于你传入的线程池,独立命名、合理参数、监控告警缺一不可。如果项目里已经引入了Hystrix或Resilience4j,也可以用它们自带的线程池配合CompletableFuture使用,隔离性和可观测性会更好。
最后再提一个容易被忽略的小技巧:如果你在Spring Boot项目里用CompletableFuture做异步接口返回,记得把自定义的线程池声明为Bean,并且通过构造注入或@Qualifier拿到它,避免在工具类里重复new线程池造成资源浪费。类似的场景我遇到过不止一次,统一管理后问题少了很多。
CompletableFuture这块内容延展性很强,配合Stream可以做出非常漂亮的响应式风格代码,配合CountDownLatch可以做更细粒度的线程协作,配合Spring @Async可以无缝融入现有框架。把它吃透,等于给并发编程这棵技能树补上了一块非常重要的拼图。