stream(2) Stream流水线解决方案

820 阅读13分钟

一、概述

Stream采用了流水线的形式来处理这些流数据。首先记录用户每一步的操作(这时候不会执行),当用户调用结束操作时将之前记录的操作叠加到一起在一次迭代中全部执行。

更形象地说,用户在代码里写下一系列流操作后,就表示一条Stream流水线生成了,这个流水线就称为流管道(stream pipeline)。首先,是记录这条流水线上都有哪些阶段(stage),一个stage表示一个完整的操作,及这个流水线上处理数据的一个节点。当记录完成后,将数据从流水线的头(数据源)开始流入,在每一个节点(stage)对数据进行相应的操作,再流向下一个节点,直到遇到终结操作,完成流水线作业。 在这里插入图片描述 看完Stream流水线的基本结构,继续解决下面几个问题:

  • 用户的操作如何记录?
  • 操作如何叠加?
  • 叠加之后的操作如何执行?
  • 执行后的结果(如果有)在哪里?

二、用户的操作如何记录?

stream中使用stage的概念来描述一个完整的操作,并用PipelineHelper实例来代表stage,将具有先后顺序的各stage连到一起,就构成了整个流水线。一个流管道包含了一个数据源,零个或者多个中间stage和一个终结stage。

PipelineHelper

PipelineHelper是一个帮助器类,用于执行流管道,在一个位置捕获流管道的所有信息(输出形状、中间操作、流标志、并行度等)。 一个PipelineHelper描述了流管道的初始状态,包括了它的数据源、中间操作和额外的并行信息,和在这个PipelineHelper的最后一个中间操作之后的终端(或有状态)操作的信息。PipelineHelper被传递到子类方法,它可以使用PipelineHelper来访问有关管道的信息,如头部形状、流标志和大小,并使用帮助器方法来执行管道操作。

主要方法(都是Abstract方法):

序号方法名入参出参功能说明
1getSourceShape--int获取流管道的数据源形状,StreamShape具体参见:
2getStreamAndOpFlagsSpliterator<P_IN> spliterator<P_IN> long合并来自流源、所有中间操作和终端操作的输出流和标志
3exactOutputSizeIfKnownS sink, Spliterator<P_IN> spliterator<P_IN, S extends Sink<P_OUT>> S返回当前stage输出流数据的大小,如果未知或已知无穷大,则返回-1
4wrapAndCopyIntoSink<P_IN> wrappedSink, Spliterator<P_IN> spliterator<P_IN> void将当前stage应用于提供的spliterator,并将结果发送给提供的sink
5copyIntoSink<P_OUT> sink将获取的元素发送给提供的sink,如果流管道已知是短路stage,那么在每次发送元素后都会判断一下是否可以停止发送元素的操作了
6wrapSinkSink<P_OUT> sink<P_IN> Sink<P_IN>获取一个接收PipelineHelper输出类型元素的sink,并将其包装为接受输入类型元素并实现此PipelineHelper描述的所有中间操作的sink,将结果传递到提供的sink中
7makeNodeBuilderlong exactSizeIfKnown,IntFunction<P_OUT[]> generatorNode.Builder<P_OUT>构造一个节点生成器
8evaluateSpliterator<P_IN> spliterator, boolean flatten,IntFunction<P_OUT[]> generator<P_IN> Node<P_OUT>收集将管道stage应用于源Spliterator并将其应用到节点中所产生的所有输出元素

PipelineHelper的继承关系

PipelineHelper的继承关系如下: 在这里插入图片描述 其中Head表示的是流水线里的第一个stage,即调用调用诸如Collection.stream()方法产生的Stage,很显然这个Stage里不包含任何操作;StatelessOp和StatefulOp分别表示无状态和有状态的Stage,对应于无状态和有状态的中间操作

IntPipeline, LongPipeline, DoublePipeline这三个类专门为三种基本类型(不是包装类型)而定制的,跟ReferencePipeline是并列关系,这里就不做过多说明了,重点介绍一下ReferencePipeline

ReferencePipeline

ReferencePipeline 是一个结构类,在JDK1.8提供提供,它实现了Stream接口,通过定义内部类组装了各种操作流。他定义了 Head、StatelessOp、StatefulOp 三个内部类,实现了 BaseStream 与 Stream 的接口方法。

