Java并行流的性能影响

时间:2019-02-07 19:26:33

标签: java concurrency parallel-processing java-stream forkjoinpool

使用.stream().parallel()的最佳实践是什么?

例如,如果您有一堆阻塞的I / O调用,并且想检查.anyMatch(...)是否为并行操作,这似乎是明智的选择。

示例代码:

public boolean hasAnyRecentReference(JobId jobid) {
  <...>
  return pendingJobReferences.stream()
     .parallel()
     .anyMatch(pendingRef -> { 
       JobReference readReference = pendingRef.sync();
       Duration referenceAge = timeService.timeSince(readReference.creationTime());
       return referenceAge.lessThan(maxReferenceAge)
     });
}

乍看之下这很明智,因为我们可以同时执行多个阻塞读取,因为我们只关心匹配的内容,而不是一个接一个地检查(因此,如果每次读取都花费50ms,我们只需要等待( 50ms * expectedNumberOfNonRecentRefs )/ numThreads )。

在生产环境中引入此代码是否会对代码库的其他部分产生无法预料的性能影响?

1 个答案:

答案 0 :(得分:2)

编辑:正如@edharned指出的那样,.parallel()现在使用CountedCompleter而不是调用.join(),这有其自身的问题,以及Ed在http://coopsoft.com/ar/Calamity2Article.html下的解释。 What is currently being done?部分。

我相信下面的信息对于理解为什么fork-join框架是棘手的,并且结论中为.parallel()提出的替代方案仍然有意义仍然有用。


尽管代码的精神是正确的,但实际的代码可能对使用.parallel()的所有代码产生系统范围的影响,即使这并不明显。

前一段时间,我发现了一篇不建议这样做的文章:https://dzone.com/articles/think-twice-using-java-8,但直到最近我才更深入地研究。

这些是我在阅读一堆之后的想法:

    Java中的
  1. .parallel()使用ForkJoinPool.commonPool(),它是所有流共享的单例ForkJoinPoolForkJoinPool.commonPool()是公共静态方法,因此从理论上讲,其他库/部分代码可能正在使用它)
  2. ForkJoinPool实现了窃取工作,除共享队列外,还具有每个线程的队列

    1. 偷工作意味着线程空闲时,它将寻找更多工作要做
    2. 最初,我认为:按照这个定义,cached线程池也不会窃取工作吗(即使某些引用调用了缓存线程池的工作共享)?
    3. 结果发现,使用“ idle”一词时似乎存在一些术语模糊性:

      1. cached线程池中,线程只有在完成其任务后才处于空闲状态。如果它在等待阻塞呼叫时被阻塞,它不会不会变为空闲状态
      2. forkjoin线程池中,线程在完成其任务时或在子任务上调用.join()方法(这是一个特殊的阻塞调用)时都是空闲的。 / p>

        在子任务上调用.join()时,线程在等待该子任务完成时变为空闲状态。空闲时,即使它在另一个线程的队列中(它将窃取工作),它也会尝试执行任何其他可用任务。

        [这很重要] 一旦找到另一个要执行的任务,就必须在恢复其原始执行之前完成该任务,即使它正在等待的子任务在线程仍处于运行状态时也已完成。执行被盗的任务。

        [这也很重要] 。这种窃取工作的行为仅适用于调用.join()的线程。如果某个线程在诸如I / O之类的其他事物上阻塞,则它将变为空闲状态(即它不会窃取工作)。

  3. Java流不允许您提供自定义的ForkJoinPool,但是https://github.com/amaembo/streamex可以提供

我花了一些时间来了解2.3.2的含义,因此我将举一个简单的例子来说明这个问题:

  

注意:这些是虚拟的示例,但是您可以进入等价情况,而无需使用内部在内部进行派生连接的流实现流。

     

此外,我将使用极为简化的伪代码,该伪代码仅用于说明.parallel()问题,而在其他方面不一定有意义。

假设我们正在实施合并排序

merge_sort(list):
    left, right = split(list)

    leftTask = mergeSortTask(left).fork()
    rightTask = mergeSortTaks(right).fork()

    return merge(leftTask.join(), rightTask.join())

现在让我们说另一段执行以下操作的代码:

dummy_collect_results(queriesIds):
   pending_results = []

   for id in queriesIds: 
     pending_results += longBlockingIOTask(id).fork()

  // do more stuff

这里会发生什么?

编写合并排序代码时,您认为排序调用不执行任何I / O,因此它们的性能应该是确定性的,对吧?

对。您可能不会想到的是,由于dummy_collect_results方法创建了许多长时间运行且阻塞的子任务,因此当执行mergesort任务的线程在.join()上阻塞并等待子任务完成时,它们可能开始执行长时间阻塞的子任务之一。

这很不好,因为如上所述,一旦长时间阻塞(在I / O上,没有进行.join()调用,这样线程就不会再次变为空闲状态)被盗,则必须将其完成,不管线程在阻塞I / O时是否正在通过.join()等待的子任务完成。

这使得合并排序任务的执行不再是确定性的,因为执行这些任务的线程可能最终会窃取完全驻留在其他地方的代码所生成的I / O密集型任务。

这也非常令人恐惧和难以捉摸,因为您可能在整个代码库中一直使用.parallel()而没有任何问题,而所需要的只是一个类,它在使用.parallel()时引入了长期运行的任务突然之间,代码库的所有其他部分可能会获得不一致的性能。

所以我的结论是:

  1. 从理论上讲,.parallel()可以保证可以在代码中任何地方创建的所有任务都简短
  2. 除非您知道,否则
  3. .parallel()可能会对系统范围的性能产生不明显的影响(例如,如果以后添加使用.parallel()并且任务很长的一段代码,则可能会影响使用.parallel())的所有代码的性能
  4. 由于2.,您最好完全避免使用.parallel(),而可以使用ExecutorCompletionServicehttps://github.com/amaembo/streamex来提供自己的ForkJoinPool (这允许更多隔离)。更好的是,您可以使用https://github.com/palantir/streams/blob/1.9.1/src/main/java/com/palantir/common/streams/MoreStreams.java#L53,它使您可以更精细地控制并发机制。