我们先从一个小故事开始讲起。几个星期前,我在Java核心库的邮件列表中发起一个修改的提议,希望能重写一些目前是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注解来控制是否要内联一个方法。后面我们会讲到什么是内联缓存。
类层次的深度及方法的重写
如果我们选择移除方法的final关键字,这意味我们可以对它进行重写了。这是另一个我们需要考虑的因素。因些我在一个类的不同的层级上调用它的方法,同时在不同的层级上对它们进行重写,这样我才能弄清楚类的深度和重写到底会产生多大的开销。
多态
在前面我提到调用点的时候,我故意漏掉了一个很重要的细节。由于可以在子类中重写非final方法,我们的调用点可能最终会调用到不同的方法。那么可能我传的是Foo对象或者是它的子类——Baz——它也实现了bar()方法。那编译器怎么知道该调用哪个方法呢?Java中的方法默认都是虚方法(可重写的),也就是说每次方法调用都得在一张表中查找合适的方法,这个表称为虚方法表。这个过程是相当慢的,因此好的编译器都会尝试去减少这个查找的开销。我们之前提到的一个方法是内联,如果你的编译器能够确定在一个指定的调用点只会调用到某个方法的话那就太棒了。这样的调用点被称作单态调用点。
不幸的是要证明一个调用点是单态的所花费的时间大多都是不切实际的。JIT编译器倾向于采用另一种方法,它会统计各个调用点实际调用的类型,如果前N个调用都是单态的,那它就会猜测这个调用点可能一直都是单态的。这个投机式的优化通常来说都是正确的,但由于它并不全是对的,因此编译器需要方法调用前插入一个守卫,以便检查这个方法的类型。
我们想要优化的可不止单态调用点一个而已。有很多调用点专业点的话叫做双态——它可能会调用到两个方法。你可以使用你的守卫代码来判断应该调用哪个方法,然后跳转过去。这比完整的方法调用可要廉价多了。这种情况也可以使用内联缓存来进行优化。内联缓存并不是实际将方法体内联到调用点,而是使用了一个专门的跳转表,它就像是一个完整的虚方法表查询的缓存。HotSpot的JIT编译器支持双态内联缓存,它将那些有三个以上可能的实现的调用点称为”兆态“(megamorphic)。
现在我们区分出了三种需要进行基准测试和分析的调用方式:单态,双态,及兆态。
测试结果
我们将测试结果进行分组收集,这样能更容易看清问题的本质。我把原始数据列了出来,同时还附带了一点分析。具体的数字或者开销意义其实并不是特别大。但有趣的是不同的方法调用间的比率以及相应的标准误差都非常的低。不同调用的区别非常明显——最快和最慢的实现差了6.26倍。现实中这种差距可能更大,因为这里我们测量的是一个空方法的开销。
这些基准测试的源代码在GitHub上有。我没有把结果都放到起以免产生混淆。多态的基准测试是运行PolymorphicBenchmark的结果,而其它的是运行JavaFinalBenchmark的结果。
简单调用点
我们的第一组数据比较的是虚方法,final方法,以及一个在很深的类层次中进行重写的方法间的调用开销。注意,我们这里强制让编译器不进行内联。我们可以看到,它们之间的差别非常小,标准误差也很低。因此我们可以得出这样的结论,简单的加一个final关键字其实不会对方法调用的性能有太大的提升。同样的,重写方法也不会产生太大的区别。
简单调用点内联
现在我们还是使用这三个用例进行测试,但去掉了内联的限制。这次final和虚方法调用的结果仍然很接近。它们比非内联版本快了大概4倍,我认为这当然是进行了内联的原因。重写的这个方法介于两者之间。我怀疑这是由于这个方法可能存在多个子类的实现,导致编译器插入了一个类型守卫(type
guard)。这个机制在上面的多态一节中已经有很详细的描述。
类层级的影响
哇,这里的方法可真多。每个编号的方法(1-4)都对应着方法调用所在类的层级的深度。因此parentMethod4方法意味着我们调用的这个方法声明在类的第4级的父类中。如果你看一下结果数据你会发现1到4之间其实没太大区别。因此我们可以认为,类层级的深度并没有什么影响。内联的方法性能和前面的inlinableAlwaysOverriddenMethod结果差不多,但比inlinableVirtualInvoke要差些。我认为这是使用了类型守卫的原因。JIT编译器可以统计方法找出需要内联的那个,但它不能确保一直都会是调用的这个方法。
类层级对final方法的影响
这和上面的结果差不多——final关键字看起来并没有太大的影响。我原本认为这里可能inlinableParentFinalMethod4会被内联成不使用类型守卫的版本,不过从结果来看并不是这样。
多态
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
最后终于到了多态分发的了。单态调用的开销和通常的虚方法调用是一样的。随着我们需要查找的虚方法表越来越大,它们也变得越来越慢了,就像双态和兆态那两个例子中那样。一旦我们开启了内联,类型分析开始介入了,单态和双态的调用点的开销降低成了”内联守卫(inlined
with guard)“的开销。跟类层级用例中的很类似,只是稍微慢了些。兆态的这个仍然十分缓慢。记住,我们并没有告诉hotspot说不要去进行内联,只是它没有为比双态更复杂的调用点来实现多态的内联缓存而已。
我们从中学到了什么?
我想值得注意的是,很多人对于不同类型的方法调用需要不同的时间并没有一个清晰的性能模型,同时虽然很多人也知道它们所花费的时间不同,但却没能正确地理解它。我曾经也是这样,也曾做过话多错误的假设。因此我希望这次的分析能对你们有所帮助。下面是我的一些总结。
方法调用的最快实现和最慢实现的差别还是很大的。
在实践中增加或者减少final关键字其实对性能的影响并不大。
类层次结构的深度对方法调用的性能并没有什么实际的影响。
单态调用比双态调用要快。
双态调用比兆态调用要快。
在我们前面看到的基于统计分析可能是单态调用但是并不确定的用例中(注:会进行内联优化,但是在调用前会插入一个类型守卫),里面所使用到的类型守卫的确是会影响到性能。
类型守卫的开销对我个人而言有很大的启发。我很少看到有人提及它,通常大家都认为它无关而忽略掉了。
说明
当然这并不是这个话题的一个结论性的论述。
本文关注的只是和方法调用性能相关的类型相关的因素。有一个因素我没有提及到的是方法体的大小和调用栈的深度对内联的影响。如果你的方法太大的话,它是不会被内联的,你仍然会为方法调用的开销买单。这也是为什么方法要写得小而易看懂的一个原因。
我没有分析通过接口调用方法对这几种情况的影响。如果你对这个感兴趣的话,在Mechanical
Sympathy的博客上有关于接口调用性能的一个分析。
还有一个完全忽略了的因素是方法内联对其它编译器优化的影响。当编译只对某个方法进行优化的时候,它当然希望能收集尽可能多的信息,这样它才能更有效地进行优化。内联优化的限制可能会对其它优化产生很大的影响。
从汇编的层面来进行分析才能对这个问题有更深入的了解。
也许在以后的博客中会讨论一下这些话题。
|