谈Java语言规范之Stream

简介

java.util.stream 包中的类用来支持元素流上的函数式操作,例如,集合上的 map-reduce 转换. 例如:

int sum = widgets.stream()
     .filter(b-> b.getColor() == RED)
     .mapToInt(b -> b.getWeight())
     .sum();

这个包中引入的关键抽象是流。类 Stream 、IntStream、LongStream 和 DoubleStream 是对象和基元int、long和 double 类型上的流。流与集合在几个方面不同:

  • 没有存储。流不是存储元素的数据结构;相反,它通过计算操作管道从数据源(如数据结构、数组、生成器函数或 I/O 通道) 传输元素。
  • 函数式本质上,是流上的操作生成结果,但不修改其源。例如,过滤从集合中获得的流将生成没有过滤掉元素的新流,而不是从源集合中删除元素。
  • 延迟查找。许多流操作(如过滤、映射或重复删除)可以延迟实现,从而暴露优化的机会。例如,“查找带有三个连续元音的第一个字符串”不需要检查所有输入字符串。流操作分为中间(流生成)操作和终端(值生成或副作用生成)操作。中间操作总是惰性的。
  • 可能是无限的。集合的大小是有限的,而流则不必。诸如limit(n) 或 findFirst() 之类的短路操作允许在有限时间内完成对无限流的计算。
  • 消耗品。流的元素在流的生命周期中只访问一次。与迭代器一样,必须生成新的流来重新访问源的相同元素

流可以通过多种方式获得,一些例子包括:

  • 从一个 Collection 可以通过 stream() 和 parallelStream() 方法。
  • 从一个数组可以通过 Arrays.stream(Object[])。
  • Stream 类上的静态工厂方法。例如 Stream.of (Object[]), IntStream.range(int,int) 或者 Stream.iterate(Object, UnaryOperator) ;
  • 文件的行的 Stream 可以通过 BufferedReader.lines () 来获得。
  • 文件路径的 Stream 可以通过 Files 的方法。
  • 可以通过 Random.ints () 获取随机数的 Stream 。
  • JDK 中还有许多的流承载方法,包括 BitSet.stream() ,Pattern.splitAsStream(java.lang.CharSequence), and JarFile.stream().

Stream operations and pipelines (流操作和管道)


流操作分为中间操作和终端操作,并组合成流管道。流管道由源(如集合、数组、生成器函数或I/O通道)组成;然后是零个或多个中间操作,如Stream.filter 或者 Stream.map ; 和一个终结操作,例如 Stream.forEach 或者 Stream.reduce 。


中间操作返回一个新流。他们总是懒惰的; 执行filter()之类的中间操作实际上并不执行任何筛选,而是创建一个新的流,当遍历该流时,该流包含与给定谓词匹配的初始流的元素。管道源的遍历直到管道的终端操作执行后才开始。


IntStream intStream=IntStream.range(0,5).filter(i->{
System.out.println("filter:"+i);
return i>3;
});
System.out.println("开始执行forEach!");
intStream.forEach(System.out::println);
// 执行结果:
开始执行forEach!
filter:0
filter:1
filter:2
filter:3
filter:4
4

终端操作,例如 Stream.forEach 或者 IntStream.sum , 能够通过流产生结果或副作用。终端操作完成后,认为流管道已消耗,不能再使用;如果需要再次遍历相同的数据源,则必须返回数据源以获得新的流。在几乎所有情况下,终端操作都是急切的,在返回之前完成对数据源的遍历和对管道的处理。只有终端操作iterator()和spliterator()不是; 如果现有的操作不足以支持任意的客户端控制的管道遍历,则将其作为“逃逸舱口”(不能理解)提供。


延迟处理流有着显著的效率;在管道中(如上面的filter-map-sum示例),可以将过滤、映射和求和融合到数据的单个通道中,中间状态最小。惰性还允许在不必要的时候避免检查所有数据;对于“查找长度大于1000个字符的第一个字符串”这样的操作,只需要检查足够的字符串以找到具有所需特征的字符串,而不需要检查源文件中所有可用的字符串。当输入流是无限的,而不仅仅是大的时候,这种行为就变得更加重要了。


