Java 8的流:为什么并行流速度更慢?

68

我正在使用Java 8的Streams玩耍,但无法理解我得到的性能结果。我的CPU有2个核心(Intel i73520M),Windows 8 x64和64位的Java 8更新5。我正在对String流/并行流进行简单的映射,发现并行版本略慢。

Function<Stream<String>, Long> timeOperation = (Stream<String> stream) -> {
  long time1 = System.nanoTime();
  final List<String> list = 
     stream
       .map(String::toLowerCase)
       .collect(Collectors.toList());
  long time2 = System.nanoTime();
  return time2 - time1;
};

Consumer<Stream<String>> printTime = stream ->
  System.out.println(timeOperation.apply(stream) / 1000000f);

String[] array = new String[1000000];
Arrays.fill(array, "AbabagalamagA");

printTime.accept(Arrays.stream(array));            // prints around 600
printTime.accept(Arrays.stream(array).parallel()); // prints around 900

考虑到我有2个CPU核心,并行版本不应该更快吗?有人能给我一个提示,为什么并行版本更慢吗?

4个回答

173

这里有几个问题同时存在。

首先,以并行方式解决问题始终涉及执行更多的实际工作而不是顺序执行。在将工作分配给多个线程并合并结果时会涉及到开销。像将短字符串转换为小写之类的问题非常小,容易被并行拆分的开销淹没。

第二个问题是Java程序的基准测试非常微妙,很容易得到令人困惑的结果。两个常见问题是JIT编译和死代码消除。短暂的基准测试通常在JIT编译期间或之前就完成了,因此它们没有测量峰值吞吐量,而实际上它们可能正在测量JIT本身。编译何时发生是有些不确定性的,所以它可能导致结果也变化巨大。

对于小型的模拟基准测试,工作负载通常计算出被丢弃的结果。JIT编译器非常擅长检测到这一点,并消除不产生任何用处的代码。虽然在这种情况下可能不会发生,但如果您试图进行其他模拟工作负载的调整,它肯定会发生。当然,如果JIT消除了基准工作负载,它将使基准测试无用。

我强烈建议使用一个成熟的基准测试框架(如JMH)而不是自己编写一个。JMH具有帮助避免常见基准测试陷阱的功能,包括这些问题,并且设置和运行相当容易。以下是将您的基准测试转换为使用JMH的结果:

package com.stackoverflow.questions;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.concurrent.TimeUnit;

import org.openjdk.jmh.annotations.*;

public class SO23170832 {
    @State(Scope.Benchmark)
    public static class BenchmarkState {
        static String[] array;
        static {
            array = new String[1000000];
            Arrays.fill(array, "AbabagalamagA");
        }
    }

    @GenerateMicroBenchmark
    @OutputTimeUnit(TimeUnit.SECONDS)
    public List<String> sequential(BenchmarkState state) {
        return
            Arrays.stream(state.array)
                  .map(x -> x.toLowerCase())
                  .collect(Collectors.toList());
    }

    @GenerateMicroBenchmark
    @OutputTimeUnit(TimeUnit.SECONDS)
    public List<String> parallel(BenchmarkState state) {
        return
            Arrays.stream(state.array)
                  .parallel()
                  .map(x -> x.toLowerCase())
                  .collect(Collectors.toList());
    }
}

我使用以下命令来运行这个程序:

java -jar dist/microbenchmarks.jar ".*SO23170832.*" -wi 5 -i 5 -f 1

选项指定了五个热身迭代、五个基准测试迭代和一个分叉的JVM。在运行过程中,JMH会发出大量冗长的消息,我已经省略了它们。总体结果如下。

Benchmark                       Mode   Samples         Mean   Mean error    Units
c.s.q.SO23170832.parallel      thrpt         5        4.600        5.995    ops/s
c.s.q.SO23170832.sequential    thrpt         5        1.500        1.727    ops/s

请注意结果以每秒操作数为单位,因此并行运行似乎比顺序运行快了约三倍。但我的机器只有两个核心。嗯。每次运行的平均误差实际上比平均运行时间还要大!什么?这里有些可疑。

