Java 8 流性能基准

一则或许对你有用的小广告

欢迎加入小哈的星球 ,你将获得:专属的项目实战 / Java 学习路线 / 一对一提问 / 学习打卡/ 赠书活动

目前,正在 星球 内带小伙伴们做第一个项目:全栈前后端分离博客项目,采用技术栈 Spring Boot + Mybatis Plus + Vue 3.x + Vite 4手把手,前端 + 后端全栈开发,从 0 到 1 讲解每个功能点开发步骤,1v1 答疑,陪伴式直到项目上线,目前已更新了 204 小节,累计 32w+ 字,讲解图:1416 张,还在持续爆肝中,后续还会上新更多项目,目标是将 Java 领域典型的项目都整上,如秒杀系统、在线商城、IM 即时通讯、权限管理等等,已有 870+ 小伙伴加入,欢迎点击围观

当我阅读 angelika langer 的 java 性能教程时——java 8 流有多快? 我简直不敢相信,对于一个特定的操作,它们花费的时间比 for 循环长 15 倍。流媒体性能真的那么糟糕吗?我必须找出答案!

巧合的是,我最近看了 一个关于微基准测试 Java 代码的精彩演讲 ,我决定将我在那里学到的知识付诸实践。所以让我们看看流是否真的那么慢。

概述

像往常一样,我将从一个乏味的开场白开始。这个将解释为什么你应该非常小心我在这里展示的内容,我是如何产生这些数字的,以及你如何轻松地重复和调整基准。如果您不关心这些,请直接跳转到 流媒体表演

但首先,两个快速指针:所有基准代码都 在 github 上 这个 google 电子表格 包含结果数据。

序幕

免责声明

这篇文章包含很多数字,数字是骗人的。它们看起来都是科学的、精确的,它们引诱我们关注它们之间的相互关系和解释。但我们应该始终同等关注它们是如何形成的!

我将在下面展示的数字是在我的系统上使用非常具体的测试用例生成的。很容易过度概括它们!我还应该补充一点,我只有两天的经验来使用非平凡的基准测试技术(即那些不基于循环和手动 system.currenttimemillis() 的技术)。

将您在此处获得的见解纳入您的心理表现模型时要非常小心。隐藏在细节中的魔鬼是 jvm 本身,它是一个骗人的野兽。我的基准测试完全有可能成为扭曲数字的优化的牺牲品。

系统

  • cpu : 英特尔(r) 核心(tm) i7-4800mq cpu @ 2.70ghz
  • ram :samsung ddr3 16gb @ 1.60ghz(测试完全在 ram 中运行)
  • 操作系统 :Ubuntu 15.04。内核版本 3.19.0-26-generic
  • 爪哇 :1.8.0_60
  • jmh :1.10.5

基准

jmh

基准测试是使用出色的 java 微基准测试工具 (jmh) 创建的,它由 jvm 性能团队自己开发和使用。它有完整的文档记录,易于设置和使用,并且 通过示例进行的解释 非常棒!

如果您更喜欢随意的介绍,您可能会喜欢 aleksey shipilev 在 devoxx uk 2013 的演讲


设置

为了创建稍微可靠的结果,基准测试是单独和重复运行的。每个基准方法都有一个单独的运行,由多个 分支 组成,每个分支在实际 测量 迭代之前运行多次 预热 迭代。

我用 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

 mvn clean install
java -jar target/benchmarks.jar
  • abstractiterationbenchmark 上的注释控制每个基准执行的频率和时间
  • 常量 number_of_elements 定义被迭代的数组/列表的长度
  • 调整 create_elements_randomly 以在有序数组或随机数数组之间切换

bart cc-by-nc-nd 2.0 发布

流媒体表演

重复实验

让我们从触发我写这篇文章的案例开始:在 500'000 个随机元素的数组中找到最大值。


 mvn clean install
java -jar target/benchmarks.jar

我注意到的第一件事:我的笔记本电脑比 jax 文章中使用的机器性能好得多。这是意料之中的,因为它被描述为“过时的硬件(双核,没有动态超频)”,但它让我很高兴,因为我为这该死的东西付出了足够的代价。遍历数组只需要 0.130 毫秒,而不是 0.36 毫秒。

更有趣的是使用流来查找最大值的结果:


 mvn clean install
java -jar target/benchmarks.jar

langer 为此报告了 5.35 毫秒的运行时间,与循环的 0.36 毫秒相比,报告的速度降低了 15 倍。我一直测量到大约 0.560 毫秒,所以我最终得到“仅”x4.5 的减速。不过还是很多。

接下来,本文比较了遍历列表和流式处理列表。


 mvn clean install
java -jar target/benchmarks.jar

 mvn clean install
java -jar target/benchmarks.jar

for 循环的结果是 6.55 毫秒,流的结果是 8.33 毫秒。我的测量值是 0.700 毫秒和 3.272 毫秒。虽然这会显着改变它们的相对性能,但它会创建相同的顺序:

安吉丽卡兰格
手术 时间(毫秒) 慢点 时间(毫秒) 慢点
array_max_for 0.36 0.123
数组最大流 5.35 14'861% 0.599 487%
list_max_for 6.55 22% 0.700 17%
列表最大流 8.33 27% 3.272 467%

我将数组迭代和列表迭代之间的显着差异归因于装箱;或者更确切地说是由此产生的间接寻址。原始数组包含我们需要的值,但列表由整数 数组 支持,即对我们必须首先解析的所需值的引用。

langer 和我的一系列相对变化之间的巨大差异(+14'861% +22% +27% vs +487% + 17% + 467%)强调了她的说法,即“流的性能模型不是一个微不足道的模型”。

在结束这一部分时,她的文章提出了以下观点:

我们只是比较两个整数,经过 jit 编译后仅比一条汇编指令多一点。出于这个原因,我们的基准测试说明了元素访问的成本——这不一定是典型情况。如果应用于序列中每个元素的功能是 CPU 密集型的,则性能数字会发生很大变化。如果功能受 CPU 严重限制,您会发现 for 循环和顺序流之间不再存在可测量的差异。

因此,让我们锁定除整数比较之外的其他内容。

比较操作

我比较了以下操作:

最大限度
找到最大值。
计算所有值的总和;聚集在一个忽略溢出的 int 中。
算术
为了模拟一个不太简单的数字运算,我将这些值与少量位移和乘法相结合。
细绳
为了对创建新对象的复杂操作建模,我将元素转换为字符串并逐字符对它们进行异或运算。

这些是结果(对于 500'000 个有序元素;以毫秒为单位):

最大限度 算术 细绳
大批 列表 大批 列表 大批 列表 大批 列表
为了 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 循环实现的。对于流,我做了一些额外的实验:


 mvn clean install
java -jar target/benchmarks.jar

在这里,装箱和拆箱与数据的存储方式无关(它在数组中拆箱并在列表中装箱)但流如何处理值。

请注意, boxed intstream 转换为 stream<integer> ,即对象上的 ,intstream 是仅处理原始 int s 的流的专门实现。这应该会对性能产生负面影响,但程度取决于 逃逸分析的 效果。

由于列表是通用的(即没有专门的 intarraylist ),它返回一个 stream<integer> 。最后一个基准方法调用 maptoint ,它返回一个 intstream 。这是对流元素拆箱的幼稚尝试。

算术
大批 列表
为了 4.405 4.099
foreach 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 到 50'000'000 个元素
方法 时间
array_max_for + 44.3%
array_sum_for + 13.4%
list_max_for + 12.8%

有趣的是,这些是最简单的迭代机制和操作。

获胜者是比简单操作更复杂的迭代机制:

从 500'000 到 50'000'000 个元素
方法 时间
array_sum_stream – 84.9%
列表最大流 – 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

我们可以看到 算术 字符串 操作几乎没有变化。但是对于更简单的 max sum 操作,情况发生了变化,其中更多的元素使字段更接近。

反射

总而言之,我会说没有重大的启示。我们已经看到,循环和流之间的明显差异只存在于最简单的操作中。但是,当我们处理数百万个元素时,差距正在缩小,这有点令人惊讶。因此,在使用流时几乎不用担心会出现相当大的减速。

不过,仍有一些悬而未决的问题。最值得注意的是:并行流呢?然后我很想知道在哪种操作复杂度下我可以看到从迭代依赖(如 sum max )到迭代独立(如 算术 )性能的变化。我也想知道硬件的影响。当然,它会改变数字,但也会有质的差异吗?

对我来说,另一个收获是微基准测试并不难。大约我认为直到有人指出我所有的错误……