方法内联 是编译器最重要的优化手段,因为它除了消除方法调用的成本外,更重要的意义是为其他优化手段建立良好的基础。没有方法内联,多数其他优化都无法有效进行。方法内联的优化行为是把目标方法的代码原封不动地复制到发起调用的方法之中,避免发生真实的方法调用。

以 getter/setter 为例,如果没有方法内联,在调用 getter/setter 时,程序需要保存当前方法的执行位置,创建并压入用于 getter/setter 的栈帧、访问字段、弹出栈帧,最后再恢复当前方法的执行。而当内联了对 getter/setter 的方法调用后,上述操作仅剩字段访问。

方法内联只发生在即时编译器中。在 C2 中,方法内联是在解析字节码的过程中完成的。每当碰到方法调用字节码时,C2 将决定是否需要内联该方法调用。如果需要内联,则开始解析目标方法的字节码。

方法内联的条件

方法内联能够触发更多的优化。通常而言,内联越多,生成代码的执行效率越高。但对于即时编译器来说,方法内联越多,编译时间也就越长,而程序达到峰值性能的时刻也将被推迟。

此外,内联越多也将导致生成的机器码越长。在 Java 虚拟机里,编译生成的机器码会被部署到 Code Cache 之中。这个 Code Cache 是有大小限制的(由 Java 虚拟机参数 -XX:ReservedCodeCacheSize 控制)。这意味着生成的机器码越长越容易填满 Code Cache,从而出现 Code Cache 已满,即时编译已被关闭的警告信息。

因此,即时编译器不会无限制地进行方法内联。下面列举即时编译器的部分内联规则:

首先,由 -XX:CompileCommand 中的 inline 指令指定的方法,以及由 @ForceInline 注解的方法(仅限于 JDK 内部方法)会被强制内联。而由 -XX:CompileCommand 中的 dontinline 指令或 exclude 指令(表示不编译)指定的方法,以及由 @DontInline 注解的方法(仅限于 JDK 内部方法)则始终不会被内联。

其次,如果调用字节码对应的符号引用未被解析、目标方法所在的类未被初始化,或者目标方法是 native 方法都将导致方法调用无法内联。再次,C2 不支持内联超过 9 层的调用(可以通过虚拟机参数 -XX:MaxInlineLevel 调整)以及 1 层的直接递归调用(可以通过虚拟机参数 -XX:MaxRecursiveInlineLevel 调整)。

最后,即时编译器将根据方法调用指令所在的程序路径的热度,目标方法的调用次数及大小,以及当前 IR 图的大小来决定方法调用能否被内联。

下面的表格列举了一些 C2 相关的虚拟机参数:

参数名 默认值 说明
-XX:InlineSmalICode 2000 如果目标方法已被编译,且其生成的机器码大小超
过该值,则无法内联
-XX:MaxTrivialSize 6 如果方法的字节码大小少于该值,则直接内联
-XX:MinInliningThreshold 250 如果目标方法的调用次数低于该值,则无法内联
-XX:InlineFrequencyCount 100 如果方法调用指令执行次数超过该值,则认为是热点方法
-XX:MaxInlineSize 35 如果非热点方法的字节码大小超过该值,则无法内联
-XX:FreqInlineSize 325 如果热点方法的字节码大小超过该值,则无法内联
-XX:+PrintInlining 打印编译过程中的内联情况

总体来说,即时编译器中的内联算法更青睐于小方法。

去虚化

方法内联的优化行为理解起来是没有任何困难的,不过就是把目标方法的代码原封不动地复制到发起调用的方法之中,避免发生真实的方法调用而已。但实际上 Java 虚拟机中的内联过程却并不容易,主要原因在于 Java 语言中默认的实例方法是虚方法。

对于静态方法调用,即时编译器可以轻易地确定唯一的目标方法,那么直接进行内联就可以了。而虚方法的方法调用,必须在运行时进行方法接收者的多态选择,它们都有可能存在多于一个版本的方法接收者。因此编译器静态地去做内联时很难确定应该使用哪个方法版本,这应该是根据实际类型动态分派的,而实际类型必须在实际运行到这一行代码时才能确定。

因此,对于需要动态绑定的虚方法调用来说,即时编译器则需要先对虚方法调用进行 去虚化(devirtualize),即转换为一个或多个直接调用,然后才能进行方法内联。

即时编译器的去虚化方式可分为 完全去虚化 以及 条件去虚化。

  • 完全去虚化:通过类型推导或者类继承关系分析(class hierarchy analysis,CHA)技术,识别虚方法调用的唯一目标方法,从而将其转换为直接调用的一种优化手段。它的关键在于证明虚方法调用的目标方法是否是唯一的。

  • 条件去虚化:将虚方法调用转换为若干个类型测试以及直接调用的一种优化手段。它的关键在于找出需要进行比较的类型。

类继承关系分析(CHA)技术是整个应用程序范围内的类型分析技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。这样编译器在进行内联时会根据不同情况采取不同处理。

在介绍具体的去虚化方式之前,我们先来看一段代码。这里我定义了一个抽象类 BinaryOp,其中包含一个抽象方法 apply。BinaryOp 类有两个子类 Add 和 Sub,均实现了 apply 方法。

  1. abstract class BinaryOp {
  2. public abstract int apply(int a, int b);
  3. }
  4. class Add extends BinaryOp {
  5. public int apply(int a, int b) {
  6. return a + b;
  7. }
  8. }
  9. class Sub extends BinaryOp {
  10. public int apply(int a, int b) {
  11. return a - b;
  12. }
  13. }

