Stream和CompletableFuture结合起来引发的问题

问题的产生

最近在弄着一个新的系统,有个接口耗时比较久,要针对它进行优化,优化嘛,多线程先上,多线程不行再上缓存。于是乎,很快啊,我就啪啪啪的写完了多线程的代码了,但是运行后发现,也没快多少啊。

java 复制代码
List<Object> list;
Executor executor;
list.stream()
    .filter(v -> v.isEnable())
    .map(v -> CompletableFuture.runAsync(v::dosomething, executor))
    .forEach(CompletableFuture::join);

写完之后,丢到流水线部署,成功之后,再次调用接口发现,时间没啥变化,甚至还久了一些,不应该啊。

问题定位

控制变量法

加了多线程后居然还会有这样的结果,我自己也很惊讶,那个时候我还觉得是CompletableFuture的问题,然后将代码改成了这样:

java 复制代码
List<Object> list;
Executor executor;
list.stream()
    .filter(v -> v.isEnable())
    .forEach(v -> executor.execute(v::dosomething);

push上去然后部署代码,一试,时间快了很多,就是我想要的优化效果。但是吧,这样的写法并不能保证异步线程的任务都执行完毕,所以还是改回用CompletableFuture抛弃Stream

java 复制代码
List<Object> list;
Executor executor;
List<CompletableFuture<Void>> futureList = new ArrayList<>();
list.removeIf(v -> !v.isEnable());
for (Object v : list) {
    futureList.add(CompletableFuture.runAsync(v::dosomething, executor));
}
futureList.forEach(CompletableFuture::join);

这样的代码运行效果跟上面的一样的同时也能阻塞主线程等待每个任务的完成结果,就是我想要的效果。

接口耗时问题解决了,但是新的问题又来了,为什么CompletableFutureStream同时使用会有这个问题呢,还是其他原因导致的?

为了研究问题究竟出现在哪,我用CompletableFutureStream写了针对这个案例写了多种搭配的用法。

不同写法的基准测试

Benchmark仓库:github.com/AzirZsk/Str...

总共写了5种写法:

序号 方法名称 代码逻辑
1 normal For循环
2 executor For循环+线程池执行
3 normalCompletableFuture For循环+CompletableFuture
4 streamCompletableFuture Stream+CompletableFuture
5 streamForEach Stream的ForEach

测试结果:

从测试结果能看出,For循环 的写法和Stream+CompletableFuture的写法耗时是差不多的,可以近似理解成,这两个写法是完全相等的。

为什么会这样

为什么这两个写法的运行耗时差不多呢?我先来说说我对于Stream内部的处理逻辑的理解。

在我认知中,Stream的操作应该是和普通的ForEach是差不多的,一行代码执行完后,再执行下一行。

比如用下面的代码来演示一下:

java 复制代码
Set<String> set = list.stream()
    .filter(v -> v.isEnable())
    .map(v -> {
        String str = v.toString();
        return str + "123123";
    })
    .collect(Collectors.toSet());

按我的想法,执行步骤应该是这样的:

  1. 先把list中的元素都进行状态的过滤
  2. 全部过滤后,执行toString()方法后,拼接123123
  3. 将前两步操作的得到的结果汇总成一个Set对象。

说白了就是每一个方法就是在这个阶段处理完成所有元素后再将处理得到的结果传递给下一个操作,可以理解为跟下面的代码是等价的:

java 复制代码
// 先过滤元素 => stream的filter方法
list.removeIf(v -> v.isEnable());
// 处理逻辑 => stream的map、collect方法
Set<Strign> set = new HashSet<>();
for (Object v : list) {
    set.add(v.toString() + "123123");
}

那为什么实际运行情况跟我设想中的不太一样呢?经过我仔细阅读源码后 DeepSeek 后,发现事实并非如此,在Stream实际运行中,只有执行到collectforEach终端方法时,才会给每个元素执行流上的操作。

说人话就是,在Stream中,每个元素都是按顺序一个一个 的执行全部逻辑 ,并非统一执行完一个 逻辑后,再统一执行下个逻辑。

例如上面的Stream代码,实际执行步骤则是:

  1. 第一个元素按照顺序分别执行Stream中的filter、map、collect方法。
  2. 第二个元素重复执行第一个步骤,直到全部元素都执行完毕。

那么,对于我第一版优化的代码来说,在Stream中的实际操作 则是先利用CompletableFuture异步执行了方法后,马上就调用join方法等待执行结果。实际上,这样子的写法跟单线程是没有区别的。若是非得使用StreamCompletableFuture的话,就得写成这样:

java 复制代码
List<Object> list;
Executor executor;
List<CompletableFuture<Void>> futureList = list.stream()
    .map(v -> CompletableFuture.runAsync(v::dosomething, executor))
    .collect(Collectors.toList());
futureList.stream()
    .forEach(CompletableFuture::join);

得分别调用两次Stream操作,才能同时使用CompletableFuture和Stream。

后记

回到这个问题,要是没有这一次问题的出现,我这辈子对于Stream流都是停留在之前的印象当中了。

说到这里,可能有人会问了,为什么不用parallelStream呢?因为业务中要用到线程间的变量,需要指定对应的线程池(在线程池中做了变量传递),而且默认的线程池能不用就不用,所以还是


最后附上一下优化前后的耗时对比,多线程果然是利器!

优化前

优化后

相关推荐
Lei活在当下3 分钟前
先用起来,再理解,关于协程Coroutine应该知道的事
android·java·jvm
Java爱好狂.20 分钟前
Java程序员体系化学习路线(2026最新版)
java·后端·java面试·java架构师·java程序员·java八股文·java学习路线
陈随易39 分钟前
Redis 8.8发布,一定要更新
前端·后端·程序员
tongluowan0071 小时前
以ReentrantLock为例解释AQS的工作流程
java·模板方法模式·aqs·reentrantlock
装不满的克莱因瓶1 小时前
SpringBoot 如何将 lib 目录中jar包打包进最终的jar包里面
spring boot·后端·maven·jar·mvn
ltl2 小时前
Transformer 原论文实验结果:为什么 28.4 BLEU 足以改写路线图
后端
身如柳絮随风扬2 小时前
Java 项目打包与部署完全指南:JAR vs WAR,从构建到运行
java·firefox·jar
云烟成雨TD2 小时前
Spring AI Alibaba 1.x 系列【62】时光旅行(Time-Travel)
java·人工智能·spring
excel2 小时前
为什么我推荐使用 Termius:现代 SSH 工具的完整体验
前端·后端
浩少7023 小时前
【无标题】
java·开发语言