【java.util.stream】读文章笔记

【背景】springboot官网文件上传教程用了大量stream方法,stream是interface,各种集合类型是如何通过stream()方法进行流化的,不是很清楚,搜stream原理,搜到一个系列教程的第三篇,因此读一下整个系列并作总结。

参考链接一(java.util.stream 库简介)

【作用】借助 java.util.stream包,您可以简明地、声明性地表达集合、数组和其他数据源上可能的并行批量操作。

【优点】流利用了这种最强大的计算原理:组合。通过使用简单的构建块(过滤、映射、排序、聚合)来组合复杂的操作,在问题变得比相同数据源上更加临时的计算更复杂时,流查询更可能保留写入和读取的简单性。

【设计方法】所采用的设计方法实现了实际的关注点分离。客户端负责指定计算的是 “什么”,而库负责控制 “如何做”。这种分离倾向于与专家经验的分发平行进行;客户端编写者通常能够更好地了解问题领域,而库编写者通常拥有所执行的算法属性的更多专业技能。编写允许这种关注点分离的库的主要推动力是,能够像传递数据一样轻松地传递行为,从而使调用方可在 API 中描述复杂计算的结构,然后离开,让库来选择执行战略。

【待看】文章中的JDK中的流来源的源码

【流管道剖析】所有流计算都有一种共同的结构:它们具有一个流来源、0 或多个中间操作,以及一个终止操作。中间操作始终是惰性:调用中间操作只会设置流管道的下一个阶段,不会启动任何操作。中间操作可进一步划分为无状态 和有状态 操作。无状态操作(比如 filter() 或 map())可独立处理每个元素,而有状态操作(比如 sorted() 或 distinct())可合并以前看到的影响其他元素处理的元素状态。数据集的处理在执行终止操作时开始,比如缩减(sum() 或 max())、应用 (forEach()) 或搜索 (findFirst()) 操作。终止操作会生成一个结果或副作用。执行终止操作时,会终止流管道,如果您想再次遍历同一个数据集,可以设置一个新的流管道。

【用途】除了将流用于计算之外,您可能还希望考虑通过 API 方法使用流来返回聚合结果,而在以前,您可能返回一个数组或集合。返回流的效率通常更高一些,因为您不需要将所有数据复制到一个新数组或集合中。返回流通常更加灵活;库选择返回的集合形式可能不是调用方所需要的,而且很容易将流转换为任何集合类型。(返回流不合适,而返回物化集合更合适的主要情形是,调用方需要查看某个时间点的状态的一致快照。)

【并行性】所有流操作都可以顺序(stream())或并行执行(parallelStream()),但请记住,并行性并不是高性能的原因。并行执行可能比顺序执行更快、一样快或更慢。最好首先从顺序流开始,在您知道您能够获得提速(并从中受益)时才应用并行性。

【注意】大多数流操作都要求传递给它们的拉姆达表达是互不干扰 和无状态 的。互不干扰意味着它们不会修改流来源;无状态意味着它们不会访问(读或写)任何可能在流操作寿命内改变的状态。对于缩减操作(例如计算 summin 或 max 等汇总数据),传递给这些操作的拉姆达表达式必须是结合性 的(或遵守类似的要求)。

【总结】java.util.stream 库提供了一种简单而又灵活的方法来表达各种数据源上可能并行的函数式查询,包括集合、数组、生成器函数、内置的工厂或自定义数据结构。

参考链接二(使用流执行聚合)

【总结】聚合工具是 Streams 库的最有用和灵活的部分之一。可以使用缩减操作来轻松地按顺序或并行聚合简单的值;更复杂的汇总结果可通过 collect() 创建。Collectors库附带了一组简单的基本收集器,可以组合它们来执行更复杂的聚合,而且您可以轻松地将自己的收集器添加到组合中。

【待试】自定义一个收集器并使用

参考链接三(Streams的工作原理)

【意义】在许多情况下,java.util.stream库会确定如何高效地执行查询,而不需要用户协助。但在性能至关重要时,了解该库的内部工作原理很有价值,这样您就能够消除低效性的可能来源。

【流管道】一个流管道 包含一个流来源、0 或多个中间操作,以及一个终止操作。流来源可以是集合、数组、生成器函数或其他任何适当地提供了其元素的访问权的数据源。中间操作将流转换为其他流 — 通过过滤元素 (filter()),转换元素 (map()),排序元素 (sorted()),将流截断为一定大小 (limit()),等等。终止操作包括聚合(reduce()collect()),搜索 (findFirst()) 和迭代 (forEach())。

