028-86261949

当前位置:首页 > 技术交流 > Java 8 stream包初步认识

Java 8 stream包初步认识

2018/08/02 18:54 分类: 技术交流 浏览:136

是什么?

这肯定是一个我们最想先了解的问题.
java.util.stream包是Java 8的新特性之一,虽然名字乍一看好像和io包中的InputStream,OutputStream等很类似,但作用是完全不同的.它的作用是专注于Collection对象进行各种非常便利,高效的聚合操作或者大批量数据操作,支持同为Java 8新特性的Lambda表达式(可参见另外两篇Lambda的技术分享文章)以及方法引用(后面使用时一起介绍),极大的提高编程的效率和程序可读性.并且提供串行执行和并行执行两种方式进行集合对象的处理,底层采用了Java 7中引入的fork/join框架.这意味着我们不用去写一行多线程代码,就可充分利用多核的性能处理集合对象.而多线程代码的编写,恰恰是不容易把握的,需要我们十分谨慎才不至于弄巧成拙,Stream的出现无疑是一个好事.
简而言之,两个字,厉害.
 
说了一些概念上的东西,想必有些”太长不看”的朋友会对代码更感兴趣,那么我们来写一点代码,作为Stream的简单示例:
假设我们有一个集合,集合中的元素是订单(Order)对象,每个订单包含一个金额(amount)以及一个性别(gender),需求是需要把集合中每个性别为男(true)的并且金额大于50000的订单按照金额从小到大排序,并将最终结果打印出来,按照Java 7的for循环写法,大致应该是这样:
(for循环写法太长以至于直接在IDEA中截图都不方便截完,所以放到了笔记中再次截图.)
而如果我们使用Java 8的Stream呢,大概应该是这样写:
上图的示例中,我们使用到了Stream API,使用到了Lambda表达式,同时也使用到了方法引用.有没有感觉清爽了很多,没有那么多层作用域,代码量与可读性都大大提高.
当然,我们也可以直接从数据库中使用各种函数及条件,将数据过滤再返回,但这个就是数据库层面的操作,我们在此处只区别Java代码中的区别,就不要在意这些细节了.
通过for循环方式的代码可以看到,我们先遍历了一次集合,将符合gender为true且amount>50000的订单对象取出,放入到一个新的集合中,然后再对集合排序,虽然Java 8已经对底层进行了优化,但排序仍然是再次遍历了一遍,所以是遍历了两遍.
而stream难道说有什么不同吗?是的,stream只遍历了一次.虽然我们调用了filter方法,调用了sorted方法,最终调用collect方法生成了一个List,但并不是每调用一个方法就会立即执行操作.这里我们引入两个概念:
l Intermediate(中间操作):一个流可以后面跟随零个或多个 intermediate 操作。其目的主要是打开流,做出某种程度的数据映射/过滤,然后返回一个新的流,交给下一个操作使用。这类操作都是惰性化的(lazy),就是说,仅仅调用到这类方法,并没有真正开始流的遍历。调用中间操作只会返回一个新的Stream对象.
l Terminal(终端操作):一个流只能有一个 terminal 操作,当这个操作执行后,流就被消耗掉,无法再被操作。所以这必定是流的最后一个操作。Terminal 操作的执行,才会真正开始流的遍历,并且会生成一个结果,或者一个 side effect。Side effect在这里可以理解为我们操作引用对象所造成的结果,虽然没有返回新的值,但被操作的引用对象可能已经被修改了.调用终端返回的是非Stream对象或者无返回值.

 

是不是已经感觉到了Stream API并不仅仅只是对for循环的简单加强,而是从底层换了另外一种方式去实现.这意味着我们加上再多的Intermediate操作,也只会在执行Terminal操作的时候才开始遍历,这样比传统的for循环在性能上提升了太多.
 

怎么用?

