4

假设我有一个想要循环的 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 循环中的迭代器,因为控制流很简单。

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

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

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

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

4

2 回答 2

4

你的方法不起作用。正确的做法是这样的:

  • 除非您是性能专家(这很难成为),否则不要假设哪种代码性能好与性能差,并在分析分析器报告时保持怀疑。这不是特别有用的建议(归结为:分析器报告可能对您撒谎!),但事实就是如此。实际上,要么成为一名性能专家,要么接受你对此无能为力。糟透了,但是,不要射击信使。
  • 编写惯用的 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 代码. 如果您编写没有其他人编写的 bizarro java 代码,hotspot 更有可能无法优化它,因为hotspot 工具的作者也是人,他们针对常见情况进行优化,而不是为怪异而优化。资料来源:Douglas Hawkins,例如 Azul 的 JVM 性能工程师,这个 devoxx 演示文稿,以及许多其他 JVM 性能工程师都说过类似的话。

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

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

大多。但是,嘿,CPU 和内存非常便宜,hotspot 很少对算法进行改进(例如,hotspot 很少将一种算法变成一个算法,O(n^2)例如O(n):如果您将“输入大小”与“耗时运行算法”,该算法似乎会产生一条看起来像 的曲线y = x^2,但热点设法将其变成y = x线性事件。这很少或不可能 - 改进往往是恒定因素,因此投入更多的 CPU 内核和/或 RAM 通常同样有效。

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

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

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

于 2021-06-01T17:26:16.163 回答
4

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

首先,只有当实例的所有使用都内联在一个编译单元中时,才可以消除分配。如果是迭代器,则意味着迭代器构造函数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关于部分逃逸分析提案的邮件列表的讨论。如果实施,它可以显着扩展标量替换优化的适用性。

于 2021-06-02T01:04:57.200 回答