Stream常用操作以及原理探索

本文介绍了Java8中的Stream API,包括Stream的定义、使用方法、操作分类以及常用操作。强调了Stream的声明式处理数据、与for循环的对比、惰性计算和与Collection的区别。文章还探讨了Stream的并行处理优势,并分析了Stream的内部原理,如操作如何记录、叠加、执行以及操作结果的存储位置。最后,讨论了Stream与函数式编程的关系,以及Stream在处理集合数据时的效率问题。
摘要由CSDN通过智能技术生成

Stream常用操作以及原理


Stream是什么?

Stream是一个高级迭代器,它不是数据结构,不能存储数据。它可以用来实现内部迭代,内部迭代相比平常的外部迭代,它可以实现并行求值(高效,外部迭代要自己定义线程池实现多线程来实现高效处理)、惰性求值(中没有终止操作,中间操作是不会执行的)、短路操作(拿到正确的结果就返回,不需要等到整个过程完成之后)等

  • Stream翻译过来的意思就是“溪流,流”的意思,而我们刚开始学习java的时候接触最多的就是IO流,它更像“农夫山泉”,“我们只做大自然的搬运工”,只是将一个文件从这个地方传到另一个地方,对于文件当中内容不做任何增删改操作,而Stream就会,也就是将要处理的数据当作流,在管道中进行传输,并在管道中的每个节点对数据进行处理,如过滤、排序、转换等;

  • 通常我们需要处理的数据是以Collection、Array等数据来源;

  • Stream它是Java8中的一个新特性,那关于Java8中的其他新特性内容可以参考这篇文章《Java8新特性实战》

  • 那既然是Java8的新特性,而且我们也知道Java8大改动之一的就是增加了函数式编程,而Stream就主角,那有关函数式编程是什么,可以参考知乎上的一篇文章《什么是函数式编程?》

  • 既然是函数式编程,所以通常是配合Lambda表达式使用;


Stream怎么用?

所有操作分类

首先Stream的所有操作可分为两类,一是中间操作二是终止操作

中间操作:中间操作只是一种标记,只有结束操作才会触发实际计算

  • 无状态:指元素的处理不受前面元素的影响;
  • 有状态:有状态的中间操作必须等到所有元素处理之后才知道最终结果,比如排序是有状态操作,在读取所有元素之前并不能确定排序结果。

终止操作:顾名思义,就是得出最后计算结果的操作

  • 短路操作:指不用处理全部元素就可以返回结果;
  • 非短路操作:指必须处理所有元素才能得到最终结果。

此外这里我看到有的地方将collect定义为了中间操作,但通过我看了大部分对Stream的介绍,发现Collect这个收集操作是最终止操作,毕竟这也符合我们平时所用到它的场景,所以还请加以辨别有的文章中提到的collect是中间操作的错误解释。


常用操作

以下两张图是对stream的常用操作做了一个简单使用案例,原本流程图在这Java8新特性

image-20220624190633854

image-20220624190537640

那至于常用操作这块,本次博客也不在进行过多的细说,因为网上有很多这种使用类型的文章,我常看的有这三篇文章:


为什么使用Stream?

声明式处理数据

第一个原因我觉得是Stream流可以以声明式的方式去处理数据,也就是像它其中就有filter、sort这种以及写好的操作,只需要拿来使用即可,如果我们平时使用for循环,还要在for循环中自己去写怎么过滤的这些操作,最后才得出自己想要的结果,对比这种命令式的操作

可以说让我们代码更加干净、简洁。

对比for循环

对于与for循环效率的对比,我觉得和以下内容差不多,但搜寻网上资料来证明某一观点正确的我目前没有找到,很多人持有观点就是“牺牲代码效率来换取代码简洁度”,“Stream的优势在于有并行处理”,“Stream的效率与for差不多,为了代码简洁更偏向Stream”等。

但是牺牲代码效率换代码简洁度我觉得还是有问题的,不能一概而论。但是函数式编程的优点就是代码简洁,多核友好并行处理这是不可否认的。

  • 针对不同的数据结构,Stream流的执行效率是不一样的
  • 针对不同的数据源,Stream流的执行效率也是不一样的
  • 对于简单的数字(list-Int)遍历,普通for循环效率的确比Stream串行流执行效率高(1.5-2.5倍)。但是Stream流可以利用并行执行的方式发挥CPU的多核优势,因此并行流计算执行效率高于for循环。
  • 对于list-Object类型的数据遍历,普通for循环和Stream串行流比也没有任何优势可言,更不用提Stream并行流计算。

虽然在不同的场景、不同的数据结构、不同的硬件环境下。Stream流与for循环性能测试结果差异较大,甚至发生逆转。但是总体上而言

  • Stream并行流计算 >> 普通for循环 ~= Stream串行流计算 (之所以用两个大于号,你细品)
  • 数据容量越大,Stream流的执行效率越高。
  • Stream并行流计算通常能够比较好的利用CPU的多核优势。CPU核心越多,Stream并行流计算效率越高。
  • 如果数据在1万以内的话,for循环效率高于foreach和stream;如果数据量在10万的时候,stream效率最高,其次是foreach,最后是for。另外需要注意的是如果数据达到100万的话,parallelStream异步并行处理效率最高,高于foreach和for

