Future 与 CompletableFuture:异步编排、异常传播、线程池隔离(046)

Java并发编程约 15 分钟阅读7,824 字符
知识库文档 47 / 251

一、从"提交任务却拿不到结果"这个痛点说起

前几篇讲的都是线程池内部的运转机制——参数怎么估算、队列怎么选、拒绝策略怎么设计。但这些讨论有一个共同的前提:任务提交进去之后,"怎么拿到任务的执行结果"这件事本身,其实还有一整套值得单独展开的学问。

最朴素的用法是 ExecutorService.submit(callable),它会立刻返回一个 Future<T> 对象,然后你调用 future.get() 去拿结果。这个模式能工作,但有两个明显的短板:第一,get() 是阻塞的——调用它的线程会一直卡在那里,直到异步任务真正跑完,如果这个线程本来还有别的事要做,这段等待时间就是纯粹的浪费;第二,Future 本身不支持"组合"——如果你有三个互相依赖的异步任务(比如先查用户信息,再用用户信息去查订单,最后把两者拼成一个响应),用 Future 去写,会变成一层套一层的 get() 阻塞调用,代码读起来完全看不出"这几个任务之间到底是什么关系"。

CompletableFuture(Java 8 引入)解决的正是这两个问题:它把"任务完成之后要做什么"变成一种可以声明式挂载的回调,而不是必须主动阻塞去等;同时提供了一整套方法把多个异步任务按"串联""并联""等待全部完成""等待任意一个完成"这些不同的关系组合起来。这篇要讲清楚的,是这套编排能力具体怎么用、异常在这条链路里是怎么流动的,以及一个几乎所有人都会踩、但很少被认真讲透的坑——默认线程池的隔离问题。

二、Future 接口本身:get、cancel、isDone 的语义与陷阱

在深入 CompletableFuture 之前,有必要先把 Future 接口本身的几个方法的真实语义讲清楚,因为 CompletableFuture 本身也实现了 Future 接口,这几个陷阱同样适用:

  • **get()**:无参数调用会无限期阻塞,直到任务完成或者抛出异常。如果异步任务本身因为某种原因永远不会结束(比如陷入了死锁、或者在等一个永远不会到来的外部事件),调用 get() 的线程会被永久挂起,这是生产环境里一个相当常见但很容易被忽视的隐患——凡是调用无参数 get() 的地方,几乎都应该换成带超时的 get(timeout, unit),并且认真考虑超时之后应该如何处理(重试?降级?直接失败?)。
  • **cancel(boolean mayInterruptIfRunning)**:这个方法的实际效果经常被高估。如果任务还没开始执行,cancel 可以让它直接不再被执行;但如果任务已经在执行中,cancel(true) 只是会去尝试给执行任务的线程发一个中断信号(调用 Thread.interrupt()),真正能不能被中断,完全取决于任务代码本身有没有在合适的地方检查中断状态或者响应**InterruptedException**。一段没有任何中断检查点的纯计算代码(比如一个死循环或者一段很长的同步阻塞 I/O 调用如果不支持中断),调用 cancel(true) 之后它会毫无反应地继续跑完,cancel 只是给了个"建议",不是强制终止。
  • **isDone()**和**isCancelled()**:isDone() 在任务正常完成、异常结束、或者被取消这三种情况下都会返回 true——它只表示"这个 Future 已经不会再变化了",不代表"任务成功了"。想知道任务是不是因为异常而结束,还是得调用 get() 并捕获异常,或者对 CompletableFuture 使用后面会讲到的 isCompletedExceptionally()。

文章配图
文章配图

三、CompletableFuture 登场:从"阻塞等待"到"声明式编排"

创建一个 CompletableFuture 最常见的方式是 CompletableFuture.supplyAsync(supplier)(有返回值)或者 CompletableFuture.runAsync(runnable)(无返回值),两者都可以额外传入一个 Executor 参数,指定这个异步任务具体在哪个线程池上执行——这一点在第六节会展开讲,是一个非常重要、却经常被忽略的细节。

