设为首页 加入收藏

TOP

哪些因素影响Java调用的性能?(一)
2015-07-16 12:55:48 来源: 作者: 【 】 浏览:63
Tags:哪些 因素 影响 Java 调用 性能

这得从一个小故事说起。我在一个Java核心库的邮件列表中提交了一个修改 ——重写了一些本是?final 的方法。一石激起千层浪,这一改动引发了几番讨论。而其中一个讨论的话题是:调用一个去除?final?标记的方法,将导致哪种程度的性能下降(performance regression)。


我不能确定这一改变是否会导致性能下降,但当我决定将此暂时搁置一边,试着寻找在这个讨论里是否有人公布过任何相关的完整基准测试(sane benchmarks)时,结果空手而归。我不能肯定地说有关的基准测试是不存在的,或者说其他人没做过这方面的探讨。但我能肯定的是,在这里,连任何公开的代码评审都没有。唉,看来是时候写一个基准测试了。


我决定选用一个相当不错的框架 —— JMH 来构建基准测试。如果你质疑它测试的准确性,那么建议你看下对这个框架作者(Aleksey Shipilev)的访谈,或者阅读一下由Nitsan Wakart撰写的一篇彰显此框架风采的博文


现在,我想知道哪些因素影响了Java方法调用的性能。所以我决定以不同方式调用方法,并测算它们的性能开销。以单一变量为前提来构造一套基准测试,我便能逐个排除或确定,哪些因素或哪种组合会影响到方法调用的性能。


方法调用的有无,是一个影响程度既是最高又是最低的因素——对于编译器来说,彻底优化方法调用所带来的开销并非不可能,有两种方法可以实现这样的需求:直接内联该方法本身和使用内联缓存(inline cache)。千万别被引入的这些术语给吓倒——它们都是通俗易懂的。现在我们假设有一个叫Foo的类,该类定义了一个叫bar的方法:


我们以如下的方式调用bar方法:


这里有一个重要的知识点:实际调用?bar?的位置,即?foo.bar(),称为调用点(callsite)。当我们说一个方法“被内联”,意指方法体被插入到了调用点的位置上,以代替方法调用。对于那些由许多短小的方法所构成的程序——我称之为被适当分解的程序——内联可以有效地提升性能。这是因为结束以后可以发现,程序并没有把所有时间用在方法调用上,实际上程序并没有工作!我们在JMH中可以借由?CompilerControl?注释控制一个方法是否被内联。关于内联缓存的概念,我稍后再来说明。


如果我们移除一个方法的?final?关键字,便意味着我们能够重写它。所以这是另一个在进行测试我们需要考虑的情况。我会选择在同一层次结构中不同层次的子类里调用一些方法,并且在这些方法里有一些是会被不同层次的子类重写的。这样的测试能让我们确定或排除深的层次结构是否影响到重写所带来的性能开销。


先前我提到调用点这一概念时,我偷偷地回避了一个相当重要的问题——因为在子类中可以重写一个非?final?方法,这使得调用点可以调用不同的方法。现假设我传入一个 Foo 的实例或一个重写了?bar?子类—— Baz的实例,编译器如何得知要调用哪一个?bar?方法呢?在默认情况下,方法将在Java中被虚拟化(可重写)。对于任一调用点,编译器需要在一个称为虚拟表(vtable)的表中寻找与其对应的方法。这是个非常耗时的过程,所以,能进行优化的编译器,总是会试图减少这种查询带来的开销。一种方法就是先前提到的内联,这的确是个良策,但前提是编译器能证明在给定的调用点上调用的方法唯一。而这样的调用点我们称为单态(monomorphic)调用点。


不幸的是,进行这种分析需要耗费大量时间。所以在实际过程中,确定一个调用点是否单态是个不太可取的方法。对此,JIT编译器倾向于使用一种替代方法:列出哪些类可以在此调用点被调用,接着根据之前的N个相同的调用猜测此调用点是否是单态的。以假定某个调用点永远为单态,来进行投机性质的优化往往是可取的行为。因为这样的优化往往都是正确的,但也因它无法确保永远正确,编译器需要在方法调用之前注入一个用于检查方法类型的防护机制。


