Hotspot中的escape分析在简单的情况下是多么脆弱,比如每个循环的迭代器



假设我有一个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编译器开始使用这种方法之后。所以应该不需要这个";纳米优化";。(我甚至不会用微观优化这个词来美化它;它有点太小了。)除了。。。

我一直听说逃逸分析是";脆弱的";但似乎从来没有人谈论过什么特别会把事情搞砸。直观地说,我认为更复杂的控制流将是最担心的事情,这意味着每个循环中的迭代器应该可靠地消除,因为那里的控制流很简单。

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

事实上,这里有一篇博客文章,有人尝试了这样的实验,三分之二的分析人员给出了错误的结果:

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

与那篇博客文章的作者相比,我对晦涩难懂的JVM魔法的了解要少得多,而且很可能更容易被误导。

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

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

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

然而,内联本身在HotSpot中是一个脆弱的优化,因为它依赖于许多启发式和限制。例如,由于达到最大内联深度,或者由于外部编译已经太大,iterator.next()调用可能没有完全内联到循环中。

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

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

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

Ruslan Cheremin在一篇关于Scalar Replacement的精彩演讲中有更多的例子(这是俄语,但YouTube的字幕翻译功能起到了拯救作用)。

另一篇推荐阅读的文章是Aleksey Shipilõv的博客文章,其中还演示了如何使用JMH来验证标量替换是否发生在特定场景中。

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

最近在hotspot-compiler-dev邮件列表上有一个关于部分逃逸分析提案的讨论。如果实施,它可以显著扩展标量替换优化的适用性。

您的方法不起作用。正确的方法是:

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

更深入的沉思

大概,你不会因为我告诉你要相信以上格言而相信它们。我将试着带你通过一些可证伪的推理,向你展示为什么以上格言是正确的。

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

让我们首先将过于夸张、因此相当无用的众所周知的格言过早优化是万恶之源转化为更具体的东西:

不要因为想象中的性能问题而让你的代码变得丑陋、充满警告的怪异

为什么我不能去听常听的格言

不要用";人;在这里因为";人;因一次又一次在性能上完全错误而臭名昭著。如果你能找到广泛、简洁、完全没有证据或研究表明X对性能是好是坏的,你可以放心,这意味着绝对没有任何东西。在这方面,你的普通推特作家或其他什么人都是个愚蠢的白痴。证据、充分的研究或证书是认真对待事情的绝对要求,最好是其中的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代码,那么热点更有可能无法优化它,因为热点工具的作者也是人,他们针对常见情况进行优化,而不是针对怪异情况。来源:Douglas Hawkins,Azul的JVM性能工程师,例如这个devoxx演示,和许多其他JVM性能工程师都说过类似的话。

顺便说一句,你会得到易于维护和解释的代码,因为其他java程序员会阅读它并找到熟悉的地方。

说真的,成为一名表演专家,这是唯一的办法吗

大部分。但是,嘿,CPU和内存相当便宜,热点很少进行算法改进(如中所示,热点很少会将O(n^2)的算法转化为O(n)的算法,如:如果你将"输入大小"与"运行算法所需的时间"进行比较,则该算法似乎会产生一条看起来像y = x^2的曲线,但热点成功地将其转化为y = x的线性事件。这很罕见,也不可能-改进往往是恒定因素,所以抛出一般来说,增加一些CPU内核和/或RAM也同样有效。

当然,算法的胜利总是让任何热点和微/纳米优化都相形见绌。

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

"让我们加一张空支票,yaknow,以防万一"不符合那个计划。

最新更新