i. 接口实现
abstract class ReferencePipeline<P_IN, P_OUT>
        extends AbstractPipeline<P_IN, P_OUT, Stream<P_OUT>>
        implements Stream<P_OUT>  {
  • P_IN:上游源数据元素类型
  • P_OUT:本阶段生产的元素类型
  • AbstractPipeline<P_IN, P_OUT,
  • Stream<P_OUT>>: Stream<P_OUT>:实现的Stream接口的类型是本阶段生产的元素类型
ii. 三个内部类

Head<E_IN, E_OUT>:Head 类主要用来定义数据源操作,在我们初次调用 names.stream() 方法时,会初次加载 Head 对象,进行加载数据源操作

StatelessOp<E_IN, E_OUT>:流的无状态中间阶段的基类,构造器通过向现有流附加无状态中间操作来构造新流

StatefulOp<E_IN, E_OUT>:流的有状态中间阶段的基类,构造器通过向现有流附加有状态中间操作来构造新流

iii. 构造函数

ReferencePipeline提供了三个构造函数,这三个构造函数都是调用了父类 AbstractPipeline 的构造器

构造方法一:用作流管道头部的构造器,主要参数如下:

  • source:Supplier> 数据源
  • sourceFlags:int 流数据源标识,不同的标识stream在后续处理中会有不同的选择和策略,对应枚举类为 StreamShape
  • parallel:boolean 是否是并行流数据

构造方法二:同样是用作流管道头部的构造器,和构造方法一不同的是source的类型不一样

  • source:Spliterator<?> 数据源
  • sourceFlags:int 流数据源标识,不同的标识stream在后续处理中会有不同的选择和策略,对应枚举类为 StreamShape
  • parallel:boolean 是否是并行流数据

构造方法三:用于将中间操作附加到现有管道上

  • upstream:AbstractPipeline 上游数据源
  • opFlags:int 流数据标识,同(sourceFlags:int)对应枚举类为 StreamShape

三、这些操作如何叠加?

以上只是解决了操作记录的问题,要想让流水线起到应有的作用我们需要一种将所有操作叠加到一起的方案。由于在流管道里前面的Stage并不知道后面Stage到底执行了哪种操作,以及回调函数是哪种形式,只有当前stage本身才知道该如何执行自己包含的动作,无法在流水线上一次执行每个stage的操作,这就需要有某种协议来协调相邻Stage之间的调用关系。

这个协议由Sink接口完成

Sink

Sink接口是流管道用来传递数据通过stage的一个扩展协议,使用额外的方法去管理信息大小、控制流等。一个Sink实例用于管理这个流管道的每个stage,无论是哪种类型的数据Sink都可以提供对应的accept方法用于stage接受数据,因此我们不需要为每一个特殊场景提供一个特殊接口。

在调用接受数据accept方法之前,必须调用begin方法去通知有数据要开始进入了(告诉Sink接口有多少个数据进入),在所有的方法都发送完成后,还必须调用end方法。在调用end方法后就不能在再调用accept方法,除非重新调用begin方法。Sink接口还提供了一种机制,通过这种机制,Sink接口可以协同发出不希望接收更多数据的信号,stage可以在向Sink发送更多数据之前对其进行轮询。

Sink接口一般有两种状态:初始化状态和活动状态。它开始的时候处于初始状态,在调用begin方法是变成活动状态。在调用end方法后又变回了初始状态,这样它就可以被再次使用了。数据接受方法只能在活动状态时被调用。

方法名

入参

出参

功能描述

begin

long size

void

default,将sink从初始状态变为活动状态,在传送数据之前必须要调用

end

--

void

default,表示所有元素都已被推送,如果sink是有状态的,需要将中间存储也发送到下游并清除任何累积状态和香港资源,调用后sink变为初始状态

cancellationRequested

--

boolean

default,是否可以结束操作,可以让短路操作尽早结束,默认返回false

accept

int/long/double value

void

default,三种入参的重载方法,遍历元素时调用,接受一个待处理元素,并对元素进行处理。Stage把自己包含的操作和回调方法封装到该方法里,前一个Stage只需要调用当前Stage.accept(T t)方法就行了

有了上面的协议,相邻Stage之间调用就很方便了,每个Stage都会将自己的操作封装到一个Sink里,当前stage关联的Sink实例需要知道下一个stage的数据类型,并在其下游的Sink上调用正确的accept方法。 类似地,每个stage必须实现与它接受的数据类型相对应的正确的accept方法。特殊的子类会重写accept方法来实现自己的特殊逻辑。前一个Stage只需调用后一个Stage的accept方法即可,并不需要知道其内部是如何处理的。当然对于有状态的操作,Sink的begin()和end()方法也是必须实现的。 实际上Stream API内部实现的的本质,就是如何重载Sink的这四个接口方法

四、叠加后的操作如何执行?

Sink完美封装了Stream每一步操作,并给出了 [处理->转发] 的模式来叠加操作。这一连串的齿轮已经咬合,就差最后一步拨动齿轮启动执行。是什么启动这一连串的操作呢?也许你已经想到了启动的原始动力就是结束操作(Terminal Operation),一旦调用某个结束操作,就会触发整个流水线的执行。

在我们初次调用 .stream() 方法时,会初次加载 Head 对象,此时为加载数据源操作;接着加载的是中间操作,分别为无状态中间操作 StatelessOp 对象和有状态操作 StatefulOp 对象,此时的 Stage 并没有执行,而是通过 AbstractPipeline 生成了一个中间操作 Stage 链表;当我们调用终结操作时,会生成一个最终的 Stage,通过这个 Stage 触发之前的中间操作,从最后一个 Stage 开始,反向生成一个 Sink 链表。因为在创建每一个 Stage 时,都会包含一个 opWrapSink方法,该方法会把一个操作的具体实现封装在 Sink 类中,Sink 采用[ 处理 -> 转发] 的模式来叠加操作。最后,调用 AbstractPipeline 的 wrapSink 方法,该方法会调用 opWrapSink 生成一个 Sink 链表,Sink 链表中的每一个 Sink 都封装了一个操作的具体实现。

final <P_IN> Sink<P_IN> wrapSink(Sink<E_OUT> sink) {
  Objects.requireNonNull(sink);
	// 从最后一个sink开始,反向生成一个Sink链表
  for ( @SuppressWarnings("rawtypes") AbstractPipeline p=AbstractPipeline.this; p.depth > 0; p=p.previousStage) {
    sink = p.opWrapSink(p.previousStage.combinedFlags, sink);
  }
  return (Sink<P_IN>) sink;
}

从开始到结束的所有的操作都被包装到了一个Sink里,执行这个Sink就相当于执行整个流水线。Stream 开始执行,通过 spliterator 迭代集合,执行 Sink 链表中的具体操作。

@Override
final <P_IN> void copyInto(Sink<P_IN> wrappedSink, Spliterator<P_IN> spliterator) {
  Objects.requireNonNull(wrappedSink);

  if (!StreamOpFlag.SHORT_CIRCUIT.isKnown(getStreamAndOpFlags())) {
    wrappedSink.begin(spliterator.getExactSizeIfKnown());
    spliterator.forEachRemaining(wrappedSink);
    wrappedSink.end();
  }
  else {
    copyIntoWithCancel(wrappedSink, spliterator);
  }
}

五、执行后的结果在哪里?

下表给出了各种有返回结果的Stream终结操作:

序号返回类型对应的终结操作
1booleananyMatch(), allMatch(), noneMatch()
2OptionalfindFirst, findAny()
3归约操作reduce(), collect()
4数组toArray()

对于表中返回boolean或者Optional的操作,由于值返回一个值,只需要在对应的Sink中记录这个值,等到执行结束时返回就可以了。

对于归约操作,最终结果放在用户调用时指定的容器中(容器类型通过收集器指定)。collect(), reduce(), max(), min()都是归约操作,虽然max()和min()也是返回一个Optional,但事实上底层是通过调用reduce()方法实现的。

对于返回是数组的情况,毫无疑问的结果会放在数组当中。但在最终返回数组之前,结果其实是存储在一种叫做Node的数据结构中的。Node是一种多叉树结构,元素存储在树的叶子当中,并且一个叶子节点可以存放多个元素。这样做是为了并行执行方便。

六、扩展

扩展一:Node数据结构

Node作为T类型的有序序列的容器,是一个多叉树的数据结构。

一个Node节点包含固定数量的元素,可以被count(),spliterator(),forEach(),asArray()或copyInto等方法访问。每个Node节点可能有一个或者多个子节点,如果它没有子节点那么就被认为是叶子节点或者单独的一个节点(只有根节点没有分支),否则是父节点。父节点的大小是其子节点大小的总和。

节点通常不直接存储元素,而是调节对一个或多个现有(实际上不可变)数据结构(如集合、数组或一组其他节点)的访问。通常,节点形成一棵树,其形状与生成包含在叶节点中的元素的计算树相对应。在流框架中使用节点主要是为了避免在并行操作期间不必要地复制数据。

Node接口

java.util.stream.Node接口定义了Node节点的一系列操作方法用于对Node节点进行一系列操作,此外还包含了OfPrimitive,OfInt,OfLong,OfDouble四个子接口用于不同数据类型的定制化操作。

Nodes工厂类

Nodes类提供了8种类型的Node实体类,并且提供了相应的builder来创建不同类型的Node节点用来兼容不同的流数据类型。

序号节点类名称主要属性功能
1EmptyNode--提供一个空的Node节点,里面不存任何实际数据,方法都是返回空值或者默认值
2ArrayNodefinal T[] array; int curSize;Node节点里可存放一个实体对象数组,其中curSize用来记录数组的大小
3CollectionNodefinal Collection c;Node节点里可存放存放集合类型的实体对象
4IntArrayNodefinal int[] array; int curSize;Node节点里可存放一个int类型的数组,其中curSize用来记录数组的大小
5LongArrayNodefinal long[] array; int curSize;Node节点里可存放一个long类型的数组,其中curSize用来记录数组的大小
6DoubleArrayNodefinal double[] array; int curSize;Node节点里可存放一个double类型的数组,其中curSize用来记录数组的大小
7AbstractConcNodefinal T_NODE left;final T_NODE right;final long size;Node节点里可以存放左、右两个子Node节点,这两个子Node节点的类型可以不一样(只要都继承了Node接口即可),作为抽象类只定义了底层数据结构 当前节点的size = left.count() + right.count()
8ConcNode--继承了AbstractConcNode类, 底层结构数据结构完全沿用,在这里实现了Node接口的各种方法

七、参考文献

spliterator:[了解Java Spliterator 这篇文章就够了]

stream流水线模型和Sink:[深入理解Java Stream流水线]

欢迎关注个人公众号【小肖爱吃肉】