Hotspot 中的逃逸分析在简单情况下有多脆弱,例如 for-each 循环中的迭代器

问题描述

假设我有一个想要循环的 java.util.Collection。通常我会这样做:

for(Thing thing : things) do_something_with(thing);

但是假设这是在一些到处使用的核心实用方法中,并且在大多数地方,集合是空的。那么理想情况下,我们不希望仅仅为了执行无操作循环而对每个调用者强加迭代器分配,我们可以像这样重写:

if(things.isEmpty()) return;
for(Thing thing : things) do_something_with(thing);

如果 things一个 List,一个更极端的选择是使用 C 风格的 for 循环。

但是等等,Java 转义分析应该消除这种分配,至少在 C2 编译器开始使用这种方法之后。所以应该不需要这种“纳米优化”。 (我什至不会用术语微优化来夸大它;它有点太小了。)除了......

我一直听说逃逸分析是“脆弱的”,但似乎没有人谈论过什么特别会弄乱它。直觉上,我认为更复杂的控制流将是主要的恐惧,这意味着应该可靠地消除 for-each 循环中的迭代器,因为那里的控制流很简单。

这里的标准反应是尝试进行实验,但除非我知道在起作用的变量,否则很难相信我可能会从此类实验中得出的任何结论。

确实,这里有一篇博客文章,有人尝试过这样的实验,但 3 个分析器中有 2 个给出了错误的结果:

http://psy-lob-saw.blogspot.com/2014/12/the-escape-of-arraylistiterator.html

与那篇博文的作者相比,我对晦涩难懂的 JVM 魔法知之甚少,而且很可能更容易被误导。

解决方法

你的方法行不通。正确的做法是这样的:

  • 除非您是性能专家(这很难成为),否则不要假设哪种代码性能良好,性能较差,并在分析分析器报告时保持怀疑态度。这不是特别有用的建议(归结为:分析器报告可能在骗你!),但事实就是如此。实际上,要么成为性能专家,要么接受您对此无能为力的事实。很糟糕,但是,不要向信使开枪。
  • 编写惯用的 Java 代码。它最容易维护,也最有可能通过热点进行优化。
  • 降低算法复杂性很有用,应该始终是您检查的第一件事。在某种程度上,降低算法复杂性的优化会忽略第一条规则。您无需特别了解 JVMTI 或 Flight Recorder 的变幻莫测,以及分析器的工作原理,即可得出结论,算法重写是值得的,并且会显着提高性能。
  • 不要相信简明的经验法则,不管有多少人在说。不要寻找“易于应用的模式”,例如“通过附加一个首先测试空的 if 块来替换所有 foreach 循环”——这些本质上永远不会正确,通常会降低性能。
  • 请注意,糟糕的性能建议非常普遍。您不应该永远将一些没有证据或研究的论证的普遍存在视为“这使得它更有可能成为真的”作为生活和逻辑推理的一般原则(毕竟,这是一个逻辑谬误!),但这对性能来说是双倍的!

更深入的思考

据推测,您不会仅仅因为我告诉您要相信它们就相信上述格言。我将尝试通过一些可证伪的推理向您展示为什么上述格言是正确的。

特别是,这种先检查空的想法似乎极其被误导了。

让我们首先将过度双曲线因而相当无用的著名格言过早优化是万恶之源转化为更切实的东西:

不要因为想象中的性能问题而使您的代码变得丑陋、令人担忧的怪异混乱。

为什么我不能遵循经常听到的格言?

不要在这里“人”。因为“人”因一次又一次在性能上完全错误而臭名昭著。如果您能找到广泛、简洁且完全没有证据或研究的陈述,表明 X 对性能的好坏,您可以放心,这意味着绝对没有任何。在这方面,你的普通 joe twitter 作家或诸如此类的东西是一个无知的白痴。证据、充分的研究或凭据是认真对待事情的绝对要求,最好是其中的 2 或 3 个。有一系列众所周知的性能谎言(关于如何提高 JVM 性能的普遍信念,这些信念绝对无济于事,而且往往实际上是有害的),如果你然后搜索这些谎言,你可以找到一大群拥护它的人,因此证明您不能仅仅基于“不断听到”这一事实就相信任何事情。

