详解Java 8中Stream类型的“懒”操作

在进入正题之前,我们需要先引入Java 8中Stream类型的两个很重要的操作:

中间和终结操作(Intermediate and Terminal Operation)

Stream类型有两种类型的方法:

  • 中间操作(Intermediate Operation)
  • 终结操作(Terminal Operation)

官方文档给出的描述为[不想看字母的请直接跳过]:

Stream operations are divided into intermediate and terminal operations, and are combined to form stream pipelines. A stream pipeline consists of a source (such as a Collection, an array, a generator function, or an I/O channel); followed by zero or more intermediate operations such as Stream.filter or Stream.map; and a terminal operation such as Stream.forEach or Stream.reduce.

Intermediate operations return a new stream. They are always lazy; executing an intermediate operation such as filter() does not actually perform any filtering, but instead creates a new stream that, when traversed, contains the elements of the initial stream that match the given predicate. Traversal of the pipeline source does not begin until the terminal operation of the pipeline is executed.

Terminal operations, such as Stream.forEach or IntStream.sum, may traverse the stream to produce a result or a side-effect. After the terminal operation is performed, the stream pipeline is considered consumed, and can no longer be used; if you need to traverse the same data source again, you must return to the data source to get a new stream. In almost all cases, terminal operations are eager, completing their traversal of the data source and processing of the pipeline before returning. Only the terminal operations iterator() and spliterator() are not; these are provided as an "escape hatch" to enable arbitrary client-controlled pipeline traversals in the event that the existing operations are not sufficient to the task.

Processing streams lazily allows for significant efficiencies; in a pipeline such as the filter-map-sum example above, filtering, mapping, and summing can be fused into a single pass on the data, with minimal intermediate state. Laziness also allows avoiding examining all the data when it is not necessary; for operations such as "find the first string longer than 1000 characters", it is only necessary to examine just enough strings to find one that has the desired characteristics without examining all of the strings available from the source. (This behavior becomes even more important when the input stream is infinite and not merely large.)

其实看完这个官方文档,撸主整个人是很蒙圈的,给大家讲讲官方文档这段话到底说了些什么:

第一段:流操作分为中间操作和终结操作(我就这么翻译了啊),这两种操作外加数据源就构成了所谓的pipeline,处理管道。

第二段:说中间操作会返回一个流;中间操作是懒的(lazy,究竟怎么个懒法,我们后面会讲到);还拿filter举了个例子说,执行中间操作filter的时候实际上并没有进行任何的过滤操作,而是创建了一个新的流,这个新流包含啥呢?包含的是在遍历原来流(initial stream)过程中符合筛选条件的元素(很奇怪哎,这不明显是一个过滤操作吗?怎么说没有呢);要注意的是:中间操作在pipeline执行到终结操作之前是不会开始执行的(这将在我们后面的内容中讲到);

第三段:人家说了,终结操作是eager的,也就是说,执行到终结操作的时候我就要开始遍历数据源并且执行中间操作这个过程了,不会再去等谁了。而且一旦pipeline中的终结操作完成了,那么这个pipeline的使命就完成了,如果你还有新的终结操作,那么对不起,这个旧的pipeline就用不了了,你得新建一个stream,然后在造一遍轮子。这里有一句话我实在没弄明白什么意思啊,"

Only the terminal operations iterator() and spliterator() are not; these are provided as an "escape hatch" to enable arbitrary client-controlled pipeline traversals in the event that the existing operations are not sufficient to the task.

",还希望道友们帮忙解释一下,感激不尽!

第四段:夸了一下stream“懒”执行的好处:效率高。将中间操作融合在一起,使操作对对象的状态改变最小化;而且还能使我们避免一些没必要的工作,给了个例子:在一堆字符串里要找出第一个含超过1000个字符的字符串,通过stream operation的laziness那么我们就不用遍历全部元素了,只需执行能找出满足条件的元素的操作就行(其实这个需求不通过stream pipeline也能做到不是吗?);其实最重要的还是当面对一个无限数据源的操作时,它的不可替代性才体现了出来,因为经典java中collection是finite的,当然这个不是我们今天的目标,这里就不拓展开讲了。

愿文档后面还有一点内容,讲了中间操作有的是持有状态的(stateful),有的是无状态的(stateless),他们在对原数据的遍历上也有一些不同感兴趣的同学可自己去研究研究,我们今天主要还是看看中间操作是怎么个“懒”法以及这个“懒”的过程是怎么样的。