下面用这个例子来逐一讲解这几种去虚化方式。

1. 完全去虚化

1.1 基于类型推导的完全去虚化

基于类型推导的完全去虚化将通过数据流分析推导出调用者的动态类型,从而确定具体的目标方法。

如果根据数据流分析能直接确定调用者的动态类型,那么就进行这项去虚化。如果需要额外的数据流分析方能确定,那么干脆不做以节省编译时间,并依赖接下来的去虚化手段进行优化。

1.2 基于 CHA 的完全去虚化

基于类继承关系分析(CHA)的完全去虚化通过分析 Java 虚拟机中所有已被加载的类,判断某个抽象方法或者接口方法是否仅有一个实现(没有多个目标版本可供选择)。如果是,那对这些方法的调用将只能调用至该具体实现中。因此可以假设先按这个具体实现来进行内联,这种内联被称为 守护内联(Guarded Inlining)。

如下方法调用示例:

  1. public static int foo() {
  2. BinaryOp op = new Add();
  3. return op.apply(2, 1);
  4. }
  5. public static int bar(BinaryOp op) {
  6. op = (Add) op;
  7. return op.apply(2, 1);
  8. }

假设在编译 foo 和 bar 方法时,Java 虚拟机仅加载了 Add 类。那 BinaryOp.apply 方法只有 Add.apply 这么一个具体实现。因此,当即时编译器碰到对 BinaryOp.apply 的调用时,便可直接内联 Add.apply 的内容。

不过由于 Java 程序是动态连接的,即时编译器无法保证在今后的执行过程中,BinaryOp.apply 方法还是只有这一个实现类,Java 虚拟机有可能在上述编译完成之后加载 Sub 类,从而引入另一个 BinaryOp.apply 方法的具体实现 Sub.apply。因此这种内联属于激进预测性优化,必须预留好逃生门。

Java 虚拟机的具体做法是为当前编译结果注册若干个 假设(assumption),假定某抽象类只有一个子类或者某抽象方法只有一个具体实现,又或者某类没有子类等。之后,每当新的类被加载,Java 虚拟机便会重新验证这些假设。如果虚拟机一直没有加载到会令某个假设不再成立的类,那这个内联优化的代码就可以一直使用。如果某个假设不再成立,那么 Java 虚拟机就必须对其所属的编译结果进行去优化,抛弃已编译的代码退回到解释状态进行执行或重新进行编译。

事实上,即便在上面的代码示例中将调用者的声明类型修改为 Add,即时编译器仍需为之添加假设。因为 Java 虚拟机不能保证没有重写了 apply 方法的 Add 类的子类。为了保证这里 apply 方法的语义,即时编译器需要假设 Add 类没有子类。当然,通过将 Add 类显式标注为 final 可以避开这个问题。

可以看到,即时编译器并不要求目标方法使用 final 修饰符。只要目标方法事实上是 final 的,便可以进行相应的去虚化以及内联。不过,如果使用了 final 修饰符,即时编译器便可以不用生成对应的假设。这将使编译结果更加精简,并减少类加载时所需验证的内容。

2. 条件去虚化

条件去虚化通过向代码中添加若干个类型比较,将虚方法调用转换为若干个直接调用。具体的原理就是将调用者的动态类型,依次与 Java 虚拟机所收集的类型 Profile(Type Profile)中记录的类型相比较。如果匹配则直接调用该记录类型所对应的目标方法。

  1. public static int test(BinaryOp op) {
  2. return op.apply(2, 1);
  3. }

我们继续使用前面的例子。假设编译时类型 Profile 记录了调用者的两个类型 Sub 和 Add,那么即时编译器可以据此进行条件去虚化,依次比较调用者的动态类型是否为 Sub 或者 Add 并内联相应的方法:

  1. public static int test(BinaryOp op) {
  2. if (op.getClass() == Sub.class) {
  3. return 2 - 1; // inlined Sub.apply
  4. } else if (op.getClass() == Add.class) {
  5. return 2 + 1; // inlined Add.apply
  6. } else {
  7. ... // 当匹配不到类型Profile中的类型怎么办?
  8. }
  9. }

如果遍历完内类型 Profile 中的所有记录,仍旧匹配不到调用者的动态类型,那么即时编译器有两种选择:

  • 如果类型 Profile 是完整的,即所有出现过的动态类型都被记录至类型 Profile 之中了,那么即时编译器可以让程序进行去优化,再重新收集类型 Profile。

  • 如果类型 Profile 是不完整的,即某些出现过的动态类型并没有记录至类型 Profile 之中,那么重新收集并没有多大作用。此时,即时编译器可以让程序进行原本的虚调用,通过内联缓存进行调用,或者通过方法表进行动态绑定。

每个字节码的类型 Profile 是有数量限制的,比如默认情况下只能存两个不同的动态类型。如果收集 Profile 过程中来了三个不同的动态类型,JVM 不能全部记下,因此即时编译器看到的类型 Profile 是不完整的

3. 内联缓存

内联缓存(Inline Cache)是一个建立在目标方法正常入口之前的缓存,它的工作原理大致为:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本。如果以后进来的每次调用的方法接收者版本都是一样的,那么这时它就是一种单态内联缓存(Monomorphic Inline Cache)。通过该缓存来调用,比用不内联的非虚方法调用,仅多了一次类型判断的开销而已。但如果真的出现方法接收者不一致的情况,就说明程序用到了虚方法的多态特性,这时候会退化成超多态内联缓存(Megamorphic Inline Cache),其开销相当于真正查找虚方法表来进行方法分派。