在简单的情况下,例如 for-each 循环中的迭代器,Hotspot 中的逃逸分析有多脆弱
how fragile is escape analysis in Hotspot in simple cases such as iterator in for-each loop
假设我有一个要循环的 java.util.Collection。通常我会这样做:
for(Thing thing : things) do_something_with(thing);
但是假设这是在一些到处都在使用的核心实用方法中,并且在大多数地方,集合是空的。那么理想情况下,我们不希望只是为了执行无操作循环而对每个调用者施加迭代器分配,我们可以重写如下:
if(things.isEmpty()) return;
for(Thing thing : things) do_something_with(thing);
一个更极端的选择,如果 things
是一个列表,将使用 C 风格的 for
循环。
但是等等,Java-转义分析应该消除这个分配,至少在 C2 编译器绕过这个方法之后。所以应该不需要这种“纳米优化”。 (我什至不会用微优化这个词来形容它;它有点太小了。)除了...
我一直听说逃逸分析是“脆弱的”,但似乎没有人谈论特别是什么会把它搞砸。直觉上,我认为更复杂的控制流是最主要的恐惧,这意味着应该可靠地消除 for-each 循环中的迭代器,因为那里的控制流很简单。
这里的标准反应是尝试 运行 一个实验,但除非我知道起作用的变量,否则很难相信我可能会从这样的实验中得出任何结论。
的确,这是一个博客 post,其中有人尝试过这样的实验,3 个分析器中有 2 个给出了错误的结果:
http://psy-lob-saw.blogspot.com/2014/12/the-escape-of-arraylistiterator.html
我对晦涩难懂的 JVM 魔法的了解比该博客的作者少得多 post,而且很可能更容易被误导。
你的方法行不通。正确的做法是:
- 除非您是性能专家(这很难成为),否则不要假设哪种代码性能好与性能差,并在分析分析器报告时保持怀疑态度。这不是特别有用的建议(它归结为:分析器报告可能在骗你!),但它就是这样。实际上,要么成为性能专家,要么接受您对此无能为力。糟透了,但是,不要开枪。
- 编写惯用的 java 代码。最容易维护,最有可能被热点优化。
- 减少算法的复杂性很有用,应该始终是您检查的第一件事。在某种程度上,降低算法复杂性的优化会忽略第一条规则。您不需要特别了解 JVMTI 或 Flight Recorder 的变化无常以及分析器如何工作以得出算法重写是值得的并且将显着提高性能的结论。
- 不要相信精辟的经验法则,不管有多少人在说。不要寻找 'easy to apply patterns' 之类的 'replace all foreach loops by appending an if-block that tests for empty first' - 这些基本上永远不会正确,通常 会降低 性能。
- 请注意,糟糕的性能建议非常普遍。你应该 永远不要 将一些无处不在的论证视为生活和逻辑推理中的一般原则(它是,在全部,一个逻辑谬误!),但这对性能来说是双倍的!
更深入的思考
据推测,您不会仅仅因为我告诉您要相信它们就相信上述格言。我将尝试通过一些可证伪的推理向您展示为什么上述格言是正确的。
特别是,这种首先检查是否为空的想法似乎极度被误导了。
让我们首先将过于夸张且因此相当无用的众所周知的格言过早的优化是万恶之源翻译成更具体的东西:
不要因为想象中的性能问题而使您的代码变得丑陋、充满警告的怪异混乱。
为什么我不能遵循经常听到的格言?
这里不要指望“人”。因为“人”因一次又一次地完全错误 表现而臭名昭著。如果您能找到关于 X 对性能的好坏的广泛、简洁且完全没有证据或研究的陈述,您可以放心地认为这意味着 绝对没有任何意义 。在这方面,你的普通 joe 推特作家或诸如此类的东西是一个无能为力的白痴。证明、充分的研究或证书是认真对待事情的绝对要求,最好是其中的 2 或 3 个。有一些众所周知的性能谬误列表(关于如何提高 JVM 性能的普遍信念,这些信念绝对没有任何帮助,而且通常实际上是有害的),如果您随后搜索这些谬误,您会发现一大群支持它的人,因此证明你不能仅仅基于你“不断听到它”这一事实就相信任何东西。
还请注意,对于几乎每一行 java 代码,您都可以想出 100 多个似是而非但有些奇特的想法来使代码不那么明显但看起来 'more performant'。很明显你不能将所有 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)
算法,如:如果您将 'size of input' 与 'time taken to run the algorithm' 作图,该算法似乎会产生一条看起来像 y = x^2
的曲线,但热点设法将其转变为 y = x
线性事件。这几乎是不可能的 - 改进往往始终是常数因素,因此一般来说,在它上面投入更多 CPU 个内核 and/or RAM 同样有效。
此外,当然,算法的胜利总是让任何热点都相形见绌,micro/nano-optimizations 可以为你做的。
因此:只要编写看起来不错、易于测试、以惯用的方式编写并使用正确、最有效的算法的代码,它就会 运行 很快。如果它不够快,请增加 CPU 或 RAM。如果还不够快,花10年成为专家。
“让我们添加一张空头支票,以防万一!”不符合那个计划。
标量替换确实是一种你永远无法绝对确定的优化,因为它取决于太多因素。
首先,只有当实例的所有使用都内联在一个编译单元中时,才能消除分配。如果是迭代器,则意味着迭代器构造函数、hasNext
和 next
调用(包括嵌套调用)必须内联。
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()
,迭代器将分配在堆上。要进行消除,迭代器的类型必须始终相同。
有 more examples in a great talk Ruslan Cheremin 的 Scalar Replacement(它是俄语,但 YouTube 的字幕翻译功能可以解决)。
另一篇推荐阅读的文章是 Aleksey Shipilёv 的 blog post, which also demonstrates how to use JMH,用于验证标量替换是否在特定场景中发生。
简而言之,在像您这样的简单情况下,分配消除很有可能会按预期工作。正如我上面提到的,可能会有一些边缘情况。
hotspot-compiler-dev
邮件列表中有一个关于部分逃逸分析提案的 recent discussion。如果实施,它可以显着扩展标量替换优化的适用性。
假设我有一个要循环的 java.util.Collection。通常我会这样做:
for(Thing thing : things) do_something_with(thing);
但是假设这是在一些到处都在使用的核心实用方法中,并且在大多数地方,集合是空的。那么理想情况下,我们不希望只是为了执行无操作循环而对每个调用者施加迭代器分配,我们可以重写如下:
if(things.isEmpty()) return;
for(Thing thing : things) do_something_with(thing);
一个更极端的选择,如果 things
是一个列表,将使用 C 风格的 for
循环。
但是等等,Java-转义分析应该消除这个分配,至少在 C2 编译器绕过这个方法之后。所以应该不需要这种“纳米优化”。 (我什至不会用微优化这个词来形容它;它有点太小了。)除了...
我一直听说逃逸分析是“脆弱的”,但似乎没有人谈论特别是什么会把它搞砸。直觉上,我认为更复杂的控制流是最主要的恐惧,这意味着应该可靠地消除 for-each 循环中的迭代器,因为那里的控制流很简单。
这里的标准反应是尝试 运行 一个实验,但除非我知道起作用的变量,否则很难相信我可能会从这样的实验中得出任何结论。
的确,这是一个博客 post,其中有人尝试过这样的实验,3 个分析器中有 2 个给出了错误的结果:
http://psy-lob-saw.blogspot.com/2014/12/the-escape-of-arraylistiterator.html
我对晦涩难懂的 JVM 魔法的了解比该博客的作者少得多 post,而且很可能更容易被误导。
你的方法行不通。正确的做法是:
- 除非您是性能专家(这很难成为),否则不要假设哪种代码性能好与性能差,并在分析分析器报告时保持怀疑态度。这不是特别有用的建议(它归结为:分析器报告可能在骗你!),但它就是这样。实际上,要么成为性能专家,要么接受您对此无能为力。糟透了,但是,不要开枪。
- 编写惯用的 java 代码。最容易维护,最有可能被热点优化。
- 减少算法的复杂性很有用,应该始终是您检查的第一件事。在某种程度上,降低算法复杂性的优化会忽略第一条规则。您不需要特别了解 JVMTI 或 Flight Recorder 的变化无常以及分析器如何工作以得出算法重写是值得的并且将显着提高性能的结论。
- 不要相信精辟的经验法则,不管有多少人在说。不要寻找 'easy to apply patterns' 之类的 'replace all foreach loops by appending an if-block that tests for empty first' - 这些基本上永远不会正确,通常 会降低 性能。
- 请注意,糟糕的性能建议非常普遍。你应该 永远不要 将一些无处不在的论证视为生活和逻辑推理中的一般原则(它是,在全部,一个逻辑谬误!),但这对性能来说是双倍的!
更深入的思考
据推测,您不会仅仅因为我告诉您要相信它们就相信上述格言。我将尝试通过一些可证伪的推理向您展示为什么上述格言是正确的。
特别是,这种首先检查是否为空的想法似乎极度被误导了。
让我们首先将过于夸张且因此相当无用的众所周知的格言过早的优化是万恶之源翻译成更具体的东西:
不要因为想象中的性能问题而使您的代码变得丑陋、充满警告的怪异混乱。
为什么我不能遵循经常听到的格言?
这里不要指望“人”。因为“人”因一次又一次地完全错误 表现而臭名昭著。如果您能找到关于 X 对性能的好坏的广泛、简洁且完全没有证据或研究的陈述,您可以放心地认为这意味着 绝对没有任何意义 。在这方面,你的普通 joe 推特作家或诸如此类的东西是一个无能为力的白痴。证明、充分的研究或证书是认真对待事情的绝对要求,最好是其中的 2 或 3 个。有一些众所周知的性能谬误列表(关于如何提高 JVM 性能的普遍信念,这些信念绝对没有任何帮助,而且通常实际上是有害的),如果您随后搜索这些谬误,您会发现一大群支持它的人,因此证明你不能仅仅基于你“不断听到它”这一事实就相信任何东西。
还请注意,对于几乎每一行 java 代码,您都可以想出 100 多个似是而非但有些奇特的想法来使代码不那么明显但看起来 'more performant'。很明显你不能将所有 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)
算法,如:如果您将 'size of input' 与 'time taken to run the algorithm' 作图,该算法似乎会产生一条看起来像 y = x^2
的曲线,但热点设法将其转变为 y = x
线性事件。这几乎是不可能的 - 改进往往始终是常数因素,因此一般来说,在它上面投入更多 CPU 个内核 and/or RAM 同样有效。
此外,当然,算法的胜利总是让任何热点都相形见绌,micro/nano-optimizations 可以为你做的。
因此:只要编写看起来不错、易于测试、以惯用的方式编写并使用正确、最有效的算法的代码,它就会 运行 很快。如果它不够快,请增加 CPU 或 RAM。如果还不够快,花10年成为专家。
“让我们添加一张空头支票,以防万一!”不符合那个计划。
标量替换确实是一种你永远无法绝对确定的优化,因为它取决于太多因素。
首先,只有当实例的所有使用都内联在一个编译单元中时,才能消除分配。如果是迭代器,则意味着迭代器构造函数、hasNext
和 next
调用(包括嵌套调用)必须内联。
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()
,迭代器将分配在堆上。要进行消除,迭代器的类型必须始终相同。
有 more examples in a great talk Ruslan Cheremin 的 Scalar Replacement(它是俄语,但 YouTube 的字幕翻译功能可以解决)。
另一篇推荐阅读的文章是 Aleksey Shipilёv 的 blog post, which also demonstrates how to use JMH,用于验证标量替换是否在特定场景中发生。
简而言之,在像您这样的简单情况下,分配消除很有可能会按预期工作。正如我上面提到的,可能会有一些边缘情况。
hotspot-compiler-dev
邮件列表中有一个关于部分逃逸分析提案的 recent discussion。如果实施,它可以显着扩展标量替换优化的适用性。