Stream之所以“懒”的秘密也在于每次在使用Stream时,都会连接多个中间操作,并在最后附上一个结束操作。 像map()和filter()这样的方法是中间操作,在调用它们时,会立即返回另一个Stream对象。而对于reduce()及findFirst()这样的方法,它们是终结操作,在调用它们时才会执行真正的操作来获取需要的值。

从一个例子出发:

比如,当我们需要打印出第一个长度为3的大写名字时:

public class LazyStreams {
    private static int length(final String name) {
        System.out.println("getting length for " + name);
        return name.length();
    }
    private static String toUpper(final String name ) {
        System.out.println("converting to uppercase: " + name);
        return name.toUpperCase();
    }
    public static void main(final String[] args) {
        List<String> names = Arrays.asList("Brad", "Kate", "Kim", "Jack", "Joe", "Mike", "Susan", "George", "Robert", "Julia", "Parker", "Benson");

        final String firstNameWith3Letters = names.stream()
            .filter(name -> length(name) == 3)
            .map(name -> toUpper(name))
            .findFirst()
            .get();

        System.out.println(firstNameWith3Letters);
    }
}

你可能认为以上的代码会对names集合进行很多操作,比如首先遍历一次集合得到长度为3的所有名字,再遍历一次filter得到的集合,将名字转换为大写。最后再从大写名字的集合中找到第一个并返回。这也是经典情况下Java Eager处理的角度。此时的处理顺序是这样的

对于Stream操作,更好的代码阅读顺序是从右到左,或者从下到上。每一个操作都只会做到恰到好处。如果以Eager的视角来阅读上述代码,它也许会执行15步操作:

 

 

可是实际情况并不是这样,不要忘了Stream可是非常“懒”的,它不会执行任何多余的操作。实际上,只有当findFirst方法被调用时,filter和map方法才会被真正触发。而filter也不会一口气对整个集合实现过滤,它会一个个的过滤,如果发现了符合条件的元素,会将该元素置入到下一个中间操作,也就是map方法中。所以实际的情况是这样的:

\

 

控制台的输出是这样的:

getting length for Brad
getting length for Kate
getting length for Kim
converting to uppercase: Kim
KIM

为了更好理解上述过程,我们将Lambda表达式换为经典的Java写法,即匿名内部类的形式:

final String firstNameWith3Letters = names.stream()
            .filter(new Predicate<String>{
                public boolean test(String name){
                    return length(name)==3;
                }
             })
            .map(new Function<String,String>{
                public String apply(String name){
                    return toUpper(name);
                }
            })
            .findFirst()
            .get();  

执行的见下图:

 

很容易得出之前的结论:只有当findFirst方法被调用时,filter和map方法才会被真正触发。而filter也不会一口气对整个集合实现过滤,它会一个个的过滤,如果发现了符合条件的元素,会将该元素置入到下一个中间操作,也就是map方法中。

当终结操作获得了它需要的答案时,整个计算过程就结束了。如果没有获得到答案,那么它会要求中间操作对更多的集合元素进行计算,直到找到答案或者整个集合被处理完毕。

JDK会将所有的中间操作合并成一个,这个过程被称为熔断操作(Fusing Operation)。因此,在最坏的情况下(即集合中没有符合要求的元素),集合也只会被遍历一次,而不会像我们想象的那样执行了多次遍历,也许这就回答了官方文档中为什么说"Processing streams lazily allows for significant efficiencies"了。

为了看清楚在底层发生的事情,我们可以将以上对Stream的操作按照类型进行分割:

Stream<String> namesWith3Letters = names.stream()
    .filter(name -> length(name) == 3)
    .map(name -> toUpper(name));

System.out.println("Stream created, filtered, mapped...");
System.out.println("ready to call findFirst...");

final String firstNameWith3Letters = namesWith3Letters.findFirst().get();

System.out.println(firstNameWith3Letters);
// 输出结果 // Stream created, filtered, mapped... // ready to call findFirst... // getting length for Brad // getting length for Kate // getting length for Kim // converting to uppercase: Kim // KIM 

根据输出的结果,我们可以发现在声明了Strema对象上的中间操作之后,中间操作并没有被执行。只有当真正发生了findFirst()调用之后,才会执行中间操作。

参考资料:

撸主比较懒,上文中的例子和前两张图来自于 CSDN 博主 dm_vincent 的博客《 [Java 8] (7) 利用Stream类型的"懒"操作 》

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值