我们需要先明确一件事,那就是stream操作并不是直接操作集合,而是将集合中的元素复制到了一个流中,对流的操作不会影响到原来的集合.
对于如何使用,我们第一步应该是获取到一个Stream对象,然后再根据文档来操作Stream对象.一般来说,最常用的一种获取方式就是如上面示例的方式,调用集合对象的stream方法.
对于数组,我们可以使用Arrays.stream(数组)的方式,将需要获得流的数组传入,得到一个流.
以上两种方法对我们的基本使用来说已经足够.
这里我们说一说方法引用,简单理解来说,就是把方法当做参数传入,然后我们对流中的每一个元素都执行我们传入的这个方法.下面的代码部分可能会用到Lambda表达式以及方法引用.
接下来我们就需要根据需求去对数据进行处理.
常用的Intermediate操作有:
map,filter,distinct,sorted,peek,limit,skip,parallel
从方法名称来看我们也大致可以知道其作用,直接通过代码示例来看怎么使用吧.

1. Map

将上游流中的元素,按照给定的逻辑进行处理,返回另一种类型的元素,并且生成一个新的流,交给后面的操作处理.
可以看到示例中,我们调用了map操作,并且通过方法引用的形式传入了一个参数,最终生成了一个Stream<Integer>对象.在上面代码中,我们从randomList中获取了一个流,然后对流中的每一个元素执行了getAmount方法,得到了一个Integer对象的流.这只是一个简单的使用,同样我们也可以执行一些比较复杂的操作,比如Order中有name字段,我们把name作为key,amount作为value,将每个Order对象转换为一个Map对象,并生成一个Map的流,这些都是可以的,大家可以自己尝试一下.
map有一些特殊方法.正如我们所知,装箱拆箱实际上是比较消耗性能的,所以Stream提供了mapToInt,mapToLong,mapToDouble三种方法,用于操作int,long和double,以便于后面的操作能够在性能上得到优化.
map也有一个flatMap方法,可以将流中的集合或数组扁平化,例如二维数组,按照一般的map我们得到的流中的元素就是一个一个的数组,这可能不怎么符合我们一些场景的期望.当我们需要获得确切的每一个最底层元素时,我们可以使用flatMap,将原先的结构扁平化.代码示例如下:
我们创建了3个String数组,然后将3个String数组又放入到了一个数组中,由此我们得到了一个二维数组.
接下来我们获得了二维数组的流,通过flatMap方法,将每一个流中的元素执行了获取流的操作,而获取到的就是元素为String的流.最终将二维数组的流转换成为了一个其底层元素的流.到这里我们再简单的调用一个collect方法,就可以生成一个集合.
同样的,flatMap也有三个用于int,long和double的方法,即为flatMapToInt,flatMapToLong和flatMapToDouble.

2. Filter

见名知意,用于将上游流中的元素按照我们给定规则进行过滤的操作.
在最上面的示例中,我们以Lambda表达式的形式传入了我们所指定的过滤规则
刚才演示map操作的时候,都使用的是方法引用的方式,这里因为判断条件需要调用两个方法,将两个布尔值(布尔表达式)组合为一个布尔表达式,所以我们用到了Lambda表达式.

 

怎么理解这个表达式呢?
我们表达式中在箭头符号左边写了一个e,这个e是我们自己定义的一个变量,改成别的什么其实没差别,它指代的是我们流中的每一个当前元素,可以直观的理解为我们在遍历这个集合,而每个当前元素就是我们的e,然后返回的值就是箭头符号右边的语句的结果.这么一来我们这个filter的规则就是:”将每一个流中的元素,判断其gender值是否为true且amount大于50000”.filter操作期待的返回值是一个布尔值,为true则表明当前元素符合规则,放入下游流,如果为false则不符合,继续判断下一个.
当然,我这里所写的Lambda表达式只是最简单的一种情况,其他的情况可以参见另外两篇文章详细了解.

3. Distinct

   眼熟吗?是的,就是同一个意思,去重.
