记一次生产中使用CompletableFuture遇到的坑

本文介绍如何使用CompletableFuture优化批量RPC调用,通过合理分配任务和自定义线程池解决生产环境中的性能瓶颈。

为什么使用CompletableFuture

业务功能描述:有一个功能是需要调用基础平台接口组装我们需要的数据,在这个功能里面我们要调用多次基础平台的接口,我们的入参是一个id,但是这个id是一个集合。我们都是使用RPC调用,一般常规的想法去遍历循环这个idList,但是呢这个id集合里面的数据可能会有500个左右。说多不多,说少也不少,主要是在for循环里面多次去RPC调用是一件特别费时的事情。

我用代码大致描述一下这个需求:

 public List<BasicInfo> buildBasicInfo(List<Long> ids) {
         List<BasicInfo> basicInfoList = new ArrayList<>();
         for (Long id : ids) {
             getBasicData(basicInfoList, id);
         }
     }
 ​
     private List<BasicInfo> getBasicData(List<BasicInfo> basicInfoList, Long id) {
         BasicInfo basicInfo = rpcGetBasicInfo(id);
         return basicInfoList.add(basicInfo);
     }
 ​
     public BasicInfo rpcGetBasicInfo(Long id) {
         // 第一次RPC 调用
          rpcInvoking_1()...........
 ​
         // 拿到第一次的结果进行第二次RPC 调用
          rpcInvoking_2()...........
 ​
         // 拿到第二次的结果进行第三次RPC 调用、
          rpcInvoking_3()...........
 ​
         // 拿到第三次的结果进行第四次RPC 调用、
          rpcInvoking_4()...........
 ​
         // 组装结果返回
 ​
         return BasicInfo;
     }
复制代码

是的,这个数据的获取就是这么的扯淡。。。如果使用循环的方式,当ids数据量在500个左右的时候,这个接口返回的时间再8s左右,这是万万不能接受的,那如果ids数据更多呢?所以不能用for循环去遍历ids呀,这样确实是太费时了。

既然远程调用避免不了,那就想办法让这个接口快一点,这时候就想到了多线程去处理,然后就想到使用CompletableFuture异步调用:

CompletableFuture多线程异步调用

       List<BasicInfo> basicInfoList = new ArrayList<>();
       CompletableFuture<List<BasicInfo>> future = CompletableFuture.supplyAsync(() -> {
             ids.forEach(id -> {
                 getBasicData(basicInfoList, id);
             });
             return basicInfoList;
        });
        try {
            List<BasicInfo> basicInfos = future.get();
        } catch (Exception e) {
             e.printStackTrace();
        } 
复制代码

这里补充一点:CompletableFuture是否使用默认线程池的依据,和机器的CPU核心数有关。当CPU核心数减1大于1时,才会使用默认的线程池(ForkJoinPool),否则将会为每个CompletableFuture的任务创建一个新线程去执行。即,CompletableFuture的默认线程池,只有在双核以上的机器内才会使用。在双核及以下的机器中,会为每个任务创建一个新线程,等于没有使用线程池,且有资源耗尽的风险

默认线程池,池内的核心线程数,也为机器核心数减1,这里我们的机器是8核的,也就是会创建7个线程去执行。

上面这种方式虽然实现了多线程异步执行,但是如果ids集合很多话,依然会很慢,因为future.get();也是堵塞的,必须等待所有的线程执行完成才能返回结果。

改进CompletableFuture多线程异步调用

想让速度更快一点,就想到了把ids进行分隔:

   int pageSize = ids.size() > 8 ? ids.size() >> 3 : 1;
   List<List<Long>> partitionAssetsIdList = Lists.partition(ids, pageSize);
复制代码

因为我们CPU核数为8核,所有当ids的大小小于8时,就开启8个线程,每个线程分一个。这里的>>3(右移运算)相当于ids的大小除以2的3次方也就是除以8;右移运算符相比除效率会高。毕竟现在是在优化提升速度。

如果这里的ids的大小是500个,就是开启9个线程,其中8个线程是处理62个数据,另一个线程处理4个数据,因为有余数会另开一个线程处理。具体代码如下:

         int pageSize = ids.size() > 8 ? ids.size() >> 3 : 1;
         List<List<Long>> partitionIdList = Lists.partition(ids, pageSize);
         List<CompletableFuture<?>> futures = new ArrayList<>();
         //如果ids为500,这里会分隔成9份,也就是partitionIdList.size()=9;遍历9次,也相当于创建了9个CompletableFuture对象,前8个CompletableFuture对象处理62个数据。第9个处理4个数据。
         partitionIdList.forEach(partitionIds -> {
             List<BasicInfo> basicInfoList = new ArrayList<>();
             CompletableFuture<List<BasicInfo>> future = CompletableFuture.supplyAsync(() -> {
                 partitionIds.forEach(id -> {
                     getBasicData(basicInfoList, id);
                 });
                 return basicInfoList;
             });
             futures.add(future);
         });
         // 把所有线程执行的结果进行汇总
         List<BasicInfo> basicInfoResult = new ArrayList<>();
         for (CompletableFuture<?> future : futures) {
             try {
                 basicInfoResult.addAll((List<BasicInfo>)future.get());
             } catch (Exception e) {
                 e.printStackTrace();
             }
         }