处理集合数据

Stream可以说是Java8中对于处理集合的抽象概念,所以我们经常对集合中的数据采用像SQL这种类似方式去处理;所以经常会用Stream进行遍历操作,那相较于我们以前写的嵌套for循环可以说是代码更加的简洁,更直观易读。当然循环只是循环,而Stream是个流的形式去做处理。那如何去做迭代,那就得看看stream的原理了。

惰性计算

惰性计算我们也可以称作惰性求值或者延迟求值,这种方式在函数式编程中极为常见,也就是当计算出结果后不立马去返回值,而是在它要被用到的时候来计算;

在Stream中,我们就可以看作中间操作,比如当要对一个List集合做出Stream操作,比如filter,但是没有最终操作,它返回的还是一个Stream流。也就是我们可以看作下图这种方式。

image-20220625164918253

与Collection的不同点

从实现角度比较, Stream和Collection也有众多不同:

  • 不存储数据。 流不是一个存储元素的数据结构。 它只是传递源(source)的数据。
  • 功能性的(Functional in nature)。 在流上操作只是产生一个结果,不会修改源。 例如filter只是生成一个筛选后的stream,不会删除源里的元素。
  • 延迟搜索。 许多流操作, 如filter, map等,都是延迟执行。 中间操作总是lazy的。
  • Stream可能是无界的。 而集合总是有界的(元素数量是有限大小)。 短路操作如limit(n) , findFirst()可以在有限的时间内完成在无界的stream
  • 可消费的(Consumable)。 不是太好翻译, 意思流的元素在流的声明周期内只能访问一次。 再次访问只能再重新从源中生成一个Stream

Stream原理

也许我们会觉得,Stream的实现是每一次去调用函数,它就会进行一次迭代,这肯定是不对的,这样Stream的效率是很低的。

其实事实是我们可以通过看源码来发现它是怎样迭代的,其实Stream内部是通过流水线(Pipeline)的方式来实现的,基本思想是在迭代的时候顺着流水线(Pipeline)尽可能的执行更多的操作,从而避免多次迭代

也就是说Stream在执行中间操作时仅仅是记录,当用户调用终止操作时,会在一个迭代里将已经记录的操作顺着流水线全部执行掉。沿着这个思路,有几个问题需要解决:

  1. 用户的操作如何记录?
  2. 操作如何叠加?
  3. 叠加之后的操作如何执行?

关键问题解决

以上我们可以知道Stream的完整操作,是一个由<数据来源、操作、回调函数>组成的三元组;

此外我们还需要知道Stream的相关类与接口的继承关系。如下图:

  • 从图中可以看出我们除了基本类型以外,引用类型是通过实例化的ReferencePipeline来表示
  • 而与ReferencePipeline并行三个类是为其基本类型定制的。

image-20220625165921940

1.操作如何记录?

  • 首先JDK源码中经常会用stage(阶段)来标识一次操作。
  • 其次,Stream操作通常需要一个回调函数(Lambda表达式)

image-20220625171257401

从以上我们可以看出,当我们调用stream方法时,最终会去创建一个Head实例来表示操作头,也就是第一个stage,当调用filter()方法时则会创建中间操作实例StatelessOp(无状态),接着调用map()方法时则会创建中间操作实例StatelessOp(无状态),最后调用sort()方法时会创建最终操作实例StatefulOp(有状态),同样调用其他操作对应的方法也会生成一个ReferencePipeline实例,通过调用这一系列操作最终形成一个双向链表,即每个Stage都记录了前一个Stage和本次的操作以及回调函数。

源码

1.调用stream,创建Head实例

image-20220625173329776

2.调用filter或map中间操作

  • 这些中间操作以及最终操作都在ReferencePipeline这个类中,它实现其元素类型的中间管道阶段或管道源阶段的抽象基类。

下面代码逻辑就是将回调函数mapper包装到一个Sink当中。由于Stream.map()是一个无状态的中间操作,所以map()方法返回了一个StatelessOp内部类对象(一个新的Stream),调用这个新Stream的opWripSink()方法将得到一个包装了当前回调函数的Sink。

这个Sink就是下面提到的操作如何叠加方式。

image-20220625173737720

2.操作如何叠加?

从上面我们可以知道Stream通过stage来记录操作,但stage只保存当前操作,它是不知道怎么操作下一个stage,它又需要什么操作。

所以要执行的话还需要某种协议将各个stage关联起来。

JDK中就是使用Sink(我们可以称为“汇聚结点”)接口来实现的,Sink接口定义begin()、end()、cancellationRequested()、accept()四个方法,如下表所示。

方法名 作用
void begin(long size) 开始遍历元素之前调用该方法,通知Sink做好准备。
void end() 所有元素遍历完成之后调用,通知Sink没有更多的元素了。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值