中间操作进一步分为无状态操作和有状态操作。无状态操作(如filter和map)在处理新元素时不保留以前看到的元素的状态——每个元素都可以独立于对其他元素的操作进行处理。有状态操作,例如distinct和ordered,在处理新元素时可以合并来自以前看到的元素的状态。


有状态操作可能需要在生成结果之前处理整个输入。例如,在看到流的所有元素之前,不能从排序流中产生任何结果。因此,在并行计算下,一些包含有状态中间操作的管道可能需要对数据进行多次传递,或者可能需要缓冲重要数据。仅包含无状态中间操作的管道可以在单个通道中处理,无论是顺序的还是并行的,只需最少的数据缓冲。


此外,一些操作被认为是短路操作。如果中间操作在输入为无穷大时可能产生有限的流,则该操作就是短路。当输入为无穷大时,终端操作可能在有限时间内终止,则该终端操作为短路。在管道中进行短路操作是无限流处理在有限时间内正常终止的必要条件,但不是充分条件(即无限流在有限时间内正常终止=> 在管道中进行短路操作)。


Parallelism (并行)


带有显式for循环的处理元素本质上是串行的。流通过将计算重新组织为聚合操作的管道,而不是作为对每个元素的强制操作,从而促进并行执行。所有流操作都可以串行或并行执行。JDK中的流实现创建串行流,除非显式地请求并行性。例如,Collection有Collection.stream()和Collection. parallelstream()方法,它们分别生成顺序流和并行流;其他含流方法,如IntStream.range(int, int)产生连续的流,但是这些流可以通过调用它们的BaseStream.parallel()方法来高效地并行化。要并行执行前面的例子,我们需要:

int sum = widgets.parallelStream()

     .filter(b-> b.getColor() == RED)

     .mapToInt(b -> b.getWeight())

     .sum();

这个示例的串行和并行版本之间的唯一区别是初始流的创建,使用“parallelStream()”而不是“stream()”。在启动终端操作时,根据调用流的流的方向顺序或并行地执行流管道。可以使用isParallel()方法确定流是串行执行还是并行执行,并且可以使用BaseStream.sequential()和Bas eStream.parallel()操作修改流的方向。当终端操作启动时, 流管道是顺序执行还是并行执行,这取决于调用它的流的模式。


除了确定为显式不确定性的操作(如findAny())之外,流是按顺序执行还是并行执行都不应更改计算结果。


大多数流操作接受描述用户指定行为的参数,这些参数通常是lambda表达式。为了保持正确的行为,这些行为参数必须是非干扰的,并且在大多数情况下必须是无状态的。这些参数总是函数接口(如Function)的实例,通常是lambda表达式或方法引用。


Non-interference (不干涉)

流使您能够在各种数据源上执行可能的并行聚合操作,甚至包括非线程安全的集合,如ArrayList。只有当我们能够在流管道的执行过程中防止对数据源的干扰时,这才有可能。除了escape-hatch操作iterator()和spliterator()之外,执行在调用终端操作时开始,在终端操作完成时结束。对于大多数数据源,防止干扰意味着确保在流pipel的执行过程中数据源不被修改


因此,源可能不是并发的流管道中的行为参数永远不应该修改流的数据源。如果一个行为参数修改流的数据源,或者导致该数据源被修改,那么它就会干扰非并发数据源。不干涉的要求适用于所有管道,而不仅仅是平行管道。除非流源是并发的,否则在流管道执行期间修改流的数据源可能会导致异常、不正确的答案或不符合行为。对于性能良好的流源,可以在终端操作开始之前修改源,这些修改将反映在所覆盖的元素中。例如,考虑以下代码:

 List<String> l = new ArrayList(Arrays.asList("one", "two"));
      Stream<String> sl = l.stream();
      l.add("three");
      String s = sl.collect(joining(" "));

Stateless behaviors(无状态行为)