除了单态的调用点以外,还有两种调用点我们希望对其进行优化。一种称为双态(bimorphic)调用点,在该点上有两个候选方法。对此你依然可以实现内联——借助防护代码,让其检测应调用哪一个方法,并引导程序跳转至内联在调用点的两个方法体中真正对应的那一个。这样的方式还是比查看所有虚拟表的方式要快得多。但在某些情况下,我们得利用内联缓存来进行优化。内联缓存需要借助一张特定的跳转表(?jump table),这种表类似于对虚拟表查找做的一份缓存。hotsopt JIT编译器支持双态内联缓存,并定义那些拥有三个及三个以上候选方法的调用点为超多状态(megamorphic)调用点。


这就使得我在基准测试与探究当中,需要额外地把调用情况划分为三类:单态、双态、超多状态。


让我们把结果分类组织,以便研究细节。我已经提供了统计产生的原始数据。但我们的兴趣点不应放在性能测试结果的具体数值上,而应是不同类型的方法调用的性能开销之间的比率以及各自的错误率是否够低。如果最快与最慢的结果之间比率为6.26,则说明这是一个显著性差异。由于测试时使用的是空方法(详见源代码),所以在实际应用中,这样的差异会更大。


你可以在 github上查看此次基准测试的源代码。为了避免产生困惑,待会所有的结果将分块显示。最后显示的多态的基准测试是在?PolymorphicBenchmark?类中进行,其它的则在?JavaFinalBenchmark?类中。


最先看到的的一组结果,是比较调用一个 virtual 方法、一个?final?方法和一个拥有很深的层级结构,同时被所有子类重写的方法所带来的开销。注意,调用这些方法的时候我们都强制编译器不要内联它们。我们可以看到:三者在时间花费上相差甚微,并且各自的误差率都小到可以忽略。对此我们可以断定,仅添加一个?final?关键字并不会大幅度提升调用性能,重写一个方法也不见得会带来什么影响。


现在,我们在开启内联的情况下再来一次相同的测试。由结果可见,final?方法和 virtual 方法的时间花费依旧相近,并比在没有内联的情况下快了4倍,我将此归功于内联优化。相比而言,被所有子类重写的方法的结果可就没那么好看了。我推测这是由于此方法有多个子类实现,使得编译器必须插入一个类型保护。有关的细节我们将在研究多态性的结果时进行阐述。


哇噢——这儿有好几个的方法!方法名称的编号(1~4)代表该方法调用的层次。因此,parentMethod4 表示我们调用的方法位于class的上面第四级。(译注:在源代码中该方法位于顶层的父类)。由此结果我们能断定,结构层次的深度对性能开销没有影响。在开启内联的实例中,结论也是一样。这个测试中,被内联的方法的性能与?inlinableAlwaysOverriddenMethod?相当,但稍逊于?inlinableVirtualInvoke。我依旧认为这与使用了类型保护有关。事实上JIT编译器能剖析所有候选方法,从而只内联对应的那一个,但这并不证明它总会这么干。


该测试的结论与第一个测试一样 ——?final?关键字不会产生任何影响。我本以为该测试将证明?inlinableParentFinalMethod4?以无类型保护的方式进行内联,但结果表明事实并非如此。


多态性


最后,我们来看涉及多态分派(polymorphic dispatch)的测试结果。单态调用的性能开销与之前v

首页 上一页 1 2 下一页 尾页 1/2/2
】【打印繁体】【投稿】【收藏】 【推荐】【举报】【评论】 【关闭】 【返回顶部
分享到: 
上一篇别人18岁,已经在Google工作了 下一篇Python 装饰器学习以及实际使用场..

评论

帐  号: 密码: (新用户注册)
验 证 码:
表  情:
内  容: