当我阅读Angelika Langer的Java性能教程时-Java 8流有多快? 我简直不敢相信,对于一个特定的操作,它们花费的时间比循环要长15倍。 流媒体性能真的会那么糟糕吗? 我必须找出答案!
巧合的是,我最近观看了一个有关微基准测试Java代码的精彩讨论 ,因此决定将在这里学到的东西投入工作。 因此,让我们看一下流是否真的那么慢。
总览
和往常一样,我将以沉闷的序幕开始。 这篇文章将解释为什么您应该对我在这里介绍的内容,我如何产生这些数字以及如何轻松地重复和调整基准非常谨慎。 如果您不关心这些,请直接跳至Stream Performance 。
但首先,有两个快速提示:所有基准测试代码都在GitHub上发布,并且此Google电子表格包含结果数据。
序幕
免责声明
这篇文章包含许多数字,并且数字是欺骗性的。 它们似乎都是科学的,精确的东西,它们诱使我们专注于它们的相互关系和解释。 但是,我们应该始终同样关注它们的发展!
我将在下面显示的数字是在系统上使用非常特定的测试用例生成的。 过度概括它们很容易! 我还应该补充一点,对于非平凡的基准测试技术(即那些不基于循环和手动System.currentTimeMillis()
),我只有两天的经验。
将您在此处获得的见解纳入心理表现模型时要格外小心。 隐藏在细节中的魔鬼是JVM本身,它是一个骗人的野兽。 我的基准测试很可能成为扭曲数字的优化的牺牲品。
系统
- CPU:英特尔(R)核心(TM)i7-4800MQ CPU @ 2.70GHz
- 内存 :Samsung DDR3 16GB @ 1.60GHz(测试完全在RAM中运行)
- 操作系统 :Ubuntu 15.04。 内核版本3.19.0-26-通用
- 的Java :1.8.0_60
- 捷运 :1.10.5
基准测试
捷运
基准测试是使用JVM性能团队本身开发和使用的Java Microbenchmarking Harness(JMH)创建的。 它有完整的文档记录,易于设置和使用,并且通过示例进行的解释非常棒!
如果您喜欢随意介绍,您可能会喜欢2013年Devoxx UK的Aleksey Shipilev的演讲 。
设定
为了产生可靠的结果,基准测试要单独和重复运行。 每个基准测试方法都有一个单独的运行,该运行由几个分支组成 ,每个分支在实际测量迭代之前运行许多预热迭代。
我分别使用50,000、500,000、5,000'000、10'000'000和50'000'000元素运行基准测试。 除了最后一个以外,所有的分支都有两个,分别由五个预热和五个测量迭代组成,其中每个迭代持续三秒钟。 最后一个的一部分运行在一个分支中,进行了两次热身和三个测量迭代,每个迭代持续30秒。
Langer的文章指出,它们的数组填充有随机整数。 我将此与更令人愉快的情况进行了比较,在这种情况下,数组中的每个int
等于其在其中的位置。 两种情况之间的平均偏差为1.2%,最大差异为5.4%。
由于创建数百万个随机整数会花费大量时间,因此我选择仅对有序序列执行大多数基准测试,因此除非另有说明,否则与该情况有关。
码
基准代码本身可在GitHub上获得 。 要运行它,只需转到命令行,构建项目,然后执行生成的jar:
建立和运行基准
mvn clean install
java -jar target/benchmarks.jar
一些简单的调整:
- 在执行调用的末尾添加正则表达式只会对完全限定名称与该表达式匹配的基准方法进行基准测试; 例如仅运行
ControlStructuresBenchmark
:java -jar target/benchmarks.jar Control
-
AbstractIterationBenchmark
上的注释控制执行每个基准测试的频率和时间 - 常数
NUMBER_OF_ELEMENTS
定义要迭代的数组/列表的长度 - 调整
CREATE_ELEMENTS_RANDOMLY
以在有序数或随机数数组之间切换
流性能
重复实验
让我们从触发我写这篇文章的情况开始:在500'000个随机元素的数组中找到最大值。
SimpleOperationsBenchmark.array_max_for
int m = Integer.MIN_VALUE;
for (int i = 0; i < intArray.length; i++)if (intArray[i] > m)m = intArray[i];
我注意到的第一件事:我的笔记本电脑的性能比JAX文章所使用的机器好得多。 这是可以预料的,因为它被描述为“过时的硬件(双核,没有动态超频)”,但是它让我很高兴,因为我为这该死的东西花了足够的钱。 而不是0.36毫秒,而仅需0.130毫秒即可遍历整个阵列。 使用流查找最大值的结果更有趣:
SimpleOperationsBenchmark.array_max_stream
// article uses 'reduce' to which 'max' delegates
Arrays.stream(intArray).max();
Langer报告为此花费了5.35 ms的运行时间,与循环的0.36 ms相比,报告的运行速度降低了x15。 我始终测量大约560毫秒,因此最终导致“仅” x4.5变慢。 仍然很多。
接下来,本文将迭代列表与流式列表进行比较。
SimpleOperationsBenchmark.list_max_for
// for better comparability with looping over the array
// I do not use a "for each" loop (unlike the Langer's article);
// measurements show that this makes things a little faster
int m = Integer.MIN_VALUE;
for (int i = 0; i < intList.size(); i++)if (intList.get(i) > m)m = intList.get(i);
SimpleOperationsBenchmark.list_max_stream
intList.stream().max(Math::max);
for循环的结果是6.55毫秒,流的结果是8.33毫秒。 我的测量值为0.700毫秒和3.272毫秒。 尽管这会大大改变其相对性能,但会创建相同的顺序:
安吉利卡·兰格(Angelika Langer) | 我 | |||
---|---|---|---|---|
运作 | 时间(毫秒) | 慢点 | 时间(毫秒) | 慢点 |
array_max_for | 0.36 | – | 0.123 | – |
array_max_stream | 5.35 | 14'861% | 0.599 | 487% |
list_max_for | 6.55 | 22% | 0.700 | 17% |
list_max_stream | 8.33 | 27% | 3.272 | 467% |
我将遍历数组和列表的迭代之间的明显区别归因于拳击。 或更确切地说是间接导致的。 基本数组包含我们需要的值,但是列表由Integers
数组支持,即,对必须首先解析的所需值的引用。
朗格和我的一系列相对变化之间的可观差异(+ 14'861%+ 22%+ 27%与+ 487%+ 17%+ 467%)强调了她的观点,即“流的性能模型并非微不足道的”。
最后,她的文章进行了以下观察:
我们只比较两个整数,在JIT编译后,它们几乎不止一个汇编指令。 因此,我们的基准测试说明了元素访问的成本–不一定是典型情况。 如果应用于序列中每个元素的功能是CPU密集型的,则性能指标将发生重大变化。 您会发现,如果功能受CPU的限制很大,则for循环流和顺序流之间将不再有可测量的差异。
因此,让我们锁定除整数比较之外的其他功能。
比较操作
我比较了以下操作:
- max:求最大值。
- sum:计算所有值的总和; 聚合为
int
而不考虑溢出。 - 算术:为了对不太简单的数字运算建模,我将这些值与少量的移位和乘法相结合。
- 字符串:为了模拟创建新对象的复杂操作,我将元素转换为字符串,然后逐个字符对其进行异或。
这些是结果(对于50万个有序元素;以毫秒为单位):
最高 | 和 | 算术 | 串 | |||||
---|---|---|---|---|---|---|---|---|
数组 | 清单 | 数组 | 清单 | 数组 | 清单 | 数组 | 清单 | |
对于 | 0.123 | 0.700 | 0.186 | 0.714 | 4.405 | 4.099 | 49.533 | 49.943 |
流 | 0.559 | 3.272 | 1.394 | 3.584 | 4.100 | 7.776 | 52.236 | 64.989 |
这突显了真正的廉价比较,甚至加法花费的时间也要长50%。 我们还可以看到更复杂的操作如何使循环和流更紧密地联系在一起。 差异从几乎400%下降到25%。 同样,数组和列表之间的差异也大大减少了。 显然,算术和字符串运算受CPU限制,因此解析引用不会产生负面影响。
(不要问我为什么对数组元素进行流式运算要比在它们上循环要快。我已经将头撞墙了一段时间了。)
因此,让我们修复操作并了解迭代机制。
比较迭代机制
访问迭代机制的性能至少有两个重要变量:其开销以及是否导致装箱,这将损害内存绑定操作的性能。 我决定尝试通过执行CPU绑定操作来绕过拳击。 如上所述,算术运算可以在我的机器上实现。
迭代是通过for和for-each循环直接实现的。 对于流,我做了一些其他实验:
盒装和非盒装流
@Benchmark
public int array_stream() {// implicitly unboxedreturn Arrays.stream(intArray).reduce(0, this::arithmeticOperation);
}@Benchmark
public int array_stream_boxed() {// explicitly boxedreturn Arrays.stream(intArray).boxed().reduce(0, this::arithmeticOperation);
}@Benchmark
public int list_stream_unbox() {// naively unboxedreturn intList.stream().mapToInt(Integer::intValue).reduce(0, this::arithmeticOperation);
}@Benchmark
public int list_stream() {// implicitly boxedreturn intList.stream().reduce(0, this::arithmeticOperation);
}
在此,装箱和拆箱与数据的存储方式(在数组中拆箱并在列表中装箱)无关,而是与流如何处理值无关。
请注意, boxed
将IntStream
(仅处理原始int
的Stream的专用实现)转换为Stream<Integer>
,即对象上的流。 这将对性能产生负面影响,但程度取决于逃避分析的效果。
由于列表是通用的(即没有专门的IntArrayList
),因此它返回Stream<Integer>
。 最后一个基准测试方法调用mapToInt
,该方法返回一个IntStream
。 这是对流元素进行拆箱的幼稚尝试。
算术 | ||
---|---|---|
数组 | 清单 | |
对于 | 4.405 | 4.099 |
每次 | 4.434 | 4.707 |
流(未装箱) | 4.100 | 4.518 |
流(盒装) | 7.694 | 7.776 |
好吧,看那个! 显然,幼稚的拆箱确实有效(在这种情况下)。 我有一些模糊的概念,为什么可能会这样,但是我无法简洁(或正确)表达。 想法,有人吗?
(顺便说一句,所有关于装箱/拆箱和专门实现的讨论使我更加高兴的是Valhalla项目进展得如此之好 。)
这些测试的更具体的结果是,对于CPU限制的操作,流似乎没有相当大的性能成本。 在担心了很大的缺点之后,这很令人高兴。
比较元素数
通常,结果在序列长度不同(从50'000到50'000'000)的运行中都非常稳定。 为此,我检查了这些运行中每1'000'000个元素的归一化性能。
但是令我惊讶的是,随着序列的增加,性能不会自动提高。 我的想法很简单,即认为这将使JVM有机会应用更多优化。 相反,有一些明显的情况是性能实际上下降了:
从500'000到50000000个元素 | |
---|---|
方法 | 时间 |
array_max_for | + 44.3% |
array_sum_for | + 13.4% |
list_max_for | + 12.8% |
有趣的是,这些是最简单的迭代机制和操作。
胜者是比简单操作更复杂的迭代机制:
从500'000到50000000个元素 | |
---|---|
方法 | 时间 |
array_sum_stream | – 84.9% |
list_max_stream | – 13.5% |
list_sum_stream | – 7.0% |
这意味着我们在上面看到的500'000元素的表对于50'000'000看起来有点不同(归一化为1'000'000元素;以毫秒为单位):
最高 | 和 | 算术 | 串 | |||||
---|---|---|---|---|---|---|---|---|
数组 | 清单 | 数组 | 清单 | 数组 | 清单 | 数组 | 清单 | |
500'000个元素 | ||||||||
对于 | 0.246 | 1.400 | 0.372 | 1.428 | 8.810 | 8.199 | 99.066 | 98.650 |
流 | 1.118 | 6.544 | 2.788 | 7.168 | 8.200 | 15.552 | 104.472 | 129.978 |
50'000'000个元素 | ||||||||
对于 | 0.355 | 1.579 | 0.422 | 1.522 | 8.884 | 8.313 | 93.949 | 97.900 |
流 | 1.203 | 3.954 | 0.421 | 6.710 | 8.408 | 15.723 | 96.550 | 117.690 |
我们可以看到算术和字符串运算几乎没有变化。 但是事情发生了变化,因为最简单的最大和求和运算需要更多的元素将字段更紧密地结合在一起。
反射
总而言之,我没有什么大的启示。 我们已经看到,循环和流之间的明显差异仅存在于最简单的操作中。 但是,令人惊奇的是,当我们涉及到数百万个元素时,差距正在缩小。 因此,在使用流时几乎不需要担心速度会大大降低。
但是,仍然存在一些未解决的问题。 最值得注意的是:并行流怎么样? 然后,我很想知道在哪种操作复杂度下,我可以看到从依赖于迭代(如sum和max )到独立于迭代(如算术 )性能的变化。 我也想知道硬件的影响。 当然,它会改变数字,但是在质量上也会有所不同吗?
对我来说,另一点是微基准测试并不是那么困难。 还是这样,我想除非有人指出我的所有错误…
翻译自: https://www.javacodegeeks.com/2015/09/stream-performance.html