这带我们来到第三个问题。更仔细地查看工作负载,我们可以看到它为每个输入分配一个新的字符串对象,并将结果收集到列表中,这涉及大量的重新分配和复制。我猜这会导致相当数量的垃圾回收。我们可以通过启用GC消息重新运行基准测试来看到这一点:

java -verbose:gc -jar dist/microbenchmarks.jar ".*SO23170832.*" -wi 5 -i 5 -f 1

这将产生类似以下的结果:

[GC (Allocation Failure)  512K->432K(130560K), 0.0024130 secs]
[GC (Allocation Failure)  944K->520K(131072K), 0.0015740 secs]
[GC (Allocation Failure)  1544K->777K(131072K), 0.0032490 secs]
[GC (Allocation Failure)  1801K->1027K(132096K), 0.0023940 secs]
# Run progress: 0.00% complete, ETA 00:00:20
# VM invoker: /Users/src/jdk/jdk8-b132.jdk/Contents/Home/jre/bin/java
# VM options: -verbose:gc
# Fork: 1 of 1
[GC (Allocation Failure)  512K->424K(130560K), 0.0015460 secs]
[GC (Allocation Failure)  933K->552K(131072K), 0.0014050 secs]
[GC (Allocation Failure)  1576K->850K(131072K), 0.0023050 secs]
[GC (Allocation Failure)  3075K->1561K(132096K), 0.0045140 secs]
[GC (Allocation Failure)  1874K->1059K(132096K), 0.0062330 secs]
# Warmup: 5 iterations, 1 s each
# Measurement: 5 iterations, 1 s each
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: com.stackoverflow.questions.SO23170832.parallel
# Warmup Iteration   1: [GC (Allocation Failure)  7014K->5445K(132096K), 0.0184680 secs]
[GC (Allocation Failure)  7493K->6346K(135168K), 0.0068380 secs]
[GC (Allocation Failure)  10442K->8663K(135168K), 0.0155600 secs]
[GC (Allocation Failure)  12759K->11051K(139776K), 0.0148190 secs]
[GC (Allocation Failure)  18219K->15067K(140800K), 0.0241780 secs]
[GC (Allocation Failure)  22167K->19214K(145920K), 0.0208510 secs]
[GC (Allocation Failure)  29454K->25065K(147456K), 0.0333080 secs]
[GC (Allocation Failure)  35305K->30729K(153600K), 0.0376610 secs]
[GC (Allocation Failure)  46089K->39406K(154624K), 0.0406060 secs]
[GC (Allocation Failure)  54766K->48299K(164352K), 0.0550140 secs]
[GC (Allocation Failure)  71851K->62725K(165376K), 0.0612780 secs]
[GC (Allocation Failure)  86277K->74864K(184320K), 0.0649210 secs]
[GC (Allocation Failure)  111216K->94203K(185856K), 0.0875710 secs]
[GC (Allocation Failure)  130555K->114932K(199680K), 0.1030540 secs]
[GC (Allocation Failure)  162548K->141952K(203264K), 0.1315720 secs]
[Full GC (Ergonomics)  141952K->59696K(159232K), 0.5150890 secs]
[GC (Allocation Failure)  105613K->85547K(184832K), 0.0738530 secs]
1.183 ops/s

注意:以#开头的行是正常的JMH输出行,其余都是GC消息。这只是前五次热身迭代中的第一次,之后还有五次基准迭代。在接下来的迭代中,GC消息仍将继续。我认为可以肯定地说,测量性能受到GC开销的支配,并且不应该相信报告的结果。

此时还不清楚该怎么做。这纯粹是一个人工合成的工作负载。与分配和复制相比,显然实际工作所需的CPU时间很少。很难说你真正想要衡量什么。一种方法是提出一个在某种意义上更“真实”的不同工作负载。另一种方法是更改堆和GC参数,以避免在基准运行期间进行GC。