这个操作不需要参数,根据Object.equals方法进行判断.使用起来很简单,所以就不额外写代码演示了,大家可以自己尝试一下.

4. Sorted

上面我们示例中也是用到了的,其实它重载了一个无参的sorted方法,无参的就是按照自然排序来将流中的元素进行排序,但这只能用于实现了Comparable接口的元素.同样我们可以用上图示例中的方式,传入一个自定义的Comparator来实现排序,而我们所用的方式是Java 8新增的一种方式,简单理解来说就是根据我们传入的方法(得到用于确定排序依据的字段),得到一个Comparator.
上图即拆开写的排序,可以看到实际上我们sorted方法接收的就是一个Comparator

5. Limit/skip

limit只能传入一个参数,即指定最多返回多少个元素.
而skip传入一个参数,则表示跳过前面多少个元素.
这两个使用起来也是很简单的,也就不写代码做演示了.

6. Peek

这个操作在我第一次看到的时候是觉得难以理解的,主要在于它的使用方法以及返回都和map差不多,对每一个传入的元素进行操作,然后得到一个流.但后面了解了forEach这个Terminal操作之后,发现它其实功能与forEach很像,但差别就在于peek处理之后还会生成一个与上游流元素类型相同的下游流,而forEach作为一个Terminal操作,会将流消耗掉.
看看源码注释中的示例:
除了获得流的方式不同以外,其余的操作也基本是前面讲到过的,在这个示例中,peek用于将每个元素进行打印,当然我们也可以对每个元素做一些重新赋值之类的操作.

7. Parallel

这个方法没有任何参数,用处也就在于我们前面提到过的,串行与并行.我们想要获得一个并行流,要么在创建的时候调用集合的parallelStream获取,要么就直接获取一个stream然后使用parallel操作,将流转为并行流.否则我们获得的都是默认的串行流,而串行与并行的区别可以看最后的stream的性能介绍.

 

常用的Intermediate操作就是上面所介绍的这些,还有一些其他的可以自行去查找资料了解.接下来我们来看看有什么常用的Terminal操作.


1. forEach

这个名字也是很直观了,跟我们之前常用的foreach差不多,就是遍历.这个foreach直接使用的话,其实和for循环没什么区别,并不神奇.只是它的参数可以支持Lambda表达式以及方法引用.
这句代码的效果其实等同于:
这么一看是不是对方法引用也有了更多的认识?
forEach还有个兄弟,forEachOrdered,名字上多了个ordered,意思就是按照顺序进行遍历,用法没什么区别.

2. toArray

这个方法应该是老方法了,可以直接不传参使用,得到一个Object数组,或是传入一个指定类型的数组,得到该类型的数组,就不赘述了.

3. Reduce

