Effective Java(3rd)-Item48 在使流并行时要小心

  在主流语言中,Java一直处于提供便利来简化并发编程任务的最前沿。当Java在1996年发布时,它内置了对线程的支持,包括同步和wait/notify。Java 5引入了Java .util.并发库,具有并发集合和执行器框架。Java 7引入了fork-join包,这是一个用于并行分解的高性能框架。Java 8引入了流,它可以通过对parallel方法的单个调用并行化。用Java编写并发程序变得越来越容易,但是编写正确且快速的并发程序和以前一样困难。违反安全性和活性是并发编程中的一个事实,并行流管道也不例外。
  考虑项目45中的这个项目:


Effective Java(3rd)-Item48 在使流并行时要小心_第1张图片
image.png

  在我的机器上,这个程序立即开始打印素数,运行12.5秒才能完成。假设我天真地试图通过向流管道添加一个parallel()调用来加速它。你认为它的表现会怎样?它会加快几个百分点吗?慢几个百分点?遗憾的是,它不会打印任何东西,但是CPU使用率会飙升到90%并无限期地停留在那里(活动失败)。这个项目可能最终会终止,但我不愿意去发现;半小时后我强行把它停了下来。
  这是怎么回事?简单地说,streams库不知道如何并行化这个管道,因此试探失败。即使在最好的情况下,如果源来自Stream.iterate,或者使用中间操作限制,那么并行化管道不太可能提高其性能。这个管道必须同时处理这两个问题。更糟的是,默认的并行化策略通过假设处理一些额外的元素和丢弃任何不需要的结果没有害处来处理极限的不可预测性。在这种情况下,找到每一个梅森质数所需的时间大约是找到前一个梅森质数所需时间的两倍。因此,计算单个额外元素的成本大约等于计算前面所有元素的总和,而这个看起来没什么问题的管道使自动并行算法陷入了瘫痪。这个故事的寓意很简单:性能结果可能是灾难性的。
  通常,并行性带来的性能收益在ArrayList、HashMap、HashSet和ConcurrentHashMap实例上的流上最好;数组;int范围;和long 的范围。这些数据结构的共同之处在于,它们都可以精确而廉价地划分为任意大小的子程序,这使得在并行线程之间划分工作变得很容易。streams库用于执行此任务的抽象是spliterator,它由Stream和Iterable上的spliterator方法返回。
  所有这些数据结构的另一个重要共同点是,当按顺序处理时,它们提供了从优秀到卓越的引用位置:顺序元素引用一起存储在内存中。这些引用引用的对象在内存中可能彼此不接近,从而降低了引用的位置。引用的位置对于并行化大量操作非常重要:如果没有引用,线程将花费大量时间空闲,等待数据从内存传输到处理器的缓存中。具有最佳引用局部性的数据结构是基本数组,因为数据本身连续地存储在内存中。
  流管道终端操作的性质也影响并行执行的效率。如果在终端操作中所做的大量工作与管道的总体工作相比,并且该操作本质上是顺序的,那么并行化管道的效率将有限。并行性的最佳终端操作是缩减,即使用Stream的其中一种缩减方法或预先打包的缩减(如min、max、count和sum)组合管道中出现的所有元素。短路操作anyMatch、allMatch和noneMatch也支持并行性。流的collect方法执行的操作(称为可变约简)不适合并行性,因为组合集合的开销很昂贵。
  如果您编写自己的流、可迭代的或集合实现,并且希望获得良好的并行性能,则必须覆盖spliterator方法,并广泛地测试结果流的并行性能。编写高质量的spliterator是困难的,超出了本书的范围。
  并行化流不仅会导致性能低下,包括活动失败;它可能导致不正确的结果和不可预测的行为。安全性的失败)。安全故障可能是由于并行化使用映射器、过滤器和其他程序员提供的函数对象的管道而导致的,这些函数对象不能遵守它们的规范。流规范对这些功能对象提出了严格的要求。例如,传递给Stream reduce操作的累加器和组合器函数必须是关联的、非干扰的和无状态的。如果您违反了这些要求(第46项中讨论了其中一些要求),但按顺序运行管道,则可能会得到正确的结果;如果您将其并行化,它很可能会失败,可能是灾难性的。
  沿着这些线,值得注意的是,即使并行化的Mersenne素数程序已经运行到完成,它也不会以正确的(升序)顺序打印素数。为了保持顺序版本显示的顺序,您必须用forEachOrdered替换forEach终端操作,该操作保证按偶遇顺序遍历并行流。
  即使假设您使用的是有效的可分割源流、可并行化或廉价的终端操作以及非干扰的函数对象,您也不会从并行化中获得良好的加速,除非管道做了足够多的实际工作来抵消与并行性相关的成本。作为一个非常粗略的估计,流中的元素数量乘以每个元素执行的代码行数至少应该是100,000 [Lea14]。
  重要的是要记住,并行化流严格来说是一种性能优化。与任何优化一样,您必须在更改之前和之后测试性能,以确保这是值得做的(第67项)。理想情况下,您应该在实际的系统设置中执行测试。通常,程序中的所有并行流管道都在公共fork-join池中运行。一个行为不端的管道可能会损害系统中其他不相关部分的性能。
  如果在并行化流管道时,您可能会遇到一些困难,那是因为它们确实存在。我的一个熟人维护着一个大量使用流的数百万在线代码库,他发现只有少数几个地方并行流是有效的。这并不意味着您应该避免并行化流。在适当的环境下,只要向流管道添加一个并行调用,就可以实现处理器内核数量的近乎线性的加速。某些领域,如机器学习和数据处理,特别适合这些加速。
  作为一个简单的例子,一个流管道并行性是有效的,考虑这个函数计算π(n),质数数目小于或等于n:

Effective Java(3rd)-Item48 在使流并行时要小心_第2张图片
image.png

  在我的机器上,需要31秒计算π(108)使用这个函数。简单地添加parallel()调用可以将时间缩短到9.2秒:


Effective Java(3rd)-Item48 在使流并行时要小心_第3张图片
image.png

  换句话说,在我的四核计算机上,并行化计算速度提高了3.7倍。值得注意的是,这不是你如何计算π(n)为大n的值。还有更有效的算法,尤其是Lehmer公式。
  如果您要并行化一个随机数流,请从SplittableRandom实例开始,而不是ThreadLocalRandom(或者本质上已经过时的random)。SplittableRandom正是为这种用途而设计的,并且具有线性加速的潜力。hreadLocalRandom是为单线程使用而设计的,它将适应作为并行流源的功能,但不会像SplittableRandom那样快。Random对每个操作都进行同步,因此它会导致过多的并行性争用。
  总之,除非您有充分的理由相信流管道可以保持计算的正确性并提高其速度,否则甚至不要尝试并行化流管道。不适当地并行流的代价可能是程序失败或性能灾难。如果您认为并行性是合理的,那么确保您的代码在并行运行时保持正确,并在实际条件下进行仔细的性能度量。如果您的代码仍然正确,并且这些实验证实了您对性能提高的怀疑,那么,并且只有在那时并行化。
本文写于2019.7.16,历时1天

你可能感兴趣的:(Effective Java(3rd)-Item48 在使流并行时要小心)