CompletableFuture
告别阻塞:从 Future 到 CompletableFuture,全面掌握 Java 异步编程精髓
在现代软件开发中,性能是永恒的追求。为了榨干多核CPU的性能,我们不可避免地要和异步与多线程打交道。Java 5 引入的 Future 接口,为我们打开了异步编程的大门。然而,这扇门只开了一半。直到 Java 8 的 CompletableFuture 横空出世,我们才真正拥有了优雅、强大的异步编排能力。
这篇文章将带你从 Future 的局限性出发,一步步深入 CompletableFuture 的世界,通过一个生动的电商比价案例,让你不仅“会用”,更能“讲透”,在面试中脱颖而出。
1. 温故知新:Future 的“初心”与“窘境”
在深入 CompletableFuture 之前,我们必须先理解它的前辈 Future 解决了什么问题,又留下了哪些遗憾。
1.1 Future 的核心价值
Future 的核心思想很简单:将一个耗时的任务提交到另一个线程去执行,并给你一个“未来的凭证”(即 Future 对象),让主线程可以继续做其他事情。 当主线程需要任务结果时,再用这个凭证去获取。
这就像老师在上课(主线程),突然想喝水,他不会停止讲课,而是让班长(子线程)去小卖部买水(耗时任务)。老师继续上课,过了一会课间休息时,再问班长水买回来了没有。
Future 接口定义了几个核心操作:
get(): 获取任务结果,如果任务没执行完,就一直阻塞等待。isDone(): 判断任务是否执行完毕。cancel(): 尝试取消任务。isCancelled(): 判断任务是否被取消。
FutureTask 是 Future 和 Runnable 的一个经典实现,它既可以作为任务被线程执行,又能管理任务状态和结果。
1 | import java.util.concurrent.Callable; |
1.2 Future 的两大“窘境”
Future 模式虽然实现了异步,但在获取结果时却显得非常“笨拙”,主要体现在:
-
阻塞式
get()
一旦调用future.get(),你的主线程就会被无情地阻塞,直到异步任务完成。这与异步编程“不等待”的核心理念背道而驰。如果任务耗时很长,整个应用的吞吐量都会下降。我们称之为“不见不散”的阻塞。 -
轮询式
isDone()
为了避免get()的阻塞,我们可能会写出这样的代码:1
2
3
4while(!future.isDone()) {
// 等待,或者干点别的,但CPU在空转
}
String result = future.get();这种轮询(Busy-Wait)的方式会持续消耗CPU资源,非常低效,而且无法保证实时性。
结论:Future 解决了“有没有”的问题,但没有解决“如何优雅地处理结果”的问题。对于复杂的业务场景,比如任务A完成后自动触发任务B,或者多个任务结果需要合并,Future 提供的API就显得力不从心了。
2. 王者登场:CompletableFuture 的革命性改进
为了解决 Future 的窘境,Java 8 带来了 CompletableFuture。它不仅实现了 Future 接口,还实现了 CompletionStage 接口,这赋予了它强大的回调和编排能力。
CompletionStage 接口代表了异步计算中的一个阶段,一个阶段完成后可以触发下一个阶段。CompletableFuture 正是利用这种机制,构建了一个响应式、非阻塞的异步编程模型。
核心优势:
- 回调机制:当异步任务完成或异常时,自动调用你传入的回调函数,主线程彻底解放。
- 异步任务编排:可以像流水线一样,将多个异步任务串联、并联或组合。
- 优雅的异常处理:提供了专门的机制来处理异步链中的异常。
2.1 创建一个 CompletableFuture 任务
CompletableFuture 提供了四个核心的静态工厂方法来启动异步任务:
runAsync(Runnable runnable): 执行一个没有返回值的异步任务。runAsync(Runnable runnable, Executor executor): 使用自定义线程池执行。supplyAsync(Supplier<U> supplier): 执行一个有返回值的异步任务。supplyAsync(Supplier<U> supplier, Executor executor): 使用自定义线程池执行。
面试要点:如果没有指定
Executor,CompletableFuture默认使用ForkJoinPool.commonPool()这个公共线程池。在CPU密集型计算中它表现很好,但如果是IO密集型任务,建议使用自定义线程池,以避免公共池中的线程被长时间阻塞。
3. 实战演练:用 CompletableFuture 实现电商比价
让我们通过一个常见的业务需求,来感受 CompletableFuture 的威力。
需求:开发一个商品比价服务。当用户搜索“MySQL从入门到跑路”这本书时,需要同时从京东、淘宝、当当等多个电商平台查询价格,然后汇总成一个价格列表返回。
3.1 传统方案的痛点
- 串行查询:查完京东,再查淘宝,再查当当。如果每个平台查询耗时2秒,3个平台就需要6秒,用户体验极差。
- 手动创建线程:为每个查询创建一个
Thread或使用Future,代码繁琐,且结果合并处理起来很麻烦。
3.2 CompletableFuture 的优雅解法
我们可以为每个平台的查询创建一个 supplyAsync 任务,让它们并发执行。然后使用 allOf 等待所有任务完成,最后统一处理结果。
1 | import java.util.Arrays; |
运行结果:
1 | 《MySQL从入门到跑路》 in 淘宝 price is 105.43 |
可以看到,总耗时约等于最慢的那个网络请求的耗时,而不是所有请求耗时的总和。这就是异步并行的魅力!代码也极其简洁,充满了函数式编程的优雅。
4. 面试高频:CompletableFuture 核心 API 梳理
掌握了基本用法,我们再来系统梳理一下面试中常被问到的核心API。
4.1 结果处理与消费 (then…系列)
这是 CompletableFuture 的精髓,用于构建任务流水线。
thenApply(Function fn): 串行依赖,有返回值。当上一个任务完成时,将其结果作为输入,执行fn函数,并返回一个新的CompletableFuture。如果上一步出错,thenApply不会执行。1
2
3CompletableFuture.supplyAsync(() -> 1024)
.thenApply(r -> r * 2)
.thenAccept(System.out::println); // 输出 2048thenAccept(Consumer action): 串行依赖,无返回值。消费上一个任务的结果,但自身不产生新值。thenRun(Runnable action): 不依赖上一步结果,无返回值。只关心上一步任务是否完成,完成后就执行action。handle(BiFunction fn): 强大的异常处理。无论上一步是正常完成还是异常,handle都会执行。它接收两个参数:结果和异常(有结果时异常为null,反之亦然)。这给了你一个恢复现场的机会。1
2
3
4
5
6
7
8
9
10CompletableFuture.supplyAsync(() -> {
if (true) throw new RuntimeException("计算出错!");
return 1024;
}).handle((result, exception) -> {
if (exception != null) {
System.out.println("发生异常: " + exception.getMessage());
return -1; // 返回一个默认值
}
return result;
}).thenAccept(System.out::println); // 输出 -1
面试要点:
thenApplyvshandle?thenApply是“一帆风顺”时的处理,一旦出错,链条就断了。handle则是“无论风雨”都能处理,提供了更强的容错能力。
4.2 任务组合 (combine/either 系列)
thenCombine(other, BiFunction fn): 合并两个任务。等待当前任务和other任务都完成后,将两个结果作为参数传给fn函数进行处理。1
2
3CompletableFuture<Integer> cf1 = CompletableFuture.supplyAsync(() -> 10);
CompletableFuture<Integer> cf2 = CompletableFuture.supplyAsync(() -> 20);
cf1.thenCombine(cf2, (r1, r2) -> r1 + r2).thenAccept(System.out::println); // 输出 30applyToEither(other, Function fn): 竞速。两个任务赛跑,谁先完成就用谁的结果来执行fn函数。1
2
3
4CompletableFuture<String> taskA = CompletableFuture.supplyAsync(() -> { sleep(1); return "TaskA"; });
CompletableFuture<String> taskB = CompletableFuture.supplyAsync(() -> { sleep(2); return "TaskB"; });
taskA.applyToEither(taskB, result -> result + " is the winner")
.thenAccept(System.out::println); // 输出 TaskA is the winner
4.3 线程池说明 (…Async 后缀)
thenApply, thenAccept, thenRun 等方法都有一个对应的 ...Async 版本,例如 thenApplyAsync。
- 不带
Async: 下一步任务可能由上一步任务的线程执行,也可能由主线程(调用join/get的线程)执行,取决于上一步任务完成时,下一步任务是否已经注册。 - 带
Async: 始终会将下一步任务提交到线程池中异步执行。可以传入自定义线程池,否则使用默认的ForkJoinPool。这能确保后续任务不会阻塞上一步任务的线程,是更推荐的做法。
5. 总结
CompletableFuture 是 Java 异步编程的“瑞士军刀”,它彻底解决了 Future 模型的痛点,将我们从阻塞和轮询的泥潭中解放出来。
面试核心要点回顾:
- Why CompletableFuture? -> 为了解决
Future.get()阻塞和isDone()轮询的弊端,提供了基于回调的非阻塞模型。 - How to create? ->
supplyAsync(有返回) 和runAsync(无返回),并注意其对默认线程池ForkJoinPool的使用。 - How to chain tasks? ->
thenApply(转换),thenAccept(消费),thenRun(执行),以及它们的...Async版本。 - How to handle errors? ->
handle(全能处理) 和exceptionally(专门处理异常)。 - How to combine tasks? ->
thenCombine(合并结果) 和applyToEither(竞速)。
掌握了这些,你不仅能写出高性能、高可读性的异步代码,更能向面试官清晰地展示你对现代Java并发编程的深刻理解。希望这篇文章能成为你技术武器库中的一件利器!