复制代码

如果ids的大小等于500,就会被分隔成9份,创建9个CompletableFuture对象,前8个CompletableFuture对象处理62个数据(id),第9个处理4个数据(id)。这62个数据又会被分成7个线程去执行(CPU核数减1个线程)。经过分隔之后充分利用了CPU。速度也从8s减到1-2s。得到了总监和同事的夸赞,同时也被写到正向事件中;哈哈哈哈。

在生产环境中遇到的坑。

上面说了那么多还没有说到坑在哪里,下面我们就说说坑在哪里?

本地和测试都没有啥问题,那就找个时间上生产呗,升级到生产环境,发现这个接口堵塞了,超时了。。。

刚被记录到正向事件,可不想在被记录个负向时间。感觉去看日志。

发现日志就执行了将ids进行分隔,后面循环去创建CompletableFuture对象之后的代码都没有在执行了。然后我第一感觉测试是future.get()获取结果的时候堵塞了,所以一直没有结果返回。

排查问题过程

我们要解决这个问题就要看看问题出现在哪里?

当执行到这个接口时候我们第一时间看了看CPU的使用率:

这是访问接口之前:

发现执行这个接口时PID为10348的这个进程的CPU突然的高了起来。

紧接着使用jps -l :打印出我们服务进程的PID

PID为10348正式我们现在执行这个服务。

接着我就详细的看一下这个PID为10348的进程下哪里线程占用的高:

发现这几个占用的相对高一点:

紧接着使用jstack命令生成java虚拟机当前时刻的线程快照,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等。 线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做什么事情,或者等待什么资源

jstack -l 10348 >/tmp/10348.log,使用此命令将PID为10348的进程下所有线程快照输出到log文件中。

同时我们将线程比较的PID转换成16进制:printf "%x\n" 10411

我们将转换成16进制的数值28ab,28a9在10348.log中搜索一下:

看到线程的快照发现这不是本次修改的接口呀。看到日志4处但是也是用了CompletableFuture。找到对应4处的代码发现这是监听mq消息,然后异步去执行,代码类型这样:

经过查看日志发现这个mq消息处理很频繁,每秒都会有很多的数据上来。

我们知道CompletableFuture默认是使用ForkJoinPool作为线程池。难道mq使用ForkJoinPool和我当前接口使用的都是同一个线程池中的线程?难道是共用的吗?

MQ监听使用的线程池:

我们当前接口使用的线程池:

它们使用的都是ForkJoinPool.commonPool()公共线程池中的线程!

看到这个结论就很好理解了,我们目前修改的接口使用的线程池中的线程全部都被MQ消息处理占用,我们修改优化的接口得不到资源,所以一直处于等待。

同时我们在线程快照10348.log日志中也看到我们优化的接口对应的线程处于WAITING状态!

这里- parking to wait for <0x00000000fe2081d8>肯定也是MQ消费线程中的某一个。由于MQ消费消息比较多,每秒都会监听到大量的数据,线程的快照日志收集不全。所以在10348.log中没有找到,这不影响我们修改bug。问题的原因已经找到了。

解决问题

上面我们知道两边使用的都是公共静态线程池,我们只要让他们各用各的就行了:自定义一个线程池:ForkJoinPool pool = new ForkJoinPool();

         int pageSize = ids.size() > 8 ? ids.size() >> 3 : 1;
         List<List<Long>> partitionIdList = Lists.partition(ids, pageSize);
         List<CompletableFuture<?>> futures = new ArrayList<>();
         partitionIdList.forEach(partitionIds -> {
             List<BasicInfo> basicInfoList = new ArrayList<>();
             //重新创建一个ForkJoinPool对象就可以了
             ForkJoinPool pool = new ForkJoinPool();
             CompletableFuture<List<BasicInfo>> future = CompletableFuture.supplyAsync(() -> {
                 partitionIds.forEach(id -> {
                     getMonitoringCoverage(basicInfoList, id);
                 });
                 return basicInfoList;
            //在这里使用
             },pool);
             futures.add(future);
         });
         // 把所有线程执行的结果进行汇总
         List<BasicInfo> basicInfoResult = new ArrayList<>();
         for (CompletableFuture<?> future : futures) {
             try {
                 basicInfoResult.addAll((List<BasicInfo>)future.get());
             } catch (Exception e) {
                 e.printStackTrace();
             }
         }
