【问题标题】:Java 8: performance of Streams vs CollectionsJava 8:流与集合的性能
【发布时间】:2014-05-04 16:44:10
【问题描述】:

我是 Java 8 的新手。我仍然不深入了解 API,但我做了一个小型的非正式基准测试来比较新 Streams API 与良好的旧 Collections 的性能。

测试包括过滤Integer 的列表,并为每个偶数计算平方根并将其存储在ListDouble 的结果中。

代码如下:

    public static void main(String[] args) {
        //Calculating square root of even numbers from 1 to N       
        int min = 1;
        int max = 1000000;

        List<Integer> sourceList = new ArrayList<>();
        for (int i = min; i < max; i++) {
            sourceList.add(i);
        }

        List<Double> result = new LinkedList<>();


        //Collections approach
        long t0 = System.nanoTime();
        long elapsed = 0;
        for (Integer i : sourceList) {
            if(i % 2 == 0){
                result.add(Math.sqrt(i));
            }
        }
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Collections: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


        //Stream approach
        Stream<Integer> stream = sourceList.stream();       
        t0 = System.nanoTime();
        result = stream.filter(i -> i%2 == 0).map(i -> Math.sqrt(i)).collect(Collectors.toList());
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Streams: Elapsed time:\t\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


        //Parallel stream approach
        stream = sourceList.stream().parallel();        
        t0 = System.nanoTime();
        result = stream.filter(i -> i%2 == 0).map(i -> Math.sqrt(i)).collect(Collectors.toList());
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Parallel streams: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));      
    }.

以下是双核机器的结果:

    Collections: Elapsed time:        94338247 ns   (0,094338 seconds)
    Streams: Elapsed time:           201112924 ns   (0,201113 seconds)
    Parallel streams: Elapsed time:  357243629 ns   (0,357244 seconds)

对于这个特定的测试,流的速度大约是集合的两倍,并且并行性没有帮助(或者我使用它的方式错误?)。

问题:

  • 这个测试公平吗?我犯了什么错误吗?
  • 流比集合慢吗?有没有人为此制定了良好的正式基准?
  • 我应该争取哪种方法?

更新结果。

按照@pveentjer 的建议,我在 JVM 预热(1k 次迭代)后运行了 1k 次测试:

    Collections: Average time:      206884437,000000 ns     (0,206884 seconds)
    Streams: Average time:           98366725,000000 ns     (0,098367 seconds)
    Parallel streams: Average time: 167703705,000000 ns     (0,167704 seconds)

在这种情况下,流的性能更高。我想知道在运行时仅调用一次或两次过滤功能的应用程序中会观察到什么。

【问题讨论】:

  • 你试过用IntStream代替吗?
  • 你能正确测量吗?如果您所做的只是一次运行,那么您的基准测试当然会关闭。
  • @MisterSmith 我们能否对您如何预热 JVM 以及 1K 测试提供一些透明度?
  • 对于那些有兴趣编写正确的微基准测试的人,问题是:stackoverflow.com/questions/504103/…
  • @assylias 使用toList 应该并行运行,即使它正在收集到非线程安全列表,因为不同的线程在合并之前会收集到线程受限的中间列表。

标签: java performance collections java-8 java-stream


【解决方案1】:
  1. 停止使用 LinkedList 进行除使用迭代器从列表中间大量删除之外的任何操作。

  2. 停止手工编写基准测试代码,使用JMH

适当的基准:

@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@OperationsPerInvocation(StreamVsVanilla.N)
public class StreamVsVanilla {
    public static final int N = 10000;

    static List<Integer> sourceList = new ArrayList<>();
    static {
        for (int i = 0; i < N; i++) {
            sourceList.add(i);
        }
    }

    @Benchmark
    public List<Double> vanilla() {
        List<Double> result = new ArrayList<>(sourceList.size() / 2 + 1);
        for (Integer i : sourceList) {
            if (i % 2 == 0){
                result.add(Math.sqrt(i));
            }
        }
        return result;
    }

    @Benchmark
    public List<Double> stream() {
        return sourceList.stream()
                .filter(i -> i % 2 == 0)
                .map(Math::sqrt)
                .collect(Collectors.toCollection(
                    () -> new ArrayList<>(sourceList.size() / 2 + 1)));
    }
}

结果:

Benchmark                   Mode   Samples         Mean   Mean error    Units
StreamVsVanilla.stream      avgt        10       17.588        0.230    ns/op
StreamVsVanilla.vanilla     avgt        10       10.796        0.063    ns/op

正如我预期的那样,流实现相当慢。 JIT 能够内联所有 lambda 内容,但不能生成像 vanilla 版本那样完美简洁的代码。