还要注意,对于几乎所有可以想象的 Java 代码行,您可以想出 100 多个似是而非的想法,以说明如何使代码不那么明显但看起来“更高效”。显然,您不能将所有 100 个变体应用于整个项目中的每一行,因此您计划在这里采取的道路(“我不太相信该分析器,我发现合理的逃逸分析将无法消除此迭代器分配,所以,为了安全起见,我将添加一个 if 来首先检查是否为空"),结果以灾难告终,即使是最简单的任务也变成了多行的、看似过度冗余的汤。平均而言,表现会更糟,所以这是一个双输的局面。

这里有一个简单的例子来说明这一点,您可以观看 Doug 的演讲以了解更多此类内容:

List<String> list = ... retrieve thousands of entries ...;
String[] arr1 = list.toArray(new String[list.size()]);
String[] arr2 = list.toArray(new String[0]);

很可能 arr1 行更快,对吧?它避免了创建一个新数组,然后立即进行垃圾回收。然而,事实证明,arr2 更快,因为热点识别这种模式并将优化该数组的清零(这不是你可以在 java 中做的事情,但在当然是机器码),因为它知道无论如何都会覆盖所有字节。

为什么要写惯用的java代码?

请记住,热点是一个尝试识别模式并将优化应用于这些模式的系统。理论上可以优化的模式有无数种。因此,热点代码旨在搜索有用模式:采用给定的模式,并计算[这在您的普通java项目中出现的几率*它在性能关键代码路径中出现的频率*数量我们可以实现的性能增益]。您应该摆脱这一点,您应该编写惯用的 Java 代码。如果您编写了其他人不会编写的奇异 Java 代码,则 hotspot 优化失败的可能性要大得多,因为 hotspot 工具的作者也是人,他们针对常见情况进行优化,而不是为了怪异。来源:Azul for example,this devoxx presentation 的 JVM 性能工程师 Douglas Hawkins 和许多其他 JVM 性能工程师都说过类似的话。

顺便说一下,您会得到易于维护和解释的代码 - 因为其他 Java 编码人员会阅读它并找到熟悉的基础。

说真的,成为性能专家,这是唯一的出路吗?

大部分。但是,嘿,CPU 和内存非常便宜,而且热点很少对算法进行改进(例如,热点很少将 O(n^2) 的算法转换为例如 O(n) 的算法,例如:如果您绘制图形“输入的大小”与“运行算法所花费的时间”相比,该算法似乎会产生一条看起来像 y = x^2 的曲线,但热点设法将其变成了 y = x 线性事件。那就是很少到不可能 - 改进往往是恒定因素,因此通常情况下,投入更多 CPU 内核和/或 RAM 也同样有效。

当然,无论热点和微/纳米优化可以为您做什么,算法的胜利总是相形见绌。

因此:只需编写看起来不错、易于测试、以惯用方式编写并使用正确、最有效的算法的代码,它就会运行得很快。如果速度不够快,请投入更多的 CPU 或 RAM。如果还不够快,请花 10 年时间成为专家。

“让我们添加一张空支票,哎呀,以防万一!”不适合那个计划。

,

标量替换确实是一种您永远无法绝对确定的优化,因为它取决于太多因素。

首先,只有当实例的所有使用都内联在一个编译单元中时,才能消除分配。如果是迭代器,则意味着必须内联迭代器构造函数、hasNextnext 调用(包括嵌套调用)。

public E next() {
    if (! hasNext())
        throw new NoSuchElementException();
    return (E) snapshot[cursor++];
}

然而,内联本身在 HotSpot 中是一种脆弱的优化,因为它relies on many heuristics and limits。例如,可能会发生 iterator.next() 调用未完全内联到循环中,因为达到了最大内联深度,或者外层编译已经太大。

其次,如果引用有条件地接收不同的值,则不会发生标量替换。

for(Thing thing : things) do_something_with(thing);

在您的示例中,如果 things 有时是 ArrayList,有时是 Collections.emptyList(),则迭代器将在堆上分配。为了消除,迭代器的类型必须始终相同。

在 Ruslan Cheremin 的 Scalar Replacement 中的 more examples 中有 great talk(它是俄语的,但 YouTube 的字幕翻译功能可以提供帮助)。

另一个推荐阅读是 Aleksey Shipilёv 的 blog post,它也演示了如何使用 JMH 来验证标量替换是否发生在特定场景中。

简而言之,在像您这样的简单情况下,分配消除很有可能按预期工作。不过,正如我上面提到的,可能会有一些边缘情况。

hotspot-compiler-dev 邮件列表上有一个 recent discussion 关于部分逃逸分析提案。如果实施,它将显着扩展标量替换优化的适用性。