复制代码

这样他们就各自用各自的线程池中的线程了。不会存在资源的等待现场了。

总结:

之所以测试环境和开发环境没有出现这样的问题是因为这两个环境mq没有监听到消息。大量的消息都在生产环境中才会出现。由于测试环境的数据量达不到生产环境的数据量,所以有些问题在测试环境体验不出来。

码字不易,多多支持。还是那句话:不积跬步,无以至千里.不积小流,无以成江海!

<think> 首先,用户的问题是关于在Java的for循环中使用CompletableFuture的方法和相关知识。系统指令要求我必须使用中文回答,确保LaTeX语法正确,生成相关问题,并在引用末尾添加引用标识。 用户提供了几个引用: - 引用[1]展示了一个示例代码,使用CompletableFuture异步执行for循环,每个迭代任务在并行线程中运行。代码中用了IntStream.range和CompletableFuture.runAsync来创建任务数组,然后使用CompletableFuture.allOf(futures).join()来等待所有任务完成。 - 引用[2]讨论了在for循环中使用CompletableFuture实现异步任务的同步,类似于任务链。 - 引用[3]是关于在for循环中并行执行CompletableFuture并确保所有任务完成后录日志的问题,询问是否需要阻塞主线程。 用户的需求是:“我想了解在 Java 的 for 循环中使用 CompletableFuture 的方法和相关知识 请问Java for 循环中使用 CompletableFuture 的方法”。所以,我需要解释基本方法、相关概念和最佳实践。 结构化我的回答: 1. **引言**:简要介绍CompletableFuture和其在for循环中的应用。 2. **基本方法**:解释如何使用CompletableFuture在for循环中异步执行任务。 - 示例代码:参考引用[1]中的代码。 - 解释关键点:使用runAsync、创建任务数组、等待所有完成。 3. **同步和异步处理**:讨论如何确保任务顺序执行(如任务A完成后任务B运行),参考引用[2]。 4. **避免阻塞主线程**:讨论如何在非阻塞模式下处理,参考引用[3]。 5. **生成相关问题**:在回答结束时添加相关问题列表。 回答必须使用中文,LaTeX语法正确: - 行内数学表达式用$...$,但我这里主要是代码,可能不需要数学表达式。如果有变量,用$格式。 - 独立公式用$$...$$,但同样不太需要。 - 代码块应使用代码格式,如示例中的代码块。 生成相关问题:至少三个问题,基于主题。 引用标识:在回答中,如果引用提供的引用资料,末尾添加[^1], [^2]等。 回顾用户上一次的问题:用户上一次的问题是“我想了解在 Java 的 for 循环中使用 CompletableFuture 的方法和相关知识”,所以这是第一个交互,没有历史。我需要基于当前引用。 构建回答大纲: - **第一部分**:介绍CompletableFuture和异步编程的好处。 - **第二部分**:详细说明在for循环中使用CompletableFuture的基本方法。 - 复制或参考引用[1]的代码。 - 解释代码。 - **第三部分**:处理同步和依赖。 - **第四部分**:等待所有任务完成的非阻塞方法。 - **第五部分**:生成相关问题。 确保回答真实可靠:基于Java标准库和常见实践。 最终输出结构: - 回答主体。 - 代码块(如果有)。 - §§相关问题§§列表。 </think> ### Java for 循环中使用 CompletableFuture 的方法 在 Java 中,`CompletableFuture` 是 Java 8 引入的强大工具,用于异步编程和并发控制。它在 for 循环中的应用可以实现任务的并行执行,提升性能,尤其是在处理耗时操作时(如 I/O 或计算密集型任务)。核心思想是将循环迭代中的每个任务封装为一个 `CompletableFuture` 对象,然后批量管理这些任务的执行和完成状态。以下我将逐步解释基本方法、关键技巧和注意事项,确保回答基于 Java 标准库和实践经验。 #### 1. **基本方法:异步执行 for 循环任务** 在 for 循环中使用 `CompletableFuture` 的最简单方式是使用 `CompletableFuture.runAsync()` 或 `CompletableFuture.supplyAsync()` 来创建异步任务。每个迭代的任务被提交到线程池中并行执行。最后,通过 `CompletableFuture.allOf()` 确保所有任务完成后继续主线程流程。参考引用[1]的示例代码,以下是完整实现: ```java import java.util.concurrent.CompletableFuture; import java.util.stream.IntStream; public class AsyncForLoopExample { public static void main(String[] args) { // 创建一个CompletableFuture数组,存储每个循环迭代的任务 CompletableFuture<Void>[] futures = IntStream.range(0, 10) .mapToObj(i -> CompletableFuture.runAsync(() -> { // 模拟耗时任务,例如数据库查询或计算 try { Thread.sleep(1000); // 耗时1秒 System.out.println("Task " + i + " completed"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 处理中断 } })) .toArray(CompletableFuture[]::new); // 转换为数组 // 使用allOf()等待所有任务完成 CompletableFuture<Void> allOf = CompletableFuture.allOf(futures); allOf.join(); // 阻塞主线程,直到所有任务完成 System.out.println("All tasks completed!"); } } ``` **代码解析:** - **`runAsync()`**: 用于执行无返回值的任务(如打印日志)。如果任务有返回值(如计算结果),改用 `supplyAsync()`。 - **`IntStream.range().mapToObj()`**: 使用 Java Stream API 创建循环范围(0 到 9),并将每个迭代映射为一个 `CompletableFuture` 任务。 - **`allOf().join()`**: `allOf(futures)` 创建一个组合 future,当所有任务完成时触发;`join()` 阻塞主线程等待结果。这确保了循环结束后才执行后续代码。 - **优点**: 并行执行减少了总耗时(本例中,10个并行任务需约1秒,而非串行的10秒)。适用于独立任务场景[^1]。 #### 2. **处理任务依赖和同步** 在某些场景下,for 循环中的任务需要顺序执行(如任务A完成后任务B才能启动)。这可以通过 `thenCompose()` 或 `thenRun()` 构建任务链实现。参考引用[2],以下示例展示如何在 for 循环中强制异步任务同步: ```java import java.util.concurrent.CompletableFuture; import java.util.stream.IntStream; public class ChainedTasksExample { public static void main(String[] args) { // 初始化一个起始future CompletableFuture<Void> previous = CompletableFuture.completedFuture(null); for (int i = 0; i < 5; i++) { final int taskId = i; // 链式执行:上一个任务完成后启动当前任务 previous = previous.thenCompose(voidValue -> CompletableFuture.runAsync(() -> { System.out.println("Task " + taskId + " started after previous task"); // 模拟任务执行 try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } })); } previous.join(); // 等待所有链式任务完成 System.out.println("All chained tasks completed!"); } } ``` **关键点解释:** - **任务链**: `previous.thenCompose(...)` 确保每个新任务只在上一任务完成后启动。类似地,`thenRun()` 可用于无依赖的后续动作。 - **适用场景**: 当任务有顺序依赖时(如数据处理流水线),避免并发冲突[^2]。 - **注意事项**: 这种方法会降低并行度,因为任务是串行化的。如果不需严格顺序,优先使用 `allOf()` 提升性能。 #### 3. **非阻塞方式等待任务完成** 在需要异步处理且不阻塞主线程的场景下(如GUI应用或服务器),可以使用回调函数(如 `thenAccept()`)代替 `join()`。参考引用[3],以下代码实现非阻塞日志录: ```java import java.util.concurrent.CompletableFuture; import java.util.stream.IntStream; public class NonBlockingExample { public static void main(String[] args) { CompletableFuture<Void>[] futures = IntStream.range(0, 5) .mapToObj(i -> CompletableFuture.runAsync(() -> { System.out.println("Processing task " + i); try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } })) .toArray(CompletableFuture[]::new); // 使用allOf() + thenRun()实现非阻塞回调 CompletableFuture.allOf(futures) .thenRun(() -> System.out.println("All tasks completed asynchronously!")) .join(); // 可选:如果主线程需等待,可保留join;否则移除以实现完全非阻塞 System.out.println("Main thread continues without blocking"); } } ``` **优化建议:** - **非阻塞机制**: `thenRun()` 在所有任务完成后异步触发回调,不阻塞主线程。确保日志在任务结束时输出。 - **性能权衡**: 移除 `join()` 可使主线程立即继续,但需确保程序逻辑不提前退出(如使用 `Thread.sleep` 等待或守护线程)。 - **错误处理**: 添加 `exceptionally()` 方法捕获异常,避免任务失败导致阻塞[^3]。 #### 4. **最佳实践和注意事项** - **线程池管理**: 默认使用 `ForkJoinPool.commonPool()`。对于高性能场景,传入自定义线程池(如 `ExecutorService`)到 `runAsync/supplyAsync`。 - **资源控制**: 避免在大型循环中创建过多 future,防止线程耗尽(OOM)。限制并行任务数(如使用 `Semaphore`)。 - **超时处理**: 使用 `orTimeout()` 或 `completeOnTimeout()` 设置任务超时。 - **组合操作**: 支持 `anyOf()` 等待任一任务完成,或 `thenCombine()` 合并结果。 通过这些方法,您可以在 for 循环中高效利用 `CompletableFuture` 优化并发性能。如果任务独立,优先并行执行;若有依赖,构建任务链;非阻塞模式适合响应式系统[^1][^2][^3]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值