reduce这个方法,中文意思大概可以理解为”聚合”.
这个方法相比其他方法要来得复杂一些,没有那么清新.我们看看源码中这个方法的声明:
看不懂.
看不懂.
更加看不懂了.
那么我们直接看看怎么用的,先用起来再慢慢理解它的含义.
按照第一个方法的传值,我们可以这么用:
我们先通过mapToLong将每个Order对象中的amount拿到,然后通过reduce方法去获得了amount的总和.
可以看到,reduce方法中我们实际上传入了两个参数,一个是0,一个是Lambda表达式(e1,e2)->e1+e2.
第一个参数0可以看做我们求总和时所写的int sum = 0,作为初始值,而后面Lambda表达式中的e1,可以看做是这个reduce方法上一次操作的结果,上一次的e1+e2结果就是这一次的e1,而e2就是当前的元素.而对于第一个元素,可以理解为它要执行操作,但没有可用的e1,就直接跳过第一次操作,把自己作为了第二次操作的e1,执行第二次操作.
这是有初始值的情况,我们再来看看第二个:
这里我直接把初始值0去掉了,然后最终得到的结果就不再是一个long了,而是一个OptionalLong.
这个OptionalLong是什么东西?其实它也是参照Optional而编写的一个工具类,只是Optional可以用于所有类,而OptionalLong只专注于Long类型.而Optional它是由Google的Guava工程得来,在Java 8中新增到java.util包中,用于解决空指针异常.在这里不多加赘述,感兴趣可以自己查阅资料了解更多,我们在这里只根据我们所用到的情况来简单说一下.
这个OptionalLong,是对结果值的一个封装,我们可以直接调用getAsLong方法来获取到实际的结果值,但这样可能因为没有值而得到一个异常,所以我们换一种方式来获取,避免没有值而报错
有值就返回实际结果值,没有就返回我传入的值,这样就节约了我们写if去判断是否有值,然后再在没值的情况下写逻辑的过程.
为什么会是OptionalLong?因为不同于第一个方法,我们没有传入一个初始值,那么万一每个元素都是null的话,最终得到的值也是null.而第一个方法无论如何也会有个我们传入的初始值作为保底.
然后就到第三个了,最难理解的一个.
这个方法需要区分两个场景,串行和并行,当串行时第三个参数是不会生效的.
串行时:
对于这个我已经不忍心去改为Lambda表达式了,因为改完是这样的:
好了,忘掉你看到的东西吧,我们还是以前面的图为例分析一下.
我们把整个reduce的参数简化来看,就是一个orders,一个BiFunction对象,一个BinaryOperator对象.在串行时我们用不到第三个对象,所以new出来覆写的时候随便写写吧.
第一个orders,就是初始值.但和我们第一个方法不同,它居然是个List,而不是相同的Long.
没错,第三种写法可以用任意类型的初始值,而使用的方法也就在第二个参数中实现.在上面的例子中,我们用一个List作为初始值,在new BiFunction对象覆写其apply方法时,有两个形参,一个是List,一个是Order.在执行时,list就是我们前面传入的初始值list,而order就是遍历的当前元素,在这里我们只是简单的将order放入了list中,你也可以根据自己的需求做其他的操作.如果初始值是同类型的,那写法就跟我们第一种写法一样了.所以,第二个参数就是负责我们怎么将流中元素与传入的初始值进行处理.
并行时:

 

我对第三个参数的apply方法逻辑进行了修改,将传入的orders2集合所有元素完全放入orders集合中.
刚才我们说到,第三个参数在并行时才有用,是因为并行时有多个线程,每个线程都会有一个List<Order>,最终我们需要将所有的list合并才能得到完整的结果,所以第三个参数的作用就是这个.
有一个点需要注意,这种操作可能会出现一个问题,就是我们初始值如果不是一个空集合,每个并行线程都会在此基础上继续放入Order对象,那最终合并后我们拿到的结果集合就会有多个初始值中的元素.

4. Collect