如果流操作的行为参数是有状态的,那么流管道的结果可能是不确定的或不正确的。有状态lambda(或实现适当功能接口的其他对象)的结果取决于流管道执行过程中可能更改的任何状态。有状态lambda的一个例子是要 map()的参数:


Set<Integer> seen = Collections.synchronizedSet(new HashSet<>());
stream.parallel().map(e -> { if (seen.add(e)) return 0; else return e; })...

在这里,如果映射操作是并行执行的,由于线程调度的不同,相同输入的结果在不同的运行中可能会有所不同,而对于无状态lambda表达式,结果总是相同的。


还请注意,试图从行为参数访问可变状态会给安全性和性能带来糟糕的选择;如果不同步对该状态的访问,就会出现数据竞争,从而破坏代码,但是如果同步对该状态的访问,就会有争用破坏您希望从中受益的并行性的风险。最好的方法是完全避免流操作的有状态行为参数;通常有一种方法可以重构流管道以避免有状态。


side-effects (副作用)


流操作的行为参数中的副作用通常是不受鼓励的,因为它们常常会无意中违反无状态需求,以及其他线程安全危害。


如果行为参数确实有副作用,除非明确说明,否则无法保证这些副作用对其他线程的可见性,也无法保证在同一流管道中的同一元素上的不同操作在同一线程中执行。此外,这些影响的顺序可能令人惊讶。即使管道被限制生成与流源的相遇顺序一致的结果(例如,IntStream.range(0,5).parallel().map(x->x*2).toArray()必须产生 [0,2,4,6,8] ),不保证mapper函数应用于单个元素的顺序,也不保证在哪个线程中为给定的元素执行任何行为参数。


在许多计算中,人们可能会倾向于使用副作用,但在没有副作用的情况下,可以更安全地高效地表达这些副作用,例如使用reduce而不是可变累加器。然而,出于调试目的使用println()等副作用通常是无害的。少数流操作,如forEach()peek(),只能通过副作用进行操作;这些应该小心使用。


Ordering (排序)


流可能有也可能没有已定义的相遇顺序。流是否具有相遇顺序取决于源操作和中间操作。某些流源(如列表或数组)本质上是有序的,而其他流源(如HashSet)则不是。一些中间操作,比如ordered(),可能会对一个无序的流强制执行相遇顺序,而其他操作可能会呈现一个无序的流,比如BaseStream.unordered()。此外,一些终端操作可能会忽略偶遇顺序,比如forEach()


如果一个流是有序的,那么大多数操作都被限制为按元素的相遇顺序进行操作;如果流的源是一个包含[1,2,3]的列表,那么执行map(x -> x*2)的结果必须是[2,4,6]。但是,如果源没有定义相遇顺序,那么值[2,4,6]的任何排列都是有效的结果。


对于顺序流,偶遇顺序的存在与否并不影响性能,只影响决定论。如果对一个流进行排序,在一个相同的源上重复执行相同的流管道将产生相同的结果;如果没有排序,重复执行可能会产生不同的结果


对于并行流,放松排序约束有时可以提高执行效率。某些聚合操作,例如过滤重复项(distinct())或分组缩减(collections . groupingby()),如果元素的顺序不相关,则可以更有效地实现。类似地,本质上与遇到顺序相关的操作(如limit())可能需要缓冲以确保正确的顺序,从而破坏并行性的好处。在流具有偶遇顺序,但用户并不特别关心偶遇顺序的情况下,使用unordered()显式地对流进行排序可以提高一些有状态或终端操作的并行性能。然而,大多数流管道,如上面的“块的权重和”示例,即使在排序约束下,仍然可以有效地并行化。


Reduction operations(减少操作)


约简操作(也称为折叠)接受一系列输入元素,并通过反复应用组合操作(如查找一组数字的和或最大值,或将元素累积到列表中)将它们组合成单个摘要结果。streams类具有多种形式的通用约简操作,称为reduce()collect(),以及多种专门的约简形式,如sum()max()count()

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值