哪些因素影响Java调用的性能?

当时发生了什么?

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

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

基准测试的方法论

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

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

内联

哪些因素影响Java调用的性能?

让我们把这些方法调用点压扁

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

1

2

3

 

class Foo {

  void bar() { ... }

}

 

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

1

2

 

Foo foo = new Foo();

foo.bar();

 

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

层次结构深度与重写子类方法

哪些因素影响Java调用的性能?

是因为父母让孩子慢下来了吗?

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

多态性

哪些因素影响Java调用的性能?

动物世界:多态是如何表现的

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

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:https://www.heiqu.com/0ddef3970a957fdabf38a99fee9448b6.html