Java 8 Stream的性能到底如何?

lieee 8年前


Java 8提供的流的基于Lambda表达式的函数式的操作写法让人感觉很爽,笔者也一直用的很开心,直到看到了 Java8 Lambda表达式和流操作如何让你的代码变慢5倍 ,笔者当时是震惊的,我读书少,你不要骗我。瞬间我似乎为我的Server Application速度慢找到了一个很好地锅,不过这个跟书上讲的不一样啊。于是笔者追本溯源,最后找到了始作俑者自己的分析: 原文

不久之前我在社区内发表了这篇文章: I mused about the performance of Java 8 streams ,上面的测试结果貌似很有道理。其中一个测试是将传统的for-循环与Stream进行了比较。很多人表示了震惊、不相信等等很多很多的情绪,甚至有人直接说Stream是个什么鬼,哪凉快哪呆着去。这是没有道理的,毕竟不能通过一个简单地只是一个环境下的测试就否定这些。

在之前的测评中,在500,000个随机的整形数的数组的遍历中,我们得出的结论是for-循环的速度会比Stream的速度快上15倍。其中for-循环的数组如下所示:

int[] a = ints;  int e = ints.length;  int m = Integer.MIN_VALUE;     for (int i = 0; i < e; i++)      if (a[i] > m) m = a[i];

同样的,我们建立了一个原始类型的IntStream:

int m = Arrays.stream(ints)                .reduce(Integer.MIN_VALUE, Math::max);

在我们这个过时的设备上(双核)跑出来的结果是:

int-array, for-loop : 0.36 ms  int-array, seq. stream: 5.35 ms

for循环的方式明显的比Stream流要快很多很多,然后我们选择了另一台4核的设备,发现这个比例因子变成了4.2(原来是15)。这个结果的详细信息可以看 Nicolai Parlog’s blog 这个文章:

正如我们所料,不同的环境可能会引发不同的结果。不过我们评测的核心:for-循环速度奏是比Stream快,在不同的平台上是一致的。

接下来,我们不再测试原始类型,改用了ArrayList<Integer>,同样是填充了500000个随机数,然后结果是:

ArrayList, for-loop   : 6.55 ms  ArrayList, seq. stream: 8.33 ms

是的,for-循环的速度确实还是会快一点,但是很明显这种差距缩小了。这个结果并不令人惊讶,实际上整个测试的性能主要取决于内存访问与遍历这两大块。其中内存访问这个还受限制于硬件本身,所以不同的平台上会有不同的结果。实际上在我们的测试中出现这样的结果并不会令人惊讶,毕竟我们特意选择了一个比较极端的情况,代表了范围内的某个极端,可以解释如下:

  • 我们将for-loops与Streams进行了比较。循环本身是JIT友好的。编译器本身有了40年以上的经验,然后我们选择了循环这个JIT编译器重点优化的部分。这是所谓的某个极端:一个JIT友好的,高度优化的访问序列元素的方法。而如果是使用流的话也就意味着会在主框架内进行调用,不可避免地增加内存调用。而一个JIT编译器本身是有一个上限的,虽然大部分情况下是用不满的。因此,我们将这种情况分为JIT友好与不友好,而for-循环本身是处于JIT友好的这一边,因此它自然能够赢得这个测试,并没有神马奇怪。

  • 我们将原始类型的序列与引用类型的序列进行了比较。这两种情况可以用缓存友好/不友好来区分。一个原始类型int的序列是非常缓存友好的,特别是当未来Java引入不可变序列的时候。而一个引用类型的序列,即使用了基于数组的,就像ArrayList的这样的存储,也是只有很小的概率进行很好地缓存。每次独立地对于序列成员的访问需要获取指针指向的地址然后获取其内容,也就意味着缓存的失效。很明显地,一个使用了int[]的for-循环肯定处在缓存友好这一边,自然与序列引用的Stream相比性能上要好上很多。

  • 我们将元素轻量级使用与CPU密集型使用相比。更重要的是,我们将这种两个两个的比较寻找最大值的计算与Taylor相似度下寻找正弦值的计算进行了比较。在下面一个实验中,我们会以相对而言复杂一点的CPU密集型的运算为例,可能获取到这个值需要一分钟的时间。我们将此称作CPU友好或者CPU不友好的分割。一般来说,对于序列中的元素进行重量级的CPU密集型的运算的时候,也就是所谓的CPU不友好运算时,评测结果往往由CPU的运算速度决定,而对于上面讲的缓存缺失以及JIT循环的优化就变得不那么重要了。

讲了这么多,我们已经可以发现上面评测中对于int[]类型的数组中寻找最大值的这件事是受到JIT友好以及缓存友好这两个因素决定的。这种情况下,当然for-循环会占了很大优势,如果没做到这样才会让人惊讶呢。那么如果我们对于上文中所讲的CPU密集型的情况,这也是一种极端情况,进行评测,其中for-循环式这样的:

int[] a = ints;  int e = a.length;  double m = Double.MIN_VALUE;     for (int i = 0; i < e; i++) {     double d = Sine.slowSin(a[i]);     if (d > m) m = d;  }

Stream的用法如下:

Arrays.stream(ints)        .mapToDouble(Sine::slowSin)        .reduce(Double.MIN_VALUE, (i, j) -> Math.max(i, j));

最后的结果是:

for``-loop   : ``11.82` `ms  seq. stream: ``12.15` `ms

这个评测依旧是在上文说到的那个老旧的机器上进行的。确实for-循环的效率是比Stream要快的,不过可以看得出来这种差距不再明显了。换种说法,这种差距在统计评测的角度来看还是很重要的,不过在实际的应用过程中已经无足轻重了。到这里我们证明了与上次评测相悖的一个观点:其实Stream与for-循环之间的性能并木有很大的差异。

最后来总结一波,在有些情况下,Stream的效率确实会比for-循环要慢上很多倍,然后在其他大部分情况下是没有虾米差异的。你可以觉得Stream很酷然后就去使用它,或者为了优化你的应用的性能而依旧选择旧的语法。同时,也不要无缘无故就觉得人家Stream损害了你应用的性能,那是你自己用得不好。