【问题标题】:Why is a parallel stream slower than a sequential stream?为什么并行流比顺序流慢?
【发布时间】:2015-02-12 10:54:54
【问题描述】:

我有两个函数可以解决同一个问题。 第一个使用顺序流,第二个使用并行流。

public static int digitalRoot(int n) {
    int sum = String.valueOf(n).chars().map(i -> Integer.parseInt(String.valueOf((char) i))).sum();
    if (sum >= 10) {
        return digitalRoot(sum);
    } else {
        return sum;
    }
}

public static int digitalRootParallel(int n) {
    int sum = String.valueOf(n).chars().parallel().
            map(i -> Integer.parseInt(String.valueOf((char) i))).sum();
    if (sum >= 10) {
        return digitalRootParallel(sum);
    } else {
        return sum;
    }
}

我执行了一次这些功能。 并行函数 digitalRootParallel() 比顺序函数 (32 ms) 更快 (5 ms) digitalRoot()

但是当我在 1.000.000 的循环中执行它们时,顺序 (117 ms) 比并行 (1124 ms) 快。

    for (int i = 0; i < 1000000; i++) {
        sum = digitalRoot(n);
    }

为什么带有并行流的循环更慢?

【问题讨论】:

    标签: java java-8 java-stream


    【解决方案1】:

    我能分享的只是让我感到惊讶的是,除了并行版本的减速之外,您从未测量过任何东西。你几乎没有给 Fork/Join 做任何工作:只是解析和总结十位数。 F/J 可能会认为甚至不值得拆分它,并将在单个线程上执行整个计算,但其中涉及的开销会扼杀性能。

    如果您想看到并行化的任何好处,请确保您有至少半秒的顺序计算,并且可以轻松拆分为子任务。

    【讨论】:

    • 实际上,F/J 不会决定不值得拆分它,因为它没有必要的知识来做出这样的决定。我对其进行了测试,它确实使用了多个线程,当然,没有任何好处。我在my answer 中添加了一些关于实现的有趣细节(这对这个简单的操作并不重要……)
    • 它实际上会做一些比决定不拆分更糟糕的事情:它将所有内容“拆分”成一个批次,并再次使用单个线程来完成所有计算。对于最大 1024 的尺寸,它将继续这样做。我已经花了一些时间来反对这种拆分政策 :)
    • 一旦流有一个数组,它可以通过使用该数组的索引再次拆分,而无需复制。在我的机器(4 核,1.8.0_40)上,它进行了子阵列拆分,但它也以 2 的批次大小开始,为每个新批次增加它。如果估计大小大于 1024,则 1024 将是起始批量大小。我不知道其他实现如何,但这里 Spliterators$IntIteratorSpliterator.trySplit 包含 long s = est; if (s &gt; 1 &amp;&amp; i.hasNext()) { int n = batch + BATCH_UNIT; if (n &gt; s) n = (int) s; 显然将 n 限制为 est,这是估计的(剩余)大小。
    • 顺便说一句,我做的第一件事是在 lambda 表达式中添加 System.out.println(Thread.currentThread());,这让我对并行执行有了更多的了解,而不是干巴巴地看代码。下一步是在 lambda 表达式处设置一个断点,并查看调用 Spliterator 的实际数组。虽然我有点误会,因为我认为它不知道确切的大小,因为PrimitiveIterator 没有提供,但Spliterators.spliterator(…) 将大小作为参数......所以我看到了相当小的批次,但当然,这些就是由于来源小而小。
    • 我认为这个案例和我之前的工作的不同之处在于我有est == Long.MAX_VALUE,这就是为什么 F/J 总是喜欢阅读更多的批次而不是将现有的批次拆分成更小的批次。当est 是有限的并且在至少availableProcessors() 子任务创建之前开始接近零时,F/J 可能会决定剖析现有的批次,无论多么小。
    【解决方案2】:

    首先,您的代码……有点复杂。而不是

    String.valueOf(n).chars().map(i -> Integer.parseInt(String.valueOf((char) i))).sum();
    

    你可以写

    String.valueOf(n).chars().map(i -> i-'0').sum();
    

    但是,即使使用您的原始代码,操作也非常简单,以至于 HotSpot 优化器可以将其转换为如此短的代码,以至于它的执行速度比任何新线程的启动或同步都快。

    这尤其适用于您多次执行操作而不实际使用其结果的基准测试。在纯顺序上下文中,优化器可以识别出结果未使用,如果没有任何副作用,则忽略整个操作。相反,并行执行或更抽象的代码与其他线程通信具有副作用。

    因此,优化器不能对跨多个线程的操作做同样的事情。 Stream 实现也无法识别操作的简单性。如果您请求并行执行,Stream 实现将尊重它,假设您知道自己在做什么。


    补充说明:

    String.chars()当前 实现的一个有趣属性是它创建了一个 PrimitiveIterator.OfInt,该Spliterator.OfInt 将包装在一个Spliterator.OfInt 中,而不是直接实现Spliterator.OfInt。这意味着没有直接拆分功能,因为PrimitiveIterator.OfInt 不提供该功能。相反,拆分将通过迭代一个或多个元素来执行,将它们复制到一个数组中,并提供这个数组供不同的线程处理。

    但迭代和复制到临时数组可能比顺序执行中的实际操作花费更多时间。

    这些较差的拆分功能是 known issue,将在 Java 9 中解决。但是,您的操作不太可能从并行执行中受益。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2022-06-16
      • 2021-06-18
      • 2018-08-16
      • 1970-01-01
      • 2017-11-09
      相关资源
      最近更新 更多