拿到一个 CompletableFuture<T> 之后,最基础的三个"接下来做什么"的方法是:

  • **thenApply(Function<T, R>)**:拿到上一步的结果,做一个转换,返回一个新的 CompletableFuture<R>——类似 Stream 里的 map。
  • **thenAccept(Consumer<T>)**:拿到上一步的结果,做点什么但不需要返回值(比如打日志、更新一个计数器),返回 CompletableFuture<Void>。
  • **thenRun(Runnable)**:完全不关心上一步的结果是什么,上一步完成了就执行一段代码,返回 CompletableFuture<Void>。

这三个方法的选择本质上是根据"我需不需要上一步的结果"和"我这一步需不需要产出一个新结果"这两个维度决定的,用错了通常也不会报错,只是会让代码显得多此一举(比如明明不需要上一步结果却用了 thenApply,参数直接忽略掉)。

四、异步编排:把多个 Future 串联、并联起来

真正体现"编排"能力的,是接下来这几个方法,它们解决的正是前面提到的"多个互相依赖的异步任务"问题:

  • **thenCompose(Function<T, CompletableFuture<R>>)**:用于串联——如果你的下一步操作本身也返回一个 CompletableFuture(比如"用查到的用户 ID 去异步查订单"),用 thenApply 会得到一个令人困惑的 CompletableFuture<CompletableFuture<Order>>(嵌套的 Future),而 thenCompose 会自动把这层嵌套"压平",直接得到 CompletableFuture<Order>。这和 Stream 里 map 与 flatMap 的区别是完全同构的——thenApply 对应 map,thenCompose 对应 flatMap。
  • **thenCombine(CompletableFuture<U>, BiFunction<T, U, R>)**:用于并联——如果你有两个完全独立、可以同时执行的异步任务(比如"查用户信息"和"查用户的优惠券余额"互不依赖),用 thenCombine 可以让它们并发执行,等两者都完成后再把两个结果合并处理,而不需要串行地先等一个再等另一个。
  • **CompletableFuture.allOf(cf1, cf2, cf3, ...)**:等待所有传入的 Future 都完成,返回一个 CompletableFuture<Void>(注意它本身不返回各个任务的结果,需要在 allOf 完成之后,再分别调用每个原始 Future 的 join() 去取结果——这也是一个初学者常见的困惑点)。
  • **CompletableFuture.anyOf(cf1, cf2, cf3, ...)**:只要任意一个传入的 Future 完成就返回,常用于"给同一个请求打给多个下游、谁先返回就用谁的结果"这类竞速场景。

文章配图
文章配图

五、异常传播:一步出错,后面会发生什么

