6.6 性能

在前面我简要提及了影响并行流是否比串行流快的一些因素,现在让我们仔细看看它们。理解哪些能工作、哪些不能工作,能帮助在如何使用、什么时候使用并行流这一问题上做出明智的决策。影响并行流性能的主要因素有5个,依次分析如下。

  • 数据大小

输入数据的大小会影响并行化处理对性能的提升。将问题分解之后并行化处理,再将结果合并会带来额外的开销。因此只有数据足够大、每个数据处理管道花费的时间足够多时,并行化处理才有意义。6.3节讨论过。

  • 源数据结构

每个管道的操作都基于一些初始数据源,通常是集合。将不同的数据源分割相对容易,这里的开销影响了在管道中并行处理数据时到底能带来多少性能上的提升。

  • 装箱

处理基本类型比处理装箱类型要快。

  • 核的数量

极端情况下,只有一个核,因此完全没必要并行化。显然,拥有的核越多,获得潜在性能提升的幅度就越大。在实践中,核的数量不单指你的机器上有多少核,更是指运行时你的机器能使用多少核。这也就是说同时运行的其他进程,或者线程关联性(强制线程在某些核或CPU上运行)会影响性能。

  • 单元处理开销

比如数据大小,这是一场并行执行花费时间和分解合并操作开销之间的战争。花在流中每个元素身上的时间越长,并行操作带来的性能提升越明显。

使用并行流框架,理解如何分解和合并问题是很有帮助的。这让我们能够知悉底层如何工作,但却不必了解框架的细节。

来看一个具体的问题,看看如何分解和合并它。例6-6是并行求和的代码。

例6-6 并行求和

  1. private int addIntegers(List<Integer> values) {
  2. return values.parallelStream()
  3. .mapToInt(i -> i)
  4. .sum();
  5. }

在底层,并行流还是沿用了fork/join框架。fork递归式地分解问题,然后每段并行执行,最终由join合并结果,返回最后的值。

图6-2形象地展示了例6-6中代码所示的操作。

6.6 性能 - 图1

图6-2:使用fork/join分解合并问题

假设并行流将我们的工作分解开,在一个四核的机器上并行执行。

  1. 数据被分成四块。

  2. 如6-6所示,计算工作在每个线程里并行执行。这包括将每个Integer对象映射为int值,然后在每个线程里将1/4的数字相加。理想情况下,我们希望在这里花的时间越多越好,因为这里是并行操作的最佳场所。

  3. 然后合并结果。在例6-6中,就是sum操作,但这也可能是reducecollect或其他终结操作。

根据问题的分解方式,初始的数据源的特性变得尤其重要,它影响了分解的性能。直观上看,能重复将数据结构对半分解的难易程度,决定了分解操作的快慢。能对半分解同时意味着待分解的值能够被等量地分解。

我们可以根据性能的好坏,将核心类库提供的通用数据结构分成以下3组。

  • 性能好

ArrayList、数组或IntStream.range,这些数据结构支持随机读取,也就是说它们能轻而易举地被任意分解。

  • 性能一般

HashSetTreeSet,这些数据结构不易公平地被分解,但是大多数时候分解是可能的。

  • 性能差

有些数据结构难于分解,比如,可能要花O(N)的时间复杂度来分解问题。其中包括LinkedList,对半分解太难了。还有Streams.iterateBufferedReader.lines,它们长度未知,因此很难预测该在哪里分解。

初始的数据结构影响巨大。举一个极端的例子,对比对10 000个整数并行求和,使用ArrayList要比使用LinkedList快10倍。这不是说业务逻辑的性能情况也会如此,只是说明了数据结构对于性能的影响之大。使用形如LinkedList这样难于分解的数据结构并行运行可能更慢。

理想情况下,一旦流框架将问题分解成小块,就可以在每个线程里单独处理每一小块,线程之间不再需要进一步通信。无奈现实不总遂人愿!

在讨论流中单独操作每一块的种类时,可以分成两种不同的操作:无状态的有状态的。无状态操作整个过程中不必维护状态,有状态操作则有维护状态所需的开销和限制。

如果能避开有状态,选用无状态操作,就能获得更好的并行性能。无状态操作包括mapfilterflatMap,有状态操作包括sorteddistinctlimit

6.6 性能 - 图2要对自己的代码进行性能测试。本节只给出了哪些性能特征需要调查,但什么都比不上实际的测试和分析。