26
非常详尽的回答,以及如何正确运行和解读微基准测试的好教程! +1 - assylias
我知道这篇帖子很老了,但是通过阅读它,(a)我可以说我学到了很多东西 - 比如微基准测试,以及(b)我有一个问题:通过说“我认为可以肯定地说,测量的性能受垃圾回收开销的影响最大”,这个数据是如何呈现的?有人能详细解释一下吗? - hephestos
@hephestos 主要线索是平均误差大于实际结果。这意味着每次迭代的时间差异很大。理想情况下,您希望所有迭代都非常接近,方差仅为几个百分点。我通过启用GC日志记录来确定GC引起了方差。基准测试的工作原理是在迭代开始时和结束时记录时间戳。时间差被假定为您正在测量的工作负载所需的CPU时间。 - Stuart Marks
@hephestos 但如果有很多GC,情况就不一样了。有时应用程序线程会被阻塞,等待GC释放内存。如果这发生在基准迭代期间,则该迭代的时间包括实际工作和等待GC的时间。在JMH中,默认情况下每次迭代为1秒。查看我发布的GC日志,其中一些GC周期需要0.10、0.13、0.51秒的时间。将GC时间加起来占1秒的比例相当大。因此,那个1秒的迭代很可能超过一半的时间被GC所占据。这就是我所说的“主导”意思。 - Stuart Marks
2
我喜欢@StuartMarks在stackoverflow上维护他的答案项目,package com.stackoverflow.questions;public class SO23170832 - drac_o

18

在进行基准测试时,您应该关注JIT编译,并且计时行为可能会根据JIT编译代码路径的数量而改变。如果我将一个预热阶段添加到您的测试程序中,则并行版本比顺序版本快一点。以下是结果:

Warmup...
Benchmark...
Run 0:  sequential 0.12s  -  parallel 0.11s
Run 1:  sequential 0.13s  -  parallel 0.08s
Run 2:  sequential 0.15s  -  parallel 0.08s
Run 3:  sequential 0.12s  -  parallel 0.11s
Run 4:  sequential 0.13s  -  parallel 0.08s
以下代码片段包含了我在此测试中使用的完整源代码。
public static void main(String... args) {
    String[] array = new String[1000000];
    Arrays.fill(array, "AbabagalamagA");
    System.out.println("Warmup...");
    for (int i = 0; i < 100; ++i) {
        sequential(array);
        parallel(array);
    }
    System.out.println("Benchmark...");
    for (int i = 0; i < 5; ++i) {
        System.out.printf("Run %d:  sequential %s  -  parallel %s\n",
            i,
            test(() -> sequential(array)),
            test(() -> parallel(array)));
    }
}
private static void sequential(String[] array) {
    Arrays.stream(array).map(String::toLowerCase).collect(Collectors.toList());
}
private static void parallel(String[] array) {
    Arrays.stream(array).parallel().map(String::toLowerCase).collect(Collectors.toList());
}
private static String test(Runnable runnable) {
    long start = System.currentTimeMillis();
    runnable.run();
    long elapsed = System.currentTimeMillis() - start;
    return String.format("%4.2fs", elapsed / 1000.0);
}

10

使用多个线程来处理数据具有一些初始设置成本,例如初始化线程池。这些成本可能会超过使用这些线程带来的收益,特别是如果运行时已经非常低。此外,如果存在争用,例如其他正在运行的线程、后台进程等,则并行处理的性能可能进一步降低。

对于并行处理,这个问题并不新鲜。本文将在Java 8 parallel()的背景下提供一些细节以及需要考虑的其他事项:https://dzone.com/articles/think-twice-using-java-8


2
这里的一般论点是正确的(并行解决方案通常具有与顺序解决方案相同的所有成本,以及特定于并行化的其他成本),但您列出的主要论点——初始化线程池——是最不重要的之一。更重要的成本包括将较大的任务分割为较小的任务,排队和调度任务(可能涉及争用),组合子任务的成本等。 - Brian Goetz

2
Java中的Stream实现默认为顺序执行,除非明确指定为并行。当流以并行方式执行时,Java运行时将流分成多个子流。聚合操作并行迭代和处理这些子流,然后组合结果。 因此,如果开发人员在顺序流中有性能问题,则可以使用并行流。 请查看性能比较: https://github.com/prathamket/Java-8/blob/master/Performance_Implications.java 您将获得有关性能的总体概念。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接