collect方法就是将流最终生成一个集合或者Map,将流中的元素”收集”起来.在最上面的示例中,我们也用到了collect方法作为Terminal操作.
collect有两种,一种接收3个参数,supplier,accumulator,combiner.还有一种是接收一个Collector对象.我们先来看看这个3参数的collect方法:
还是先贴完整写法的代码:
这个完整写法其实和reduce的第三个方法有点相似的,我们来细看一下.
第一个参数,new 了一个Supplier,泛型为HashSet<Order>.Supplier意思是供应商,而我覆写了其中的get逻辑,new 了一个HashSet并返回出去.这其实就是我们提供了一个”获取初始值”的对象,和reduce相似却又不完全相同,都是需要初始值,但reduce是直接提供,这里是提供一个生成初始值的对象.
第二个参数和第三个参数,我们按照reduce第三个方法的思路来猜测一下,首先第二个参数,我new 了一个BiConsumer,起手感觉就不太好,类型都跟reduce第二个参数不一样啊.不慌,看下覆写的方法是什么参数.accept方法,接收一个HashSet,一个Order,那这个HashSet应该就是通过我们提供的Supplier生成的初始值了,然后后面的order对象应该是我们流中的元素.
第二个参数提供的方法,与reduce一样,会被重复调用直至流中的元素全部被消耗.
第三个,类型和第二个参数一样,但接收了两个HashSet,联想到reduce,这是用于并行处理时,最终合并结果.
当然,此处我们使用的是HashSet作为示例,其他的集合也是可以使用的.
然后还是贴一下使用方法引用的写法,多做比较对理解也有好处:
其实与上面的完整写法一比较,方法引用的写法也就没有那么难看懂了.
再说一下collect传Collector对象的方法,这个方法没什么多说的,但内容基本就在于生成Collector对象的Collectors类上.我们先看一看Collectors有哪些方法可以用于生成Collector:
以上是用于生成集合,或者groupingBy生成Map对象.也有一些其他的方法,例如:
可以通过传入的字符将所有结果的toString值拼接起来,这个用法相信大家也是比较熟悉的.
以上分别是计数,最小值,最大值,Int求和,long求和,double求和.
可见Collectors这个类中提供了很多功能,我们在这里也就不一一细讲,有兴趣可以下来自己了解.
那么我们如何通过Collectors去改造我们前面的复杂写法呢?
就这样,就可以根据我们所选择生成的Collector来得到最终的”收集”结果了.当然,本身要从List转Set不用这么写,这只是为了举个例子,不要在意这些细节.

5. min/max/count

这些操作是干嘛的想必不用多说了吧.使用也是常规操作,min/max传入一个Comparator,count不需要参数.

6. anyMatch/allMatch/noneMatch/findFirst/findAny

要把他们放一起说,是因为他们有一点点特殊.我们都知道”&&”还有”||”运算符,他们都有短路的效果,以上方法也是同样的,一旦符合短路条件就不会遍历后面的元素.
anyMatch:任一符合给定判断条件就短路,返回一个true,
allMatch:全部符合才返回一个true,一旦有一个不符合就短路
noneMatch:全部不符合才返回一个true,一旦有一个符合就短路
findFirst:找到符合条件的第一个元素就短路,马上返回
findAny:找到符合条件的任意一个就短路,马上返回.


 

好了,至此Stream中的常用方法也大致介绍了一下.接下来是关于Stream性能的讨论.

stream的性能

在我最开始查找资料了解Stream性能的时候,看到一个文章
看到标题的时候心里是咯噔一下的,用起来这么爽(这么装逼)的一个东西,居然性能比for-loop差了不止一点半点,而是相差5倍.
然后那几天是一点都不开心的,直到下面的评论多起来,以及gitHub上面一个更为可靠的测试出现,客观的评价了stream的性能.
的确,stream并不是无脑高性能,但也不至于慢5倍.
先贴上gitHub的帖子地址:(中文的,不要怕)
当然,对于”太长不看”的朋友,我这里大致总结一下:
1. 对于简单操作,例如简单遍历,for-loop的性能高于串行stream,但并行stream会因为CPU核数提高而性能也随之提高.
2. 对于复杂操作,例如map,filter一系列的操作,串行stream可以和高质量的for-loop性能相当,而此时并行stream因为CPU核数以及数据量的提高而性能碾压for-loop
3. 不建议在单核环境下使用并行stream.
4. 使用stream可以在底层代码有所优化时,我们无须作出任何调整,这也是”依赖接口而不依赖实现”的优势所在
5. 尽量消除掉自动装拆箱,这样在后续的大量操作中能够节省不少的时间.而消除可以通过mapToInt,mapToLong和mapToDouble这些操作来实现.


 

好了,以上就是对stream API的简单分享,希望这篇分享能够在
大家对stream这个Java 8新特性的学习理解上有所帮助!
 
 
 
   感谢源码时代教学讲师提供此文章!
   本文为原创文章,转载请注明出处!
 
#标签:Java 8 stream,Java,源码时代