Java 8: 流与集合的性能表现比较

175

我刚接触Java 8,对其API还不是很了解,但我进行了一次小型的非正式基准测试,比较了新的Streams API和老旧的Collections在性能上的差异。

测试包括对一个整型列表进行过滤,并对每个偶数计算平方根,将其存储在一个结果为Double类型的列表中。

以下是代码:

    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)

针对这个特定的测试,流比集合慢大约两倍,并且并行化没有帮助(或者我使用的方式不对?)。

问题:

  • 这个测试公平吗?我有犯任何错误吗?
  • 流比集合慢吗?有人做过好的正式基准测试吗?
  • 应该选择哪种方法?

更新结果。

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

    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)

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

1
你试过使用IntStream吗? - Mark Rotteveel
2
请你合理地进行测量好吗?如果你只是运行一次测试,那么你的基准测试结果当然会出现偏差。 - skiwi
2
@MisterSmith,您能否公开一下您是如何热身您的JVM并运行1K测试的? - skiwi
2
对于那些有兴趣编写正确的微基准测试的人,这里是问题链接:https://dev59.com/hHRB5IYBdhLWcg3wz6UK - Mister Smith
2
@assylias 使用 toList 应该可以并行运行,即使它正在收集到一个非线程安全的列表中,因为不同的线程将会在合并之前收集到线程限定的中间列表。 - Stuart Marks
显示剩余5条评论
6个回答

225
  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(匿名函数)内容,但是不像普通版本那样生成完美简洁的代码。

一般来说,Java 8流不是魔法。它们无法加速已经实现良好的东西(可能使用普通迭代或Java 5的for-each语句替换了Iterable.forEach()Collection.removeIf()调用)。流更多的是关于编码便利性和安全性。在这里方便性-速度权衡起作用。


2
感谢您抽出时间进行测试。我认为将LinkedList更改为ArrayList不会改变任何内容,因为两个测试都应该添加到它,时间不应受影响。无论如何,您能否解释一下结果?很难确定您在测量什么(单位为ns/op,但是什么被视为op?)。 - Mister Smith
66
你对程序性能的结论是正确的,但有些夸大其词。在许多情况下,流式代码比迭代代码更快,这主要是因为使用流式访问单个元素的成本比普通迭代器更便宜。而且在许多情况下,流式版本会内联到与手写版本等效的内容。当然,魔鬼在于细节;任何给定的代码可能表现不同。 - Brian Goetz
43
@BrianGoetz,请问您能够具体说明在哪些使用场景下流(streams)更快吗? - Alexandr
10
@BrianGoetz,您能指定在哪些用例下使用Stream会更快吗? - kiltek
2
@HariBharathi 你知道 peek 是什么意思吗? - user904963
显示剩余8条评论

19

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
@skiwi 当然,你说得对,特别是因为测量结果存在很大的偏差。我只进行了基本的调查,并不打算让结果精确。 - Sergey Fedorov
服务器模式下的JIT编译器在执行10,000次后启动。然后需要一些时间来编译代码并进行交换。 - pveentjer
@Lii 我对这里的 collect 实现也有同样的看法。但最终需要将几个列表合并成一个,而在给定的示例中,合并似乎是最耗费资源的操作。 - Sergey Fedorov
但也许这并不完全准确,因为这些线程在池中,并不需要启动和停止。但是在某种程度上,工作必须交给线程,并且它们必须以某种方式发出完成信号。也许这也涉及同步,所以原始措辞可能没有问题! - Lii
显示剩余5条评论

5
针对您所需内容,我建议不使用常规的Java API。因为会有大量的拆箱/装箱操作,这会导致巨大的性能开销。
个人认为很多API的设计都很烂,因为它们会产生大量的对象垃圾。
尝试使用原始的double/int数组,并单线程执行,看看性能如何。
附: 您可以考虑使用JMH进行基准测试,它会解决一些典型陷阱,比如预热JVM等问题。

链表比数组列表更糟糕,因为你需要创建所有的节点对象。取模运算符也非常慢。我相信大约需要10/15个周期,而且会耗尽指令流水线。如果你想要进行非常快速的除以2的操作,只需将数字向右移动1位即可。这些都是基本技巧,但我相信还有更高级的技巧可以加速处理速度,但这些可能更具问题特定性。 - pveentjer
首先我会确保测量不是错误的。在进行真正的基准测试前,尝试运行基准测试几次。这样至少可以预热JVM并正确编译代码。没有这样做,你可能会得出错误的结论。 - pveentjer
我认为最好避免从“你想要做什么”这个角度来考虑基准测试。也就是说,通常这些练习足够简化以便演示,但又足够复杂以至于看起来它们可以/应该被简化。 - ryvantage
如果你想进行比较,那么你正在进行基准测试。故事结束。 - pveentjer
我强烈推荐使用JMH。如果您不想使用maven,可以直接下载jar文件(加上一些依赖的jar文件),并使用ant任务运行它。设置应该只需要十分钟。 - Stuart Marks
显示剩余2条评论

5
    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);
}

我稍微改了一下代码,在我的Mac Book Pro上运行,该电脑有8个核心,我得到了一个合理的结果:

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核心的机器。 - Mellon

3

对于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

0

使用Java 17 我的结果

Collections: Elapsed time:109585000 ns  (0.109585 seconds)
Streams: Elapsed time:42179700 ns   (0.042180 seconds)
Parallel streams: Elapsed time:76177100 ns  (0.076177 seconds)

使用List.of代替LinkedList,结果发生了变化

Collections: Elapsed time:49681300 ns   (0.049681 seconds)
Streams: Elapsed time:38930300 ns   (0.038930 seconds)
Parallel streams: Elapsed time:49190500 ns  (0.049191 seconds)

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