那么这是怎么回事?
让我们从一个简短的故事开始。 几周前,我提议对Java核心libs邮件列表进行更改 ,以覆盖当前final
一些方法。 这刺激了一些讨论主题-其中之一是其中一个性能回归通过采取这是一个方法被引入的程度final
免遭停止它final
。
我对是否会出现性能下降有一些想法,但是我将这些想法放在一边,试图询问是否有关于此主题的合理基准。 不幸的是我找不到任何东西。 这并不是说它们不存在,也没有其他人没有对此情况进行调查,但是我没有看到任何经过公共同行评审的代码。 所以–是时候写一些基准了。
标杆管理方法
因此,我决定使用功能强大的JMH框架来汇总这些基准。 如果您不相信框架会帮助您获得准确的基准测试结果,那么您应该看一下编写框架的Aleksey Shipilev的演讲 ,或者Nitsan Wakart的非常酷的博客文章,其中解释了它如何提供帮助。
就我而言,我想了解是什么影响了方法调用的性能。 我决定尝试各种不同的方法调用并衡量成本。 通过设置一组基准并一次仅更改一个因素,我们可以单独排除或了解不同因素或因素组合如何影响方法调用成本。
内联
让我们压缩这些方法的调用方法。
同时,最明显的影响因素是根本没有方法调用! 方法调用的实际成本有可能完全被编译器优化。 从广义上讲,有两种降低通话成本的方法。 一种是直接内联方法本身,另一种是使用内联缓存。 不用担心-这些是非常简单的概念,但是需要引入一些术语。 假设我们有一个名为Foo
的类,它定义了一个名为bar
的方法。
class Foo {void bar() { ... }
}
我们可以通过编写如下代码来调用bar
方法:
Foo foo = new Foo();
foo.bar();
这里重要的是实际调用bar的位置– foo.bar()
–称为callsite 。 当我们说一个方法被“内联”时,这意味着该方法的主体已被取而代之以进入方法,而不是方法调用。 对于由许多小方法组成的程序(我认为是一个适当分解的程序),内联可以导致明显的加速。 这是因为该程序并没有花费大部分时间来调用方法,而是没有实际执行工作! 通过使用CompilerControl
批注,我们可以控制方法是否在JMH中内联。 稍后我们将回到内联缓存的概念。
层次深度和覆盖方法
父母会放慢孩子的速度吗?
如果我们选择从方法中删除final
关键字,则意味着我们将能够覆盖它。 因此,这是我们需要考虑的另一个因素。 因此,我采用了方法并在类层次结构的不同级别上调用了它们,并且还具有在层次结构的不同级别上被覆盖的方法。 这使我能够了解或消除深层次的层次结构如何影响成本。
多态性
动物:如何描述任何面向对象的概念。
当我较早提到呼叫站点的想法时,我偷偷地避免了一个相当重要的问题。 由于可以在子类中覆盖非final
方法,因此我们的调用站点final
可能会调用不同的方法。 因此,也许我传入了Foo或它的孩子Baz,它也实现了bar()。 您的编译器如何知道要调用的方法? 默认情况下,方法是Java中的虚拟(可重写)方法,它必须为每个调用在称为vtable的表中查找正确的方法。 这非常慢,因此优化编译器总是试图减少所涉及的查找成本。 我们前面提到的一种方法是内联,如果您的编译器可以证明在给定的调用站点只能调用一种方法,则该方法非常有用。 这称为单态呼叫站点。
不幸的是,证明呼叫站点是单态性所需的许多时间分析最终可能是不切实际的。 JIT编译器倾向于采用另一种方法来分析在调用站点上调用的类型,并猜测如果该调用站点在前N个调用中是单态的,则基于它始终将是单态的假设,值得进行推测性优化。 这种推测性优化通常是正确的,但是由于并不总是正确的,因此编译器需要在方法调用之前注入防护以检查方法的类型。
不过,单态调用站点并不是我们要优化的唯一情况。 许多调用站点被称为双态的 -可以调用两种方法。 您仍然可以内联双态呼叫站点,方法是使用保护代码检查要调用的实现,然后跳转到该实现。 这仍然比完整方法调用便宜。 也可以使用内联缓存来优化这种情况。 内联缓存实际上并不将方法主体内联到调用站点中,但它具有专门的跳转表,其作用类似于完整vtable查找上的缓存。 热点JIT编译器支持双态内联高速缓存,并声明具有3个或更多可能实现的任何呼叫站点都是megamorphic的 。
这为我们划分了3种调用情况,以进行基准测试:单态,双态和超态。
结果
让我们对结果进行分组,以便更轻松地从树木中查看木材。我介绍了原始数字以及围绕它们的一些分析。 实际的数量/成本并不是那么重要。 有趣的是,不同类型的方法调用之间的比率以及相关的错误率很低。 最快和最慢之间存在很大的差异– 6.26倍。 实际上,由于与测量空方法的时间相关的开销,差异可能更大。
这些基准的源代码可在github上找到 。 为了避免混淆,并没有全部显示结果。 最后,多态基准来自运行PolymorphicBenchmark
,而其他基准来自JavaFinalBenchmark
简单的呼叫网站
Benchmark Mode Samples Mean Mean error Units
c.i.j.JavaFinalBenchmark.finalInvoke avgt 25 2.606 0.007 ns/op
c.i.j.JavaFinalBenchmark.virtualInvoke avgt 25 2.598 0.008 ns/op
c.i.j.JavaFinalBenchmark.alwaysOverriddenMethod avgt 25 2.609 0.006 ns/op
我们的第一组结果比较了虚拟方法, final
方法和层次结构较深且被覆盖的方法的调用成本。 请注意,在所有这些情况下,我们都强制编译器不内联方法。 正如我们所看到的,时间之间的差异很小,而且我们的平均错误率表明它并不重要。 因此,我们可以得出结论,仅添加final
关键字并不会大大提高方法调用的性能。 覆盖该方法似乎也没有太大区别。
内联简单的呼叫站点
Benchmark Mode Samples Mean Mean error Units
c.i.j.JavaFinalBenchmark.inlinableFinalInvoke avgt 25 0.782 0.003 ns/op
c.i.j.JavaFinalBenchmark.inlinableVirtualInvoke avgt 25 0.780 0.002 ns/op
c.i.j.JavaFinalBenchmark.inlinableAlwaysOverriddenMethod avgt 25 1.393 0.060 ns/op
现在,我们采用了相同的三种情况,并删除了内联限制。 同样, final
和虚拟方法调用的结束时间彼此相似。 它们比非内联情况快大约4倍,我将其归结为内联本身。 在此始终被覆盖的方法调用最终在两者之间。 我怀疑这是因为方法本身具有多个可能的子类实现,因此编译器需要插入类型保护。 上面在“多态”下对此进行了详细解释。
类等级冲击
Benchmark Mode Samples Mean Mean error Units
c.i.j.JavaFinalBenchmark.parentMethod1 avgt 25 2.600 0.008 ns/op
c.i.j.JavaFinalBenchmark.parentMethod2 avgt 25 2.596 0.007 ns/op
c.i.j.JavaFinalBenchmark.parentMethod3 avgt 25 2.598 0.006 ns/op
c.i.j.JavaFinalBenchmark.parentMethod4 avgt 25 2.601 0.006 ns/op
c.i.j.JavaFinalBenchmark.inlinableParentMethod1 avgt 25 1.373 0.006 ns/op
c.i.j.JavaFinalBenchmark.inlinableParentMethod2 avgt 25 1.368 0.004 ns/op
c.i.j.JavaFinalBenchmark.inlinableParentMethod3 avgt 25 1.371 0.004 ns/op
c.i.j.JavaFinalBenchmark.inlinableParentMethod4 avgt 25 1.371 0.005 ns/op
哇–这是很多方法! 每个编号的方法调用(1-4)表示调用方法的类层次结构有多深。 所以parentMethod4
意味着我们调用了在该类的第4个父级上声明的方法。 如果看一下数字,则1和4之间的差异很小。因此,我们可以得出结论,层次深度没有区别。 可内联的案例都遵循相同的模式:层次深度没有区别。 我们的inlineable方法性能与inlinableAlwaysOverriddenMethod
相当,但比inlinableVirtualInvoke
慢。 我再次将其归结为所使用的类型防护。 JIT编译器可以对方法进行概要分析,以找出仅内联的一种方法,但无法证明这是永远存在的。
类层次对
Benchmark Mode Samples Mean Mean error Units
c.i.j.JavaFinalBenchmark.parentFinalMethod1 avgt 25 2.598 0.007 ns/op
c.i.j.JavaFinalBenchmark.parentFinalMethod2 avgt 25 2.596 0.007 ns/op
c.i.j.JavaFinalBenchmark.parentFinalMethod3 avgt 25 2.640 0.135 ns/op
c.i.j.JavaFinalBenchmark.parentFinalMethod4 avgt 25 2.601 0.009 ns/op
c.i.j.JavaFinalBenchmark.inlinableParentFinalMethod1 avgt 25 1.373 0.004 ns/op
c.i.j.JavaFinalBenchmark.inlinableParentFinalMethod2 avgt 25 1.375 0.016 ns/op
c.i.j.JavaFinalBenchmark.inlinableParentFinalMethod3 avgt 25 1.369 0.005 ns/op
c.i.j.JavaFinalBenchmark.inlinableParentFinalMethod4 avgt 25 1.371 0.003 ns/op
这遵循与上述相同的模式final
关键字似乎没有什么区别。 我会认为这是可能在这里,从理论上说,对于inlinableParentFinalMethod4
来加以证明inlineable没有型后卫,但它不会出现这种情况。
多态性
Monomorphic: 2.816 +- 0.056 ns/op
Bimorphic: 3.258 +- 0.195 ns/op
Megamorphic: 4.896 +- 0.017 ns/op
Inlinable Monomorphic: 1.555 +- 0.007 ns/op
Inlinable Bimorphic: 1.555 +- 0.004 ns/op
Inlinable Megamorphic: 4.278 +- 0.013 ns/op
最后,我们来谈谈多态调度的情况。 单态调用成本与上面的常规虚拟调用成本大致相同。 由于我们需要在较大的vtable上进行查找,因此随着双态和多态情况的显示,它们变得更慢。 一旦启用内联类型分析,我们的单态和双态调用站点就会降低我们的“内联守卫”方法调用的成本。 因此与类层次结构的情况类似,只是速度稍慢一些。 大形情况仍然非常缓慢。 请记住,我们这里没有告诉热点防止内联,只是它没有为比双态更复杂的调用站点实现多态内联缓存。
我们学到了什么?
我认为值得一提的是,有很多人没有表现心理模型来说明花费时间不同的不同类型的方法调用,还有很多人知道他们花费的时间不同,但实际上并没有非常正确。 我知道我以前去过那里,做了各种各样的错误假设。 因此,我希望这项调查对人们有所帮助。 这是我很乐意支持的声明摘要。
- 最快和最慢的方法调用类型之间有很大的不同。
- 在实践中,添加或删除
final
关键字并不会真正影响性能,但是,如果您随后重构层次结构,事情可能会开始放慢速度。 - 更深的类层次结构对呼叫性能没有真正的影响。
- 单态调用比双态调用更快。
- 双态调用比大形调用快。
- 在概要分析(但不是可证明)的情况下,我们看到的类型防护在单态调用站点上确实使速度放慢了很多。
我会说类型保护程序的成本是我个人的“重大启示”。 这是我鲜为人知的话题,经常被认为是无关紧要的。
注意事项和进一步工作
当然,这不是主题领域的最终决定!
- 该博客仅关注与方法调用性能有关的类型相关因素。 我没有提到的一个因素是由于主体大小或调用堆栈深度而导致的围绕方法内联的启发式方法。 如果您的方法太大,则根本不会内联,您仍然要为方法调用的费用付费。 编写小的,易于阅读的方法的另一个原因。
- 我没有研究过接口调用如何影响这些情况。 如果您发现这很有趣,那么可以在Mechanical Sympathy博客上研究调用接口的性能。
- 我们在这里完全忽略的一个因素是方法内联对其他编译器优化的影响。 当编译器执行仅考虑一种方法的优化(过程内优化)时,他们实际上希望获得尽可能多的信息以进行有效的优化。 内联的局限性可以大大缩小其他优化必须使用的范围。
- 将说明直接附加到汇编级别,以深入了解该问题。
也许这些是将来博客文章的主题。
翻译自: https://www.javacodegeeks.com/2014/05/too-fast-too-megamorphic-what-influences-method-call-performance-in-java.html