大家好,又见面了。

在此前我的文章中,曾分2篇详细探讨了下JAVA中Stream流的相关操作,2篇文章收获了累计 10w+阅读、2k+点赞以及 5k+收藏的记录。能够得到众多小伙伴的认可,是技术分享过程中最开心的事情。

  • 吃透JAVA的Stream流操作,多年实践总结
  • 讲透JAVA Stream的collect用法与原理,远比你想象的更强大

不少小伙伴在评论中提出了一些的疑问或自己的独到见解,也在评论区中进行了热烈的互动讨论。梳理了下相关评论内容,针对此前文章中没有提及的一些典型讨论点拿出来聊一聊,也是作为对此前两篇Java Stream相关文章内容的补充完善。

Stream处理时列表到底循环了多少次

看下面这段Stream使用的常见场景:

Stream.of(17, 22, 35, 12, 37)        .filter(age -> age > 18)        .filter(age -> age  age + "岁")        .collect(Collectors.toList());

在这段代码里面,同时有2个 filter操作和1个 map操作以及1个 collect操作,那么这段代码执行的时候,究竟是对这个list执行了几次循环操作呢?是每一个Stream步骤都会进行一次遍历操作吗?为了验证这个问题,我们将上述代码改写一下,打印下每个步骤的结果:

        List ages = Stream.of(17,22,35,12,37)                .filter(age -> {                    System.out.println("filter1 处理:" + age);                    return age > 18;                })                .filter(age -> {                    System.out.println("filter2 处理:" + age);                    return age  {                    System.out.println("map 处理:" + age);                    return age + "岁";                })                .collect(Collectors.toList());

先执行,得到如下的执行结果。其实结果已经很明显的可以看出,stream流处理的时候,是对列表进行了一次循环,然后顺序的执行给定的stream执行语句。

按照上述输出的结果,可以看出其处理的过程可以等价于如下的常规写法:

        List ages = Arrays.asList(17,22,35,12,37);        List results = new ArrayList();        for (Integer age : ages) {            if (age > 18) {                if (age < 35) {                    results.add(age + "岁");                }            }        }        System.out.println(results);

所以,Stream并不会去遍历很多次。其实上述逻辑也符合Stream 流水线加工的整体模式,试想一下,一条流水线上分环节加工一件商品,同一件产品也不会在流水线上加工2次的吧~

Stream究竟是让代码更易读还是更难懂

Java8引入了 Lambda函数式接口Stream等新鲜内容以来,针对使用Stream或Lambda语法究竟是让代码更易懂还是更复杂的争议,一直就没有停止过。有的同学会觉得Stream语法的方式,一眼就可以看出业务逻辑本身的含义,也有一些同学认为使用了Stream之后代码的可读性降低了很多。

其实,这是个人编码模式与理念上的不同感知而已。Stream主打的就是让代码更聚焦自身逻辑,省去其余繁文缛节对代码逻辑的干扰,整体编码上会更加的简洁。但是刚接触的时候,难免会需要一定的适应期。技术总是在不断迭代、不断拥抱新技术、不去刻意排斥新技术,或许是一个更好的选项。

那么,话说回来,如何让自己能够一眼看懂Stream代码、感受到Stream的简洁之美呢?分享个人的一个经验:

  1. 先了解几个常见的Stream的api的功能含义(Stream的API封装的很优秀,很多都是字面意义就可以理解)
  2. 改变意识,聚焦纯粹的业务逻辑本身,不要在乎具体写法细节

下面举了个例子,如何用上述的2条方法,快速的让自己理解一段Stream代码表达的意思。

那么上面这段代码的含义就是,先根据员工子公司过滤所有上海公司的人员,再获取员工工资最高的那个人信息。怎么样?按照这个方法,是不是可以发现,Stream的方式,确实更加容易理解了呢~

在IDEA中debug调试Stream代码段

技术分享其实是一个双向的过程,分享的同时,也是自我学习与提升的机会,除了可以梳理发现一些自己之前忽略的知识点并加以巩固,还可以在互动的时候get到新的技能。

比如,我在此前的 Java Stream介绍的文章中,有提过基于Stream进行编码的时候会导致代码 debug调试的时候会比较困难,尤其是那种只有一行Lambda表达式的情况(因为如果代码逻辑多行编写的时候,可以在代码块内部打断点,这样其实也可以进行debug调试)。

关于这一点,很多小伙伴也有相同的感受,比如下面这个评论:

你以为这就结束了?接下来一个小伙伴的提示,“震惊”了众人!纳尼?原来Stream代码段也是可以debug单步调试的?

跟踪Stream中单步处理过程的操作入口按钮长这样:

并且,另一个小伙伴补充说这是IDEA2019.03版本开始有的功能:

嗯?难怪呢,我一直用的2019.02版本的,所以才没用上这个功能(强行给自己找了个台阶、哈哈哈)。于是,我悄悄的将自己的idea升级到了最新的2023.02版本(PS:新版本的UI挺好看,就是bug贼多)。好啦,言归正传,那么究竟应该如何利用IDEA来实现单步DEBUG呢?一一起来感受下吧。

在代码行前面添加断点的时候,如果要打断点的这行代码里面包含Stream中间方法map\filter\sort之类的)的时候,会提示让选择断点的具体类型