一般来说,Java 8 流并不神奇。他们无法加速已经很好实现的事情(可能用简单的迭代或 Java 5 的 for-each 语句替换为 Iterable.forEach()Collection.removeIf() 调用)。 Streams 更多的是关于编码的便利性和安全性。方便 - 速度权衡在这里起作用。

【讨论】:

  • 感谢您抽出宝贵的时间来替补。我不认为更改 ArrayList 的 LinkedList 会改变任何东西,因为两个测试都应该添加到它,时间不应该受到影响。无论如何,你能解释一下结果吗?很难说你在这里测量的是什么(单位说 ns/op,但什么被认为是 op?)。
  • 您关于性能的结论虽然有效,但被夸大了。在很多情况下,流代码比迭代代码,主要是因为流的每个元素访问成本比普通迭代器便宜。在许多情况下,流版本内联到与手写版本等效的内容。当然,魔鬼在细节中。任何给定的代码都可能表现不同。
  • @BrianGoetz,您能否指定用例,什么时候流更快?
  • 在 FMH 的最后一个版本中:使用 @Benchmark 而不是 @GenerateMicroBenchmark
  • @BrianGoetz,您能否指定用例,什么时候流更快?
【解决方案2】:

1) 您使用基准测试发现时间不到 1 秒。这意味着副作用可能会对您的结果产生很大影响。所以,我把你的任务增加了 10 倍

    int max = 10_000_000;

并运行您的基准测试。我的结果:

Collections: Elapsed time:   8592999350 ns  (8.592999 seconds)
Streams: Elapsed time:       2068208058 ns  (2.068208 seconds)
Parallel streams: Elapsed time:  7186967071 ns  (7.186967 seconds)

未经编辑 (int max = 1_000_000) 结果是

Collections: Elapsed time:   113373057 ns   (0.113373 seconds)
Streams: Elapsed time:       135570440 ns   (0.135570 seconds)
Parallel streams: Elapsed time:  104091980 ns   (0.104092 seconds)

这就像你的结果:流比收集慢。 结论:大量时间用于流初始化/值传输。

2) 增加任务流后变得更快(没关系),但并行流仍然太慢。怎么了?注意:您的命令中有collect(Collectors.toList())。在并发执行的情况下,收集到单个集合本质上会引入性能瓶颈和开销。可以通过替换来估计开销的相对成本

collecting to collection -> counting the element count

对于流,它可以由collect(Collectors.counting()) 完成。我得到了结果:

Collections: Elapsed time:   41856183 ns    (0.041856 seconds)
Streams: Elapsed time:       546590322 ns   (0.546590 seconds)
Parallel streams: Elapsed time:  1540051478 ns  (1.540051 seconds)

这是一项艰巨的任务! (int max = 10000000) 结论: 收集物品需要花费大部分时间。最慢的部分是添加到列表中。顺便说一句,简单的ArrayList 用于Collectors.toList()

【讨论】:

  • 你需要对这个测试进行微基准测试,这意味着它应该首先预热很多次,然后执行很多次并取平均值。
  • @skiwi 当然,你是对的,尤其是因为测量值存在很大偏差。我只进行了基本调查,并没有假装结果是准确的。
  • 服务器模式下的 JIT,在执行 10k 次后启动。然后编译代码并交换它需要一些时间。
  • 关于这句话:“你的命令中有collect(Collectors.toList()),即可能存在需要多线程处理单个Collection的情况。" 我几乎可以肯定 toList 会并行收集到几个不同的列表实例。只有作为集合中的最后一步,元素才被转移到一个列表中,然后返回。所以不应该有同步开销。这就是收集器同时具有供应商、累加器和合并器功能的原因。 (当然,由于其他原因,它可能会很慢。)
  • @Lii 我认为这里的collect 实现方式相同。但最后应该将几个列表合并为一个,看起来合并是给定示例中最繁重的操作。
【解决方案3】:
    public static void main(String[] args) {
    //Calculating square root of even numbers from 1 to N       
    int min = 1;
    int max = 10000000;

    List<Integer> sourceList = new ArrayList<>();
    for (int i = min; i < max; i++) {
        sourceList.add(i);
    }

    List<Double> result = new LinkedList<>();


    //Collections approach
    long t0 = System.nanoTime();
    long elapsed = 0;
    for (Integer i : sourceList) {
        if(i % 2 == 0){
            result.add( doSomeCalculate(i));
        }
    }
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Collections: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


    //Stream approach
    Stream<Integer> stream = sourceList.stream();       
    t0 = System.nanoTime();
    result = stream.filter(i -> i%2 == 0).map(i -> doSomeCalculate(i))
            .collect(Collectors.toList());
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Streams: Elapsed time:\t\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


    //Parallel stream approach
    stream = sourceList.stream().parallel();        
    t0 = System.nanoTime();
    result = stream.filter(i -> i%2 == 0).map(i ->  doSomeCalculate(i))
            .collect(Collectors.toList());
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Parallel streams: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));      
}