【流来源】流来源有一种称为 Spliterator 的抽象来描述。顾名思义,Spliterator 组合了两种行为:访问来源的元素(迭代),可能分解输入来源来实现并行执行(拆分)。为了拆分来源,以便两个线程可分别处理输入的不同部分,Spliterator 提供了一个 trySplit() 方法。JDK 中的 Collection 实现都已配备了高质量的 Spliterator 实现。允许一些来源获得比其他来源更好的实现:包含多个元素的 ArrayList 始终可以干净且均匀地进行拆分;LinkedList 的拆分效率一直很差;而且基于哈希值和基于树的数据集通常能够进行比较不错的拆分。

【构建流管道】流管道是通过构造流来源及其中间操作的链接列表表示来构建的。在内部表示中,管道的每个阶段都通过一个流标志 位图来描述,该位图描述了在流管道的这一阶段已知的元素信息。流使用这些标志优化流的构造和执行。来源阶段的流标志来自 spliterator 的 characteristics 位图(spliterator 支持比流更大的标志集)。高质量的 spliterator 实现不仅提供了高效的元素访问和拆分,还会描述元素的特征。(例如,一个 HashSet 的 spliterator 报告 DISTINCT 特征,因为已知一个 Set 的元素是不同的。)每个中间操作都对流标志具有已知的影响;一个操作可设置、清除或保留每个标志的设置。例如,filter() 操作保留 SORTED和 DISTINCT 标志,但清除 SIZED 标志;map() 操作清除 SORTED 和 DISTINCT 标志,但保留 SIZED 标志;sorted() 操作保留 SIZED 和 DISTINCT 标志,但注入 SORTED 标志。构造阶段的链接列表表示时,会将前一个阶段的标志与当前阶段的行为相组合,以获得当前阶段的一组新标志。

【执行流管道】发起终止操作时,流实现会挑选一个执行计划。中间操作可划分为无状态filter()map()flatMap())和有状态sorted()limit()distinct())操作。无状态操作是可在元素上执行而无需知道其他任何元素的操作。例如,过滤操作只需检查当前元素来确定是包含还是消除它,但排序操作必须查看所有元素之后才知道首先发出哪个元素。

如果管道按顺序执行,或者并行执行,但包含所有无状态操作,那么它可以在一轮中计算。否则,管道会划分为多个部分(在有状态操作边界上划分)并分多轮计算。

终止操作是短路allMatch()findFirst())或非短路reduce()collect()forEach())操作。如果终止操作是非短路操作,那么可以批量处理数据(使用来源 spliterator 的 forEachRemaining() 方法,进一步减少访问每个元素的开销);如果它是短路操作,则必须一个元素处理一次(使用 tryAdvance())。

对于顺序执行,Streams 构造了一个 “机器” — 一个 Consumer 对象链,其结构与管道结构相符。其中每个 Consumer 对象知道下一个阶段;当它收到一个元素(或被告知没有更多元素)时,它会将 0 或多个元素发送到链中的下一个阶段。例如,与filter() 阶段有关联的 Consumer 将过滤器谓词应用于输入元素,并将它发送或不发送到下一个阶段;与 map() 阶段有关联的 Consumer 将映射函数应用于输入元素,并将结果发送到下一个阶段。与有状态操作(比如 sorted())有关联的 Consumer会缓冲元素,直到它看到输入的末尾,然后将排序的数据发送到下一个阶段。机器中的最后一个阶段将实现终止操作。如果此操作生成了结果,比如 reduce() 或 toArray(),该阶段可充当此结果的累加器。

流管道的执行也可以使用流标志来优化。例如,SIZED 标志指示最终结果的大小是已知的。toArray() 终止操作可使用此标志预先分配正确大小的数组;如果没有 SIZED 标志,则需要猜测数组大小,并在猜测错误时复制数据。

预先设置大小的优化在并行流执行中更有效。除了 SIZED 标志之外,另一个 spliterator 特征 SUBSIZED 表示不仅大小已知,而且如果 spliterator 已拆分,则拆分大小也是已知的。(数组和 ArrayList 就属于这种情况,但其他可拆分来源,比如树,不一定属于这种情况。)如果有 SUBSIZED 特征,在并行执行中,toArray() 操作可为整个结果分配一个正确大小的数组,各个线程(分别处理输入的不同部分)可将它们的结果直接写入数组的正确部分 — 无需同步或复制。(缺少 SUBSIZED 标志时,会将每一部分收集到一个中间数组中,然后复制到最终位置。)

【遇到顺序】遇到顺序指的是来源分发元素的顺序是否对计算至关重要。流标志 ORDERED 描述了流是否有有意义的遇到顺序。