一共有三种类型断点可供选择:

  • Line:断点打在这一行上,不会进入到具体的Stream执行函数块中
  • Lambda:代码打在内部的lambda代码块上
  • Line and Lambda:代码走到这行或者执行这一行具体的函数块内容的时候,都会进入断点

下面这个图可以更清晰的解释清楚上述三者的区别。一般来说,我们debug的时候,更多的是关注自身的业务具体逻辑,而不会过多去关注Stream执行框架的运转逻辑,所以大部分情况下,我们选择第二个Lambda选项即可

按照上面所述,我们在代码行前面添加一个Lambda类型断点,然后debug模式启动程序执行,等到断点进入的时候便可以正常的进行debug并查看内部的处理逻辑了。

如果遇到图中这种只有一行的lambda形式代码,想要看下返回值到底是什么的,可以选中执行的片段,然后 ALT+F8打开Evaluate界面(或者右键选择 Evaluate Expression),点击 Evaludate按钮执行查看具体结果。

大部分情况下,掌握这一点,已经可以应付日常的开发过程中对Stream代码逻辑的debug诉求了。但是上述过程偏向于细节,如果需要看下整个Stream代码段整体层面的执行与数据变化过程,就需要上面提到的Stream Trace功能。要想使用该功能,断点的位置也是有讲究的,必须要将断点打在stream开流的地方,否则看不到任何内容。另外,对于一些新版本的IDEA而言,这个入口也比较隐蔽,藏在了下拉菜单中,就像下面这个样子。

我们找到Trace Current Stream Chain并点击,可以打开Stream Trace界面,这里以chain链的方式,和stream代码块逻辑对应,分步骤展示了每个stream处理环节的执行结果。比如我们以 filter环节为例,窗口中以左右视图的形式,左侧显示了原始输入的内容,右侧是经过filter处理后符合条件并保留下来的数据内容,并且还有连接线进行指引,一眼就可以看出哪些元素是被过滤舍弃了的:

不止于此,Stream Trace除了提供上述分步查看结果的能力,还支持直接显示整体的链路执行全貌。点击Stream Trace窗口左下角的 Flat Mode按钮即可切换到全貌模式,可以看到最初原始数据,如何一步步被处理并得到最终的结果。

看到这里,以后还会说Stream不好调试吗?至少我不会了。

小心Collectors.toMap出现key值重复报错

在我们常规的HashMap的 put(key,value)操作中,一般很少会关注key是否已经在map中存在,因为put方法的策略是存在会覆盖已有的数据。但是在Stream中,使用 Collectors.toMap方法来实现的时候,可能稍不留神就会踩坑。所以,有小伙伴在评论区热心的提示,在使用此方法的时候需要手动加上 mergeFunction以防止key冲突。

这个究竟是怎么回事呢?我们看下面的这段代码:

public void testCollectStopOptions() {    List ids = Arrays.asList(new Dept(17), new Dept(22), new Dept(22));    // collect成HashMap,key为id,value为Dept对象    Map collectMap = ids.stream()            .collect(Collectors.toMap(Dept::getId, dept -> dept));    System.out.println("collectMap:" + collectMap);}

执行上述代码,不出意外的话会出意外。如下结果:

Exception in thread "main" java.lang.IllegalStateException: Duplicate key Dept{id=22}at java.util.stream.Collectors.lambda$throwingMerger$0(Collectors.java:133)at java.util.HashMap.merge(HashMap.java:1254)at java.util.stream.Collectors.lambda$toMap$58(Collectors.java:1320)at java.util.stream.ReduceOps$3ReducingSink.accept(ReduceOps.java:169)at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948)at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:481)at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:471)at java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:708)at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)at java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:499)

因为在收集器进行map转换的时候,由于出现了重复的key,所以抛出异常了。 为什么会出现异常呢?为什么不是以为的覆盖呢?我们看下源码的实现逻辑:

可以看出,默认情况下如果出现重复key值,会对外抛出IllegalStateException异常。同时,我们看到,它其实也有提供重载方法,可以由使用者自行指定key值重复的时候的执行策略:

所以,我们的目标是出现重复值的时候,使用新的值覆盖已有的值而非抛出异常,那我们直接手动指定下让toMap按照我们的要求进行处理,就可以啦。改造下前面的那段代码,传入自行实现的 mergeFunction函数块,即指定下如果key重复的时候,以新一份的数据为准:

    public void testCollectStopOptions() {        List ids = Arrays.asList(new Dept(17), new Dept(22), new Dept(22));        // collect成HashMap,key为id,value为Dept对象        Map collectMap = ids.stream()                .collect(Collectors.toMap(                        Dept::getId,                        dept -> dept,                        (exist, newOne) -> newOne));        System.out.println("collectMap:" + collectMap);    }

再次执行,终于看到我们预期中的结果了:

collectMap:{17=Dept{id=17}, 22=Dept{id=22}}

By The Way,个人感觉JDK在这块的默认实现逻辑有点不合理。虽然现在默认的抛异常方式,可以强制让使用端感知并去指定自己的逻辑,但这默认逻辑与map的put操作默认逻辑不一致,也让很多人都会无辜踩坑。如果将默认值改为有则覆盖的方式,或许会更符合常理一些 —— 毕竟被广泛使用的HashMap的源码里,put操作默认就是覆盖的,不信可以看HashMap源码的实现逻辑:

慎用peek承载业务处理逻辑

peekforeach在Stream流操作中,都可以实现对元素的遍历操作。区别点在与peek属于中间方法,而foreach属于终止方法。这也就意味着peek只能作为管道中途的一个处理步骤,而没法直接执行得到结果,其后面必须还要有其它终止操作的时候才会被执行;而foreach作为无返回值的终止方法,则可以直接执行相关操作。

那么,只要有终止方法一起,peek方法就一定会被执行吗?非也看版本、看场景! 比如在 JDK1.8版本中,下面这段代码中的peek方法会正常执行,但是到了 JDK17中就会被自动优化掉而不执行peek中的逻辑:

    public void testPeekAndforeach() {        List sentences = Arrays.asList("hello world", "Jia Gou Wu Dao");        sentences.stream().peek(sentence -> System.out.println(sentence)).count();    }

至于原因,可以看下JDK17官方API文档中的描述:

因为对于 findFirstcount之类的方法,peek操作被视为与结果无关联的操作,直接被优化掉不执行了。所以说最好按照API设计时预期的场景去使用API,避免自己给自己埋坑。

我们从peek的源码的注释上可以看出,peek的推荐使用场景是用于一些调试场景,可以借助peek来将各个元素的信息打印出来,便于开发过程中的调试与问题定位分析。

我们再看下peek这个词的含义解释:

既然开发者给它起了这么个名字,似乎确实仅是为了窥视执行过程中数据的变化情况。为了避免让自己踩坑,最好按照设计者推荐的用途用法进行使用,否则即使现在没问题,也不能保证后续版本中不会出问题。

字符串拼接明明有join,那么Stream中Collectors.join存在意义是啥

在介绍Stream流的收集器时,有介绍过使用 Collectors.joining来实现多个字符串元素之间按照要求进行拼接的实现。比如将给定的一堆字符串用逗号分隔拼接起来,可以这么写:

    public void testCollectJoinStrings() {        List ids = Arrays.asList("AAA", "BBB", "CCC");        String joinResult = ids.stream().collect(Collectors.joining(","));        System.out.println(joinResult);    }

有很多同学就提出字符串元素拼接直接用 String.join就可以了,完全没必要搞这么复杂。

如果是纯字符串简单拼接的场景,确实直接String.join会更简单一些,这种情况下使用Stream进行拼接的确有些大材小用了。 但是 joining的方法优势要体现在Stream体系中,也就是与其余Stream操作可以结合起来综合处理。String.join对于简单的字符串拼接是OK的,但是如果是一个Object对象列表,要求将Object某一个字段按照指定的拼接符去拼接的时候,就力不从心了——而这就是使用 Collectors.joining的时机了。比如下面的实例:

小结

好啦,关于Java Stream相关的内容点的补充,就聊到这里啦。如果需要全面了解Java Stream的相关内容,可以看我此前分享的文档。那么,你对Java Stream是否还有哪些疑问或者自己的独特理解呢?欢迎一起交流下。

传送门:

  • 吃透JAVA的Stream流操作,多年实践总结
  • 讲透JAVA Stream的collect用法与原理,远比你想象的更强大

我是悟道,聊技术、又不仅仅聊技术~

如果觉得有用,请点赞 + 关注让我感受到您的支持。也可以关注下我的公众号【架构悟道】,获取更及时的更新。

期待与你一起探讨,一起成长为更好的自己。

本文来自博客园,作者:架构悟道,欢迎关注公众号[架构悟道]持续获取更多干货,转载请注明原文链接:https://www.cnblogs.com/softwarearch/p/17698919.html