static double doSomeCalculate(int input) {
    for(int i=0; i<100000; i++){
        Math.sqrt(i+input);
    }
    return Math.sqrt(input);
}

我稍微修改了代码,在我的 8 核 mac book pro 上运行,我得到了一个合理的结果:

Collections: Elapsed time:      1522036826 ns   (1.522037 seconds)
Streams: Elapsed time:          4315833719 ns   (4.315834 seconds)
Parallel streams: Elapsed time:  261152901 ns   (0.261153 seconds)

【讨论】:

  • 我认为你的测试是公平的,你只需要一台有更多cpu核心的机器。
【解决方案4】:

对于您正在尝试做的事情,无论如何我都不会使用常规的 java api。正在进行大量的装箱/拆箱,因此会产生巨大的性能开销。

我个人认为很多设计的 API 都是垃圾,因为它们会产生大量的垃圾。

尝试使用 double/int 的原始数组并尝试单线程执行,看看性能如何。

PS: 您可能想看看 JMH 来处理基准测试。它处理了一些典型的陷阱,例如预热 JVM。

【讨论】:

  • LinkedLists 比 ArrayLists 还要糟糕,因为您需要创建所有节点对象。 mod 运算符也很慢。我相信像 10/15 个周期 + 它会耗尽指令管道。如果您想快速除以 2,只需将数字 1 位向右移动即可。这些是基本技巧,但我确信有模式高级技巧可以加快速度,但这些可能更针对特定问题。
  • 我知道拳击。这只是一个非正式的基准。这个想法是在集合和流测试中具有相同数量的装箱/拆箱。
  • 首先我会确保它不是测量错误。在进行真正的基准测试之前,请尝试运行几次基准测试。然后至少你已经完成了 JVM 预热并且代码被正确地 JITTED。没有这个,你可能会做出错误的结论。
  • 好的,我会按照您的建议发布新结果。我看过 JMH,但它需要 Maven,并且需要一些时间来配置。还是谢谢。
  • 我认为最好避免将基准测试考虑为“为了您正在尝试做的事情”。即,通常这些类型的练习被简化到足以被证明,但又足够复杂以至于它们看起来可以/应该被简化。
【解决方案5】:

Java 8 和 Java 11 的有趣结果。我使用了 leventov 提供的代码,几乎没有修改:

@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@OperationsPerInvocation(BenchmarkMain.N)
public class BenchmarkMain {

    public static final int N = 10000;

    static List<Integer> sourceList = new ArrayList<>();
    static {
        for (int i = 0; i < N; i++) {
            sourceList.add(i);
        }
    }

    @Benchmark
    public List<Double> vanilla() {
        List<Double> result = new ArrayList<>(sourceList.size() / 2 + 1);
        for (Integer i : sourceList) {
            if (i % 2 == 0){
                result.add(Math.sqrt(i));
            }
        }
        return result;
    }

    @Benchmark
    public List<Double> stream() {
        return sourceList.stream()
                .filter(i -> i % 2 == 0)
                .map(Math::sqrt)
                .collect(Collectors.toCollection(
                    () -> new ArrayList<>(sourceList.size() / 2 + 1)));
    }

    /**
     * @param args the command line arguments
     */
    public static void main(String[] args) throws IOException {
        org.openjdk.jmh.Main.main(args);

    }

}

Java 8:

# JMH version: 1.31
# VM version: JDK 1.8.0_262, OpenJDK 64-Bit Server VM, 25.262-b19
# VM invoker: /opt/jdk1.8.0_262/jre/bin/java
# VM options: <none>
# Blackhole mode: full + dont-inline hint
# Warmup: 5 iterations, 10 s each
# Measurement: 5 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
...
Benchmark              Mode  Cnt   Score   Error  Units
BenchmarkMain.stream   avgt   25  10.680 ± 0.744  ns/op
BenchmarkMain.vanilla  avgt   25   6.490 ± 0.159  ns/op

Java 11:

# JMH version: 1.31
# VM version: JDK 11.0.2, OpenJDK 64-Bit Server VM, 11.0.2+9
# VM invoker: /opt/jdk-11.0.2/bin/java
# VM options: <none>
# Blackhole mode: full + dont-inline hint
# Warmup: 5 iterations, 10 s each
# Measurement: 5 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
...
Benchmark              Mode  Cnt  Score   Error  Units
BenchmarkMain.stream   avgt   25  5.521 ± 0.057  ns/op
BenchmarkMain.vanilla  avgt   25  7.359 ± 0.118  ns/op

【讨论】:

    猜你喜欢
    • 2015-06-29
    • 1970-01-01
    • 2017-08-08
    • 2014-07-24
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-10-20
    • 1970-01-01
    相关资源
    最近更新 更多