静态方法调用,即时编译器可以轻易的确定唯一的目标方法。
对于需要动态绑定的虚方法调用,即时编译器则需要先对虚方法调用进行去虚化(devirtualize),即转换为一个或多个直接调用,然后才能进行方法内联。
即时编译器的去虚化方式可分为完全去虚化以及条件去虚化(guarded devirtualization)。
完全去虚化是通过类型推导或者类层次分析(class hierarchy analysis),识别虚方法调用的唯一目标方法,从而将其转换为直接调用的一种优化手段。它的关键在于证明虚方法调用的目标方法是唯一的。
条件去虚化则是将虚方法调用转换为若干个类型测试以及直接调用的一种优化手段。它的关键在于找出需要进行比较的类型。
基于类型推导的完全去虚化
基于类型推导的完全去虚化将通过数据流分析推导出调用者的动态类型,从而确定具体的目标方法。
在Sea-of-Nodes的IR系统中,变量不复存在,取而代之的是具体值,这些具体值的类型往往要比变量的声明类型精确。
通过将字节码转换为Sea-of-Nodes IR之后,即时编译器便可以直接去虚化,并将唯一的目标方法进一步内联进来。
1 | //抽象类BinaryOp,抽象方法apply; |
类型推导属于全局优化,本身比较浪费时间;另一方面,就算不进行基于类型推导的完全去虚化,也有基于类层次分析的去虚化,以及条件去虚化兜底,覆盖大部分的代码情况。
因此,C2和Graal决定,如果生成Sea-of-Nodes IR后,调用者的动态类型已能够直接确定,那么就进行这项去虚化。如果需要额外的数据流分析方能确定,那么干脆不做,以节省编译时间,并依赖接下来的去虚化手段进行优化。
基于类层次分析的完全去虚化
基于类层次分析的完全去虚化通过分析JVM中所有已被加载的类,判断某个抽象方法或者接口方法是否仅有一个实现。如果是,那么对这些方法的调用将只能调用至该具体实现中。
在类型推导的例子中,假设在编译foo、bar或者notInlined方法时,JVM仅加载了Add。那么,BinaryOp.apply方法只有Add.apply这么一个具体实现。因此,当即时编译器碰到对BinaryOp.apply的调用时,便可以直接内联Add.apply的内容。
即时编译器无法保证在今后的执行过程中,BinaryOp.apply方法还是只有Add.apply这么一个具体实现。JVM有可能在上述编译完成之后加载Sub类,从而引入另一个BinaryOp.apply方法的具体实现Sub.apply。
JVM的做法是为当前编译结果注册若干个假设(assumption),假定某抽象类只有一个子类,或者某抽象方法只有一个具体实现,又或者某类没有子类等。之后,每当新的类被加载,JVM便会重新验证这些假设。如果某个假设不再成立,那么JVM便会对其所属的编译结果进行去优化。
事实上,即便调用者的声明类型为Add,即时编译器仍需为之添加假设,因为JVM不能保证没有重写了apply方法的Add类的子类。
为了保证这里apply方法的语义,即时编译器需要假设Add类没有子类。
通过将Add类标注为final,可以避开这个问题。即时编译器并不要求目标方法使用final修饰符。只要目标方法事实上是final(effective final),便可以进行相应的去虚化以及内联。不过,如果使用了final修饰符,即时编译器便可以不用生成对应的假设。这将使编译结果更加精简,并减少类加载时所需验证的内容。
子类生成的代码无序检测调用者的动态类型是否为Add,便直接执行内联之后的Add.apply方法中的内容。这是因为动态类型检测已被移至假设之中了。
然而对于接口方法调用,该去虚化手段不能移除动态类型检测。这是因为在执行invokeinterface指令时,JVM必须对调用者的动态类型进行测试,看它是否实现了目标接口方法所在的接口。
Java类验证器将皆苦类型直接看成Object类型,所以有可能出现声明类型为接口,实际类型没有继承该接口的情况。
既然这一类型测试无法避免,C2干脆就不对接口方法调用进行基于类层次分析的完全去虚化,而是依赖于接下来的条件去虚化。
条件去虚化
条件去虚化通过向代码中添加若干个类型比较,将虚方法调用转换为若干个直接调用。
具体的原理非常简单,是将调用者的动态类型,依次与JVM所收集的类型Profile中记录的类型相比较。如果匹配,则直接调用该记录类型所对应的目标方法。
1 | public static int test(BinaryOp op) { |
如果遍历完类型Profile中的所有记录,仍旧匹配不到调用者的动态类型,那么即时编译器有两种选择。
- 第一,如果类型Profile是完整的,也就是说,所有出现过的动态类型都被记录至类型Profile之中,那么即时编译器可以让程序进行去优化,重新收集类型Profile。
- 第二,如果类型Profile是不完整的,也就是说,某些出现过的动态类型并没有记录至类型Profile之中,那么重新收集并没有多大作用。此时,即时编译器可以让程序进行原本的虚调用,通过内联缓存进行调用,或者通过方发表进行动态绑定。(仅在Graal中使用)
在C2中,如果类型Profile是不完整的,即时编译器压根不会进行条件去虚化,而是直接使用内联缓存或者方法表。
每个字节码的type profile有数量限制,默认情况下只能存两个不同的动态类型。如果收集profile过程中来了三个不同的动态类型,那么JVM不能全部记下来,因此即时编译器看到的type profile是不完整的。
总结
完全去虚化通过类型推导或者类层次分析,将虚方法调用转换为直接调用。它的关键在于证明虚方法调用的目标方法是唯一的。
条件去虚化通过向代码中增添类型比较,将虚方法调用转换为一个个的类型测试以及对应该类型的直接调用。它将借助JVM所收集的类型Profile(分层编译时收集的数据)。