CompletableFuture 链路里异常的传播规则,是实践中最容易踩坑、也最需要认真理解的一块:一旦链路中某一步抛出了异常,后续所有的**thenApply**/**thenAccept**/**thenRun**/**thenCompose**都会被自动跳过(这些方法体完全不会被执行),异常会一直往后传递,直到遇到一个专门处理异常的节点为止。这意味着如果你写了一条很长的链路,中间某一步出了问题,链路末端如果没有任何异常处理,最终这个异常会一直"潜伏"在返回的 CompletableFuture 里,只有在调用 get() 或者 join() 的时候才会以 ExecutionException(get())或者 CompletionException(join())包装的形式被重新抛出——这也是为什么很多时候异常发生的位置和异常最终被发现的位置,在代码里可能相隔了好几个方法调用。

专门用来处理异常的三个方法,语义各不相同:

  • **exceptionally(Function<Throwable, T>)**:只有在上游发生异常的时候才会被调用,用来提供一个兜底结果(比如返回一个默认值);如果上游没有异常,这个方法完全不会被执行,链路直接带着正常结果往后走。
  • **handle(BiFunction<T, Throwable, R>)**:无论上游是否发生异常都会被调用,方法参数里一个是正常结果(异常时为 null)、一个是异常对象(正常时为 null),你需要在方法体里自己判断走哪条分支——它的好处是可以把"正常路径"和"异常路径"统一收敛到同一个后续处理点。
  • **whenComplete(BiConsumer<T, Throwable>)**:和 handle 类似,无论是否异常都会被调用,但它不改变原来的结果或异常——只是用来做一些旁路操作(比如记录日志、上报监控),它返回的 CompletableFuture 依然带着原来的结果或异常继续往后传递,不能用它来"消化"掉异常。

一个容易踩的坑是:如果只用了 whenComplete 而没有用 exceptionally 或者 handle 去真正处理异常,异常本身并没有被"消化",它依然会继续往后传播,最终在 get()/join() 的时候抛出来——如果代码作者的本意是"我已经处理过异常了",但用错了方法,实际上异常根本没有被拦截住。

文章配图
文章配图

六、线程池隔离:xxxAsync 方法背后隐藏的坑

这是整篇里最容易被忽视、但后果可能最严重的一个细节:**thenApply**这类不带**Async**后缀的方法,执行所在的线程并不确定——如果调用这个方法的时候上一步已经完成了,它会直接在调用**thenApply**的那个线程上同步执行;如果上一步还没完成,它会在"最终让上一步完成的那个线程"上执行(也就是异步任务本身跑在哪个线程池,回调也就跟着在哪个线程池上执行)。这种"到底在哪个线程执行"取决于时序的不确定性,本身就是一个容易引发诡异 bug 的来源。

带 Async 后缀的方法(thenApplyAsync、thenComposeAsync 等)能显式指定执行的线程池,避免这种时序不确定性;但如果调用这些**xxxAsync**方法的时候没有传入**Executor**参数,Java 会默默地使用一个全局共享的默认线程池——**ForkJoinPool.commonPool()**。这个默认池的大小通常等于"CPU 核数 - 1",它不是为你的业务专门准备的,而是整个 JVM 进程里所有"没有显式指定线程池的并行/异步操作"共享的一个池子(Stream.parallel() 默认也用它)。

问题就出在这里:如果你的业务代码里有一段 CompletableFuture 链路,某一步执行的是一个比较耗时的操作(比如同步调用了一个慢查询的下游接口),又没有显式传入自己的业务线程池,这个耗时操作会占用 commonPool 里为数不多的几个线程之一,而这个池子是整个进程共享的——意味着这一个模块的耗时操作,可能会拖慢同一个 JVM 进程里其他完全不相关的模块、只要它们也恰好用了不带 Executor 参数的 xxxAsync 方法或者 parallelStream()。这种"跨模块的诡异性能问题"往往很难排查,因为出问题的模块和被拖慢的模块表面上毫无代码关联,只是共享了同一个默认线程池。

生产实践上的做法很明确:任何**xxxAsync**方法调用,都应该显式传入一个业务自己维护、根据前几篇讲的估算方法配置好参数的线程池,把"CompletableFuture 编排逻辑"和"具体执行的资源"解耦并做好隔离,而不是依赖那个来路不明的全局默认池。

文章配图
文章配图

文章配图
文章配图

七、面试追问链

从"提交任务之后怎么拿到结果"这个最基础的问题出发,逐层追问:Future.get() 和 cancel() 各自真实的行为边界是什么、CompletableFuture 相比 Future 解决了什么问题、thenApply 和 thenCompose 的区别本质上对应什么(map 与 flatMap)、异常在链路中是如何传播以及在哪个节点会被跳过、exceptionally/handle/whenComplete 三者的语义差异、以及为什么 xxxAsync 方法不显式传入线程池会埋下跨模块的性能隐患,这条链基本能覆盖面试官对异步编排这个话题的核心考察点。

文章配图
文章配图

延伸阅读

  • java.util.concurrent.CompletableFuture 源码中 UniApply、UniCompose、BiRelay 等内部类的实现,能更细致地理解链路节点之间是如何用一个内部的完成状态机串联起来的
  • ForkJoinPool.commonPool() 的默认并行度计算逻辑(Runtime.availableProcessors() - 1),以及可以通过哪个系统属性去调整它
  • 下一篇预告:CompletionService 与 Fork/Join 框架:批量异步任务的结果收集与分治并行计算

问答(不计入阅读时长)

Q1:**future.cancel(true)**调用之后,任务一定会停下来吗?

A:不一定。如果任务还没开始执行,cancel 能让它直接不再被执行;但如果任务已经在跑,cancel(true) 只是给执行线程发一个中断信号(Thread.interrupt()),任务代码必须自己在合适的地方检查中断状态或者响应 InterruptedException 才会真正停下来。一段没有任何中断检查点的死循环或者不支持中断的同步阻塞调用,收到 cancel(true) 之后会毫无反应地继续跑完。

Q2:**isDone()**返回**true**,能说明任务执行成功了吗?

A:不能。isDone() 在任务正常完成、异常结束、或者被取消这三种情况下都会返回 true,它只表示"这个 Future 不会再变化了"。要知道任务是不是因为异常而结束,得调用 get() 捕获异常,或者对 CompletableFuture 用 isCompletedExceptionally()。

Q3:**thenApply**和**thenCompose**到底该怎么选?

A:看下一步操作本身返不返回 CompletableFuture。如果下一步是个普通转换(比如把结果乘以 2),用 thenApply;如果下一步本身也是个异步操作(比如"用查到的用户 ID 去异步查订单"),用 thenApply 会得到嵌套的 CompletableFuture<CompletableFuture<Order>>,而 thenCompose 会自动压平这层嵌套。这和 Stream 里 map 与 flatMap 的区别完全同构——thenApply 对应 map,thenCompose 对应 flatMap。

Q4:**allOf**等待所有任务完成后,怎么拿到每个任务各自的结果?

A:allOf(cf1, cf2, cf3, ...) 本身返回的是 CompletableFuture<Void>,它不会自动帮你收集各个任务的结果。正确做法是等 allOf 完成之后,再分别调用每个原始 CompletableFuture 的 join() 去取各自的结果——这是一个初学者常见的困惑点,很多人以为 allOf 会自动把结果汇总成一个列表。

Q5:链路中间某一步抛了异常,后面的**thenApply**、**thenAccept**还会执行吗?

A:不会。一旦某一步抛出异常,后续所有的 thenApply/thenAccept/thenRun/thenCompose 都会被自动跳过(方法体完全不执行),异常会一直往后传递,直到遇到一个专门处理异常的节点(exceptionally 或 handle)为止。如果链路末端没有任何异常处理,这个异常会一直潜伏在返回的 CompletableFuture 里,只有调用 get()/join() 的时候才会被重新抛出。

Q6:只写了**whenComplete**而没写**exceptionally**或**handle**,异常算被处理了吗?

A:没有。whenComplete 无论是否异常都会被调用,但它不改变原来的结果或异常,只是用来做旁路操作(比如记录日志、上报监控)。它返回的 CompletableFuture 依然带着原来的异常继续往后传递,不能用它来"消化"异常。如果本意是"已经处理过异常了",但用了 whenComplete 而不是 exceptionally/handle,异常根本没有被拦截住,最终还是会在 get()/join() 时抛出来。

Q7:**exceptionally**和**handle**有什么区别?

A:exceptionally(Function<Throwable, T>) 只有在上游发生异常时才会被调用,用来提供一个兜底结果;上游没异常的话这个方法完全不执行,链路直接带着正常结果往后走。handle(BiFunction<T, Throwable, R>) 无论上游是否异常都会被调用,参数里一个是正常结果(异常时为 null)、一个是异常对象(正常时为 null),需要自己在方法体里判断走哪条分支,好处是能把正常路径和异常路径统一收敛到同一个后续处理点。

Q8:调用**thenApplyAsync**但没传**Executor**参数,代码在哪个线程池跑?会有什么隐患?

A:会默默使用 ForkJoinPool.commonPool()——一个大小通常为"CPU 核数 - 1"的全局默认池,是整个 JVM 进程里所有没有显式指定线程池的并行/异步操作(包括 parallelStream())共享的。隐患在于:如果某个模块的一段 CompletableFuture 链路里有个耗时操作又没传自己的业务线程池,这个耗时操作会占用 commonPool 里为数不多的线程,进而拖慢同一个进程里其他完全不相关、但也用了不带 Executor 参数的 xxxAsync 方法的模块——这种跨模块的诡异性能问题往往很难排查。生产实践上应该给每个 xxxAsync 调用都显式传入一个业务自己维护的线程池,做好隔离。