0%

JVM是如何执行方法调用的

设计模式大量使用了虚方法来实现多态,但是虚方法的性能效率并不高。

虚方法调用

Java里所有非私有实例方法调用都会被编译成invokevirtual指令,接口方法调用都会被编译成invokeinterface指令。这两种指令,均属于JVM中的虚方法调用。

虚方法,可以被子类覆盖的方法。

绝大多数情况下,JVM需要根据调用者的动态类型,来确定虚方法调用的目标方法。这个过程称之为动态绑定。相对于静态绑定的非虚方法调用来说,虚方法调用更加耗时。

在JVM中,静态绑定包括用于静态方法的invokestatic指令,和用于调用构造器、私有实例方法以及超类非私有实例方法的invokespecial指令。如果虚方法调用指向一个标记为final的方法,那么JVM也可以静态绑定该虚方法调用的目标方法。

JVM中采取了一种用空间换取时间的策略来实现动态绑定。它为每个类生成一张方法表,用以快速定位目标方法。

方法表

类加载的连接-准备阶段,除了为静态字段分配内存之外,还会构造与该类相关联的方法表。方法表这个数据结构,是JVM实现动态绑定的关键所在。以invokevirtual所使用的虚方法表(virtual method table, vtable)为例。(invokeinterface所使用的接口方法表(interface method table, itable)稍微复杂,但原理类似)

方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。

方法表满足两个特质:

  • 子类方法表中包含父类方法表中的所有方法;
  • 子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。

方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用而言,实际引用将指向具体的目标方法。对于动态绑定的方法调用而言,实际引用则是方法表的索引值(并不仅仅是索引值)。

在执行过程中,JVM将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。

实际上,使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值所对应的目标方法。相对于创建并初始化Java栈帧来说,这几个内存解引用操作的开销可以忽略不计。

在解释执行或者即时编译代码的最坏情况中,上述优化的效果很好。但即时编译还有另外两种性能更好的优化手段:内联缓存(inlining cache)和方法内联(method inlining)。

内联缓存

内联缓存是一种加快动态绑定的优化技术。它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定。

针对多态的优化手段中的术语(内联缓存也有类似定义):

  1. 单态(monomorphic)指的是仅有一种状态的情况。
  2. 多态(polymorphic)指的是有限数量种状态的情况。二态(bimorphic)是多态的其中一种。(HotSpot不存在多态内联缓存)
  3. 超多态(megamorphic)指的是更多种状态的情况。通常用一个具体数值来区分多态和超多态。

单态内联缓存:只缓存了一种动态类型以及它所对应的目标方法。比较所缓存的动态类型,如果命中,则直接调用对应的目标方法。

多态内联缓存:缓存了多个动态类型及其目标方法。需要逐个将缓存的动态类型与当前动态类型进行比较,如果命中,则调用对应的目标方法。

一般来说,会将更加热门的动态类型放在前面。在实践中,大部分的虚方法调用均是单态的,也就是只有一种动态类型。为了节省内存空间,JVM只采用单态内联缓存。

当内联缓存没有命中的情况下,JVM需要重新使用方法表进行动态绑定。对于内联缓存中的内容,有两种选择:

  • 替换单态内联缓存中的记录。

    在替换内联缓存之后的一段时间内,方法调用的调用者的动态类型应当保持一致,从而能够有效的利用内联缓存。最坏情况下,不同类型的调用者,轮流执行该方法调用,每次进行方法调用都将替换内存缓存(只有写缓存的额外开销,没有用缓存的性能提升)。

  • 劣化为超多态状态。(JVM的具体实现方式)

    处于这种状态下的内联缓存,放弃了优化的机会。它将直接访问方法表,来动态绑定目标方法(牺牲了优化的击昏,节省了写缓存的额外开销)。

内联缓存附带内联二字,但是它并没有内联目标方法。任何方法调用除非被内联,否则都会有固定开销。这些开销来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。

对于极其简单的方法,如getter/setter,这部分固定开销占据的CPU时间甚至超过了方法本身。此外,在即时编译中,方法内联不仅仅能够消除方法调用的固定开销,而且还增加了进一步优化的可能性。

总结

虚方法调用包括invokevirtual指令和incokeinterface指令。如果这两种指令所声明的目标方法被标记为final,那么JVM会采用静态绑定。否则将会采用动态绑定,在运行过程中根据调用者的动态类型,来决定具体的目标方法。

JVM的动态绑定是通过方法表这一数据结构来实现的。方法表中每一个重写方法的索引值,与父类方发表中被重写的方法的索引值一致。在解析虚方法调用时,JVM会记录下所声明的目标方法的索引值,并且在运行过程中根据这个索引值查找具体的目标方法。

JVM中的即时编译器会使用内联缓存来加速动态绑定。JVM所采用的单态内联缓存将记录调用者的动态类型,以及它所对应的目标方法。当碰到新的调用者时,如果其动态类型与缓存中的类型匹配,则直接调用缓存的目标方法。否则JVM将该内联缓存劣化为超多态内联缓存,在今后的执行过程中直接使用方法表进行动态绑定。