【创建流】尽管 JDK 中的许多类已被改进来用作流来源,但同样可以轻松地调整现有数据结构来分发流。要从任意数据源创建流,需要为该流的元素创建一个 Spliterator,并将该 spliterator 连同一个 boolean 标志传递给 StreamSupport.stream(),该标志表明结果流应是顺序的还是并行的。

Spliterator 实现的质量可能存在巨大差别,以平衡使用 spliterator 作为来源的流管道的实现工作与性能。Spliterator 接口有多种可选的方法,比如 trySplit()。如果您不想实现拆分,可以从 trySplit() 返回 null,但这意味着使用这个 Spliterator 作为来源的流将无法利用并行性来加速计算。

影响 spliterator 质量的考虑因素包括:

  • spliterator 是否报告了准确的大小?
  • spliterator 能否拆分输入?
  • 它能否将输入拆分为几乎相等的部分?
  • 所拆分部分的大小是否可预测(通过 SUBSIZED 特征反映)?
  • spliterator 是否报告了所有相关特征?

创建 spliterator 的最简单方法(但会导致最差的结果质量)是将 Iterator 传递给Spliterators.spliteratorUnknownSize()。您可以通过将 Iterator 和一个大小传递给 Spliterators.spliterator 来获得稍微好点的 spliterator。但是如果流性能很重要(尤其是并行性能),可以实现完整的 Spliterator 接口(包括所有适用的特征)。集合类(比如 ArrayListTreeSet 和 HashMap)的 JDK 来源提供了一些高质量的 spliterator 示例,您可针对您自己的数据结构来模仿它们。

参考链接四(从并发到并行,解释决定并行处理的有效性的因素)

从 2002 年左右开始,芯片设计者用于实现性能指数级增长的技术开始枯竭。由于各种原因,进一步提高时钟频率变得不切实际,包括功耗和散热,而且在每个周期完成更多工作的技术(指令级并行性)所带来的收益也已开始出现滑坡。

这并不是因为摩尔定律已失效;我们可以看到,晶体管数量仍然稳定地呈指数级增长。不过,虽然利用这种不断增加的晶体管预算来获得更快核心的能力已失去作用,但芯片设计者仍能使用这种不断增加的晶体管预算在单个晶片上放入更多核心

拥有更多核心可实现更高的功率效率(未被主动使用的核心可独立地中断电源),但这不一定等同于提供更高的程序性能 — 除非您可以保持所有核心都不停地执行有用的工作。诚然,如今的芯片并没有为我们提供摩尔定律所允许的核心数 — 主要是因为如今的软件无法富有成本效益地利用它们。

【从并发到并行】几乎在整个计算历史中,并发性的目标都是大致相同的 (通过增加 CPU 利用率来提高性能),但技术(和性能度量)却已发生改变。在单核系统时代,并发性主要依靠的是异步性— 允许某个活动在等待 I/O 完成期间让出 CPU。异步性可提高响应速度(在后台活动执行期间不冻结 UI)和吞吐量(在等待 I/O 完成期间允许另一个活动使用 CPU)。

随着我们进入多核时代,并发性的主要应用是将工作负载分解为独立的、粗粒度的任务(比如用户请求),这样做的目的在于通过同时处理多个请求来增加吞吐量。这一次,Java 库获得了一些工具,比如线程池、旗语 (semaphore) 和 future,它们非常适合基于任务的并发性。

但是随着核心数量的不断增加,可能没有足够的 “自然发生的任务” 来让所有核心一直处于繁忙状态。随着可用核心数超过任务数,另一个性能目标就变得很诱人:使用多个核心更快完成单个任务来减少延迟。不是所有任务都能通过这种分解来轻松处理;最适合的任务是数据密集型的查询,其中的同一个操作会在大型的数据集上完成。

更多现代课程将并发性 描述为正确、高效地控制对共享资源的访问,而并行性 指的是使用更多资源更快地解决问题。构造线程安全的数据结构属于并发性范畴,通过锁、事件、旗语、协同程序或软件事务内存 (STM) 等原语实现。另一方面,并行性使用分区或分片等技术来使多个活动处理一项任务,而没有协调过程。

为什么这一区别很重要?毕竟,并发性和并行性的目标都是同时完成多件事。但是,应用这两种技术的轻松程度有着很大差别。使用锁等协调原语正确创建并发代码很难、容易出错且不自然。通过让每个工作者拥有自己的问题部分来处理问题,从而正确创建并行代码,这样做相对更简单、更安全且更自然。

【并行性】如果并行性消耗了额外的资源却没有为我们带来收益(或负面收益),我们不应使用它。但是我们拥有各种工具,可帮助评估在给定情形中能否有效使用并行性:分析、度量和性能需求。可以用提速来度量并行性有效性,提速是并行运行时间与顺序运行时间的比率。要让并行性成为更好的选择,必须综合考虑多个方面。首先我们需要一个允许采用并行解决方案的问题。然后,我们需要实现利用了内在并行性的解决方案。我们需要确保用来实现并行性的技术没有太多开销,以至于浪费花在问题上的周期。而且我们还需要足够的数据,以便可以实现获得提速所需的规模经济。

【利用并行性】首先,看起来类似的问题可能拥有完全不同的并行性可利用程度;第二,一个可利用并行性解决的问题的解决方案的 “明显” 实现不一定会利用并行性。

【分而治之】实现可利用的并行性的标准技术称为递归分解 或分而治之

【性能考虑因素】我们现在可以继续分析并行性可带来优势的条件。通过分而治之方法引入了两个额外的算法步骤(分解问题和组合部分结果),每个步骤或多或少适合并行性。另一个可能影响并行性能的因素是并行性原语本身的效率,我们对清单 1 的伪代码中假想的 CONCURRENT 机制进行了演示。其他两个因素是数据集的属性:数据集的大小和它的内存位置。

一些问题完全不允许使用分解。甚至在问题允许采用分解时,分解可能也需要成本。此外,即使可以高效地分解问题,如果两个子问题的大小非常不均匀,我们可能不会获得太多可利用的并行性。

管理要并发执行的任务可能涉及到多个可能的效率损失来源,比如将数据从一个线程转交给另一个线程的内在延迟,或者争用共享数据结构的风险。fork-join 框架(Java SE 7 中添加来管理细粒度、计算密集型任务)旨在最大限度减少并行分派中许多常见的低效性来源。java.util.stream 库使用 fork-join 框架实现并行执行。

最后,我们必须考虑数据。如果数据集很小,则很难获得任何提速,原因是并行执行会带来启动成本。类似地,如果数据集所在的内存位置不佳(指针众多的数据结构,比如图表,而不是数组),利用如今典型的内存受限的系统执行并行可能会导致许多线程等待来自缓存的数据,而不是有效使用核心来更快地计算答案。

【阿姆达尔定律】描述计算的顺序部分对可能的并行提速有何限制。

阿姆达尔定律暗示的模型(工作的一些碎片必须完全顺序地执行,剩余部分可完美地并行化)过于简单。不过,该模型仍对理解阻碍并行性的因素很有用。查明和减少串行碎片的能力,是能够设计出更高效的并行算法的关键因素。

尽管从理论上讲,我们使用 N 个核心可将问题解决速度提高 N倍,但现实通常离此目标相距甚远。影响并行性有效性的因素包括问题本身、解决问题所使用的算法、对任务分解和调度的运行时支持,以及数据集的大小和内存位置。

参考链接五(并行流性能)

【并行流】要并行执行流管道,可以使用 Spliterator 方法 trySplit(),通过递归分解将来源数据分解为分段。

【来源拆分】数组拥有较低的拆分成本。链接列表的拆分结果很差。拆分成本很高 — 要找到中点,必须一次一个节点地遍历列表的一半内容。二叉树(比如 TreeMap)和基于哈希值的集合(比如 HashSet)的拆分结果比链接列表更好,但仍比不上数组。

【生成器作为来源】不是所有流都使用集合作为来源;一些流使用生成器函数,比如 IntStream.range()。应用于集合来源的考虑因素也可直接应用于生成器。IntStream.range比IntStream.iterate效率更好,因为迭代器内部的顺序性。函数编程人员很容易借用熟悉的 iterate 方言,而没有立即认识到此方法的内在顺序性。

【结果组合】一些组合操作(比如加和减)的成本很低。但其他组合(比如合并两个集)的成本要高得多。组合步骤所花的时间量与计算树的深度成正比;平衡的树将拥有深度 O(lg n),而病态的不平衡树(比如我们拆分一个链接列表或一个迭代生成函数所得到的树)将拥有深度 O(n)

另一个合并成本很高的问题是最后一次合并(其中将合并两半部分的结果)将顺序地执行(因为没有其他需要做的工作)。合并步骤数为 O(n) 的流管道(比如使用 sorted() 或 collect(Collectors.joining()) 终止操作的流管道)的并行性可能受到此影响的限制。

【操作语义】像一些来源(比如链接列表或迭代生成器函数)具有内在顺序性一样,一些流操作也拥有内在的顺序方面,我们可以将这视为并行性的一种阻碍。这些操作的语义通常以遇到顺序 的形式定义。

顺序地实施 findFirst() 具有极低的成本:沿管道推送数据,直到生成一些结果,然后停止。在并行执行中,我们可以轻松地并行化上游操作,但当结果是由某个子任务生成的结果时,我们的工作还未完成。我们仍需要等待在遇到顺序中先出现的所有子任务完成。另一方面,终止操作 findAny() 更有可能获得并行提速,因为它让所有核心一直繁忙地搜索匹配值,而且可以在找到一个匹配值后立即终止。

另一个语义与遇到顺序关联的终止操作是 forEachOrdered()。同样地,尽管通常可以全面并行化中间操作的执行,但最终适合的步骤是顺序性的。另一方面,forEach() 终止操作不受遇到顺序约束。可在任何时候和提供每个元素的任何线程中对该元素执行适合的步骤。

中间操作(比如 limit() 和 skip())可能也受遇到顺序约束。limit(n) 操作在前 n 个元素后截断输入流。像 findFirst()一样,当元素由某个任务生成时,limit(n) 必须等待遇到顺序中位于它之前的所有任务完成,才知道是否将这些元素推送到管道的剩余部分 — 而且它必须缓存生成的元素,直到获悉是否需要这些元素。(对于一个无序 流,limit(n) 可以选择任意 n 个 元素 — 而且像 findAny() 一样,更加适合并行化。)

在操作与遇到顺序紧密关联时,您可能对并行执行的时间和空间成本感到很奇怪。findFirst() 和 limit() 的显明顺序实现非常简单高效,而且几乎不需要空间开销,但并行实现很复杂,通常涉及到大量的等待和缓存。对于顺序执行,在显明实现中,通常会按遇到顺序遍历输入,所以对顺序执行的依赖很少是明显或昂贵的。在并行执行中,这些依赖性可能具有很高的成本。

幸运的是,这些对顺序执行的依赖性常常可通过对管道的细微更改来消除。我们通常可将 findFirst() 替换为 findAny(),而不损失任何正确性。类似地,我们在第 3 部分中已经看到,通过 unordered() 操作可让流变得无序,我们可以删除limit()distinct()sorted() 和 collect() 中内在的遇到顺序依赖性,而不损失正确性。

我们目前看到的对并行提速的各种危害都是累积性的。就像 3 参数 iterate() 来源比范围构造函数的效率低得多一样,将 2 参数 iterate() 来源与 limit() 组合的效率更低,因为它将一个顺序生成步骤与一个对顺序执行敏感的操作相组合。

【内存位置】现代计算机系统采用复杂的多级缓存将常用数据保存在离 CPU 尽可能近的地方(实际上,光速也是一个限制因素!)。从 L1 缓存抓取数据的速度很容易达到从主存储器抓取数据的 100 倍。CPU 能越高效地预测接下来需要哪个数据,CPU 执行计算所花的周期就越多,而且等待数据所花的周期就越少。

线性地处理某个数组不仅会拥有不错的位置,也会通过预取获得进一步的好处 — 当检测到线性的内存访问模式时,硬件就会开始预先获取假定可能很快需要的下一个缓存行中的内存数据。

主流 Java 实现在内存中连续放置对象的字段和数组的元素(但字段不一定按源文件中声明的顺序放置)。访问离最近访问的字段或元素 “很近” 的字段或数组元素,很有可能获得已在缓存中的数据。另一方面,对其他对象的引用被表示为指针,所以解除对象引用很有可能获得不在缓存中的数据,因而导致延迟。

【NQ模型】一个简单但有用的并行性能模型是 NQ 模型,其中 N 是数据元素数量,Q 是为每个元素执行的工作量。乘积 N*Q 越大,就越有可能获得并行提速。并行化的许多阻碍(比如拆分成本、组合成本或遇到顺序敏感性)都可以通过 Q 更高的操作来缓解。尽管拆分某个LinkedList 特征的结果可能很糟糕,但只要拥有足够大的 Q,仍然可能获得并行提速。

损害潜在并行提速的因素包括不良或不均匀的拆分来源、高组合成本、对遇到顺序的依赖性、位置不佳或没有足够的数据。另一方面,每个元素的大计算量 (Q) 可以弥补其中一些缺陷。

 

参考链接一(这里只列出系列的第一个链接,其余链接可在其页面获得)

https://www.ibm.com/developerworks/cn/java/j-java-streams-1-brian-goetz/index.html?ca=drs-

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值