CompletableFuture是一个用于异步编程的强大工具,它允许我们以非阻塞的方式处理并发任务。然而,在使用CompletableFuture时,也需要注意一些问题,下面是一些常见的坑以及相应的解决方法。
一、 避免阻塞主线程
CompletableFuture的目的是为了实现非阻塞的并发编程,因此在使用时应该避免阻塞主线程。如果我们在CompletableFuture中执行的任务需要长时间运行,那么应该使用其他线程来执行这个任务,而不是在主线程中执行。否则,主线程将会被阻塞,导致应用程序无法响应用户输入和其他事件。
解决方法:使用线程池来执行长时间运行的任务。例如,可以使用Java的ExecutorService来创建一个线程池,然后将任务提交给线程池执行。这样可以避免阻塞主线程,并且可以充分利用多核CPU的优势。
二、正确处理异常
在异步编程中,异常处理是一个非常重要的问题。如果我们在CompletableFuture中抛出了异常,但是没有正确处理这个异常,那么程序将会崩溃。因此,我们需要确保在CompletableFuture中正确处理异常。
解决方法:可以使用CompletableFuture的exceptionally()方法来捕获并处理异常。exceptionally()方法会返回一个新的CompletableFuture,当原始的CompletableFuture中出现异常时,这个新的CompletableFuture将返回指定的值或者调用指定的函数。此外,我们还可以使用handle()方法来同时处理正常情况和异常情况。
| 方法名 | 是否改变结果 | 是否必须调用 | 触发条件 |
|---|---|---|---|
| exceptionally | 是 | 仅在异常时 | 异常发生 |
| handle | 是 | 总是 | 始终执行 |
| whenComplete | 否 | 总是 | 始终执行 |
- 异常传播和处理:
CompletableFuture允许在链式调用中处理异常,但需要注意的是,使用exceptionally()处理异常会返回一个默认值,并继续任务链。而使用handle()可以同时处理异常和结果,因此,选择合适的异常处理方法非常关键。 - 异常的中断传播:在
allOf()和anyOf()的组合任务中,如果某个任务抛出异常,可能会导致整个组合任务失败。需要在组合任务中添加异常处理逻辑来确保任务链的稳定性。
CompletableFuture.allOf(task1, task2).exceptionally(ex -> { System.err.println("任务失败:" + ex.getMessage()); return null; });
三、正确处理多个CompletableFuture
当我们有多个CompletableFuture需要处理时,需要注意正确地处理它们之间的关系。如果我们只是简单地等待所有CompletableFuture完成,那么可能会导致死锁或者程序崩溃。
解决方法:可以使用CompletableFuture的anyOf()和allOf()方法来处理多个CompletableFuture。anyOf()方法会返回一个新的CompletableFuture,当任何一个原始的CompletableFuture完成时,这个新的CompletableFuture就会完成。allOf()方法会返回一个新的CompletableFuture,只有当所有的原始CompletableFuture都完成后,这个新的CompletableFuture才会完成。此外,我们还可以使用CompletionStage的runAfterBoth()、runAfterEither()等方法来指定多个CompletableFuture之间的关系。
四、避免资源泄露
在异步编程中,资源泄露是一个常见的问题。如果我们创建了一个CompletableFuture对象,但是没有正确地关闭它,那么就可能会导致资源泄露。
解决方法:在使用CompletableFuture时,应该始终确保在使用完之后关闭它。可以使用try-with-resources语句来自动关闭资源。此外,还应该避免在长时间运行的任务中使用全局变量,因为这样可能会导致内存泄露。
总结:在使用CompletableFuture时,需要避免阻塞主线程、正确处理异常、正确处理多个CompletableFuture以及避免资源泄露。通过注意这些问题并采取相应的解决方法,我们可以更好地利用CompletableFuture实现高效的异步编程。
五、线程池使用不当(着重讲)
在使用CompletableFuture时,有些开发者往往忽略了线程池的配置,这可能是最容易被忽视但影响最大的坑。CompletableFuture 默认使用 ForkJoinPool.commonPool(),在高并发环境下可能导致线程池资源耗尽。因此, 默认使用 forkJoinPool.commonPool() 作为线程池来执行异步任务,这种方式适用于轻量级的计算密集型任务。但是,对于I/O 密集型任务或需要更多线程的场景,这个默认配置可能会导致性能瓶颈甚至线程池饱和,从而降低系统的响应效率。
ExecutorService customExecutor = Executors.newFixedThreadPool(10); CompletableFuture.supplyAsync(() -> someIOTask(), customExecutor);
默认的 ForkJoinPool.commonPool()
CompletableFuture 默认的线程池是:ForkJoinPool.commonPool() ,它是一个共享的公共线程池,被所有 CompletableFuture 和其他并发任务(如并行流 Stream.parallel())共享。它的特点是:
- 适合计算密集型任务:
ForkJoinPool是为了分治法(Divide and Conquer)而设计的,适合在多核环境下并行执行小而短的计算任务。 - 线程数量有限:默认情况下,
ForkJoinPool的线程数是与CPU核心数一致的。例如,在一个 8 核的机器上,默认的线程数是 8。对于CPU 密集型任务,这很合适,因为这样的任务主要依赖于CPU的计算能力,更多的线程数不会提高性能。 - 阻塞任务的潜在问题:
ForkJoinPool并不适合I/O 密集型任务。I/O密集型任务会导致线程长时间等待外部资源(如文件、数据库、网络请求),而这会阻塞线程,导致线程池被耗尽。
为什么默认 ForkJoinPool 不适合 I/O 密集型任务?
I/O 密集型任务(如数据库查询、文件读写、HTTP 请求等)常常会涉及大量的等待时间,比如等待磁盘 I/O、网络 I/O 或其他外部资源。与计算密集型任务不同,I/O 操作可能会让线程长时间处于阻塞状态,这会导致以下问题:
- 线程资源耗尽:由于 I/O 操作需要等待较长时间,有限的线程池可能会被阻塞的线程占满,从而导致其他异步任务无法获得线程资源来执行。
- 高延迟:当
ForkJoinPool被阻塞的 I/O 任务占用时,其他任务可能会面临更高的排队时间和执行延迟。 - 吞吐量下降:由于线程被长期阻塞,系统的吞吐量会下降,无法充分利用多核 CPU 的并行处理能力。
ForkJoinPool 与自定义线程池的选择
-
计算密集型任务:
- 适合使用
ForkJoinPool,因为其设计初衷是为计算密集型任务提供高效的分治策略,并充分利用多核 CPU 的并行处理能力。 - 由于计算密集型任务主要占用 CPU 资源,而不会导致长时间的 I/O 等待,
ForkJoinPool的线程数与 CPU 核心数一致,可以实现高效的任务调度。
- 适合使用
-
I/O 密集型任务:
- 建议使用自定义线程池,如
FixedThreadPool或CachedThreadPool。这种方式允许线程池在遇到长时间的 I/O 等待时,提供足够的并行线程,防止线程池资源耗尽。 - 自定义线程池可以根据 I/O 密集型任务的特点配置更多的线程数,从而提高任务的并发处理能力。
- 建议使用自定义线程池,如
对于计算密集型任务和I/O 密集型任务,我这里做了详细的总结:
自定义线程池和公共线程池的区别
1.1 并行的概念
- CPU 核心数确实决定了同一时间内的真正并行计算的线程数,但并不意味着只有 8 个线程。事实上,在高并发的应用中,通常会有比 CPU 核心数多得多的线程被创建。
- 例如,I/O 密集型任务在等待时可以让出 CPU,而这时另一个任务可以利用 CPU 继续执行。因此,虽然 CPU 核心数限制了同时运行的计算密集型线程,但在有大量 I/O 操作时,可以允许更多线程存在。
1.2 公共线程池的瓶颈
- 公共线程池(
ForkJoinPool.commonPool())默认使用的线程数等于 CPU 核心数,适合计算密集型任务。如果这些线程被 I/O 操作阻塞,那么新任务可能就无法获得线程资源,从而导致任务队列的积压。 - 公共线程池中的任务如果被长时间阻塞,那么整个线程池就可能耗尽可用线程,导致新任务无法立即启动。
1.3 自定义线程池的优点
- 更多的线程数:自定义线程池允许创建比核心数更多的线程,从而提高 I/O 密集型任务的吞吐量。在 I/O 阻塞时,其他线程仍然可以获得 CPU 时间片来执行。
- 分离不同任务类型:使用单独的线程池来处理不同类型的任务(如 I/O 和计算)可以避免不同任务之间的干扰。例如,计算密集型任务可以在公共线程池中运行,而 I/O 密集型任务可以在自定义的线程池中运行,从而提升系统的整体吞吐量和响应速度。
六、总结
通过上面的详细列举分析,我们可以看到虽然CompletableFuture虽然强大,但是也确实存在不少陷阱和深坑。
最后建议
- 理解原理:不要只是机械地使用API,要理解
CompletableFuture的工作原理; - 适度使用:不是所有场景都需要异步,同步代码更简单易懂;
- 测试覆盖:异步代码的测试很重要,要覆盖各种边界情况;
- 监控告警:在生产环境中要有完善的监控和告警机制;
- 持续学习:关注Java并发编程的新特性和最佳实践。
记住,工具是为了提高生产力,而不是制造问题。
掌握了这些避坑技巧,CompletableFuture将成为你手中强大的并发编程利器!