JVM笔记(七)高效编译(2)

273 阅读23分钟

三、Java语法糖与Java编译器

自动装箱与自动拆箱

Java 语言拥有 8 个基本类型,每个基本类型都有对应的包装(wrapper)类型。

之所以需要包装类型,是因为许多 Java 核心类库的 API 都是面向对象的。举个例子,Java 核心类库中的容器类,就只支持引用类型。

当需要一个能够存储数值的容器类时,我们往往定义一个存储包装类对象的容器。

对于基本类型的数值来说,我们需要先将其转换为对应的包装类,再存入容器之中。在 Java 程序中,这个转换可以是显式,也可以是隐式的,后者正是 Java 中的自动装箱。

public int foo() {
  ArrayList<Integer> list = new ArrayList<>();
  list.add(0);
  int result = list.get(0);
  return result;
}

以上图中的 Java 代码为例。我构造了一个 Integer 类型的 ArrayList,并且向其中添加一个 int 值 0。然后,我会获取该 ArrayList 的第 0 个元素,并作为 int 值返回给调用者。这段代码对应的 Java 字节码如下所示:

public int foo();
  Code:
     0: new java/util/ArrayList
     3: dup
     4: invokespecial java/util/ArrayList."<init>":()V
     7: astore_1
     8: aload_1
     9: iconst_0
    10: invokestatic java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
    13: invokevirtual java/util/ArrayList.add:(Ljava/lang/Object;)Z
    16: pop
    17: aload_1
    18: iconst_0
    19: invokevirtual java/util/ArrayList.get:(I)Ljava/lang/Object;
    22: checkcast java/lang/Integer
    25: invokevirtual java/lang/Integer.intValue:()I
    28: istore_2
    29: iload_2
    30: ireturn

当向泛型参数为 Integer 的 ArrayList 添加 int 值时,便需要用到自动装箱了。在上面字节码偏移量为 10 的指令中,我们调用了 Integer.valueOf 方法,将 int 类型的值转换为 Integer 类型,再存储至容器类中。

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

这是 Integer.valueOf 的源代码。可以看到,当请求的 int 值在某个范围内时,我们会返回缓存了的 Integer 对象;而当所请求的 int 值在范围之外时,我们则会新建一个 Integer 对象。

在介绍反射的那一篇中,我曾经提到参数 java.lang.Integer.IntegerCache.high。这个参数将影响这里面的 IntegerCache.high。

也就是说,我们可以通过配置该参数,扩大 Integer 缓存的范围。Java 虚拟机参数 -XX:+AggressiveOpts 也会将 IntegerCache.high 调整至 20000。

奇怪的是,Java 并不支持对 IntegerCache.low 的更改,也就是说,对于小于 -128 的整数,我们无法直接使用由 Java 核心类库所缓存的 Integer 对象。

25: invokevirtual java/lang/Integer.intValue:()I

当从泛型参数为 Integer 的 ArrayList 取出元素时,我们得到的实际上也是 Integer 对象。如果应用程序期待的是一个 int 值,那么就会发生自动拆箱。

在我们的例子中,自动拆箱对应的是字节码偏移量为 25 的指令。该指令将调用 Integer.intValue 方法。这是一个实例方法,直接返回 Integer 对象所存储的 int 值。

泛型与类型擦除

你可能已经留意到了,在前面例子生成的字节码中,往 ArrayList 中添加元素的 add 方法,所接受的参数类型是 Object;而从 ArrayList 中获取元素的 get 方法,其返回类型同样也是 Object。

前者还好,但是对于后者,在字节码中我们需要进行向下转换,将所返回的 Object 强制转换为 Integer,方能进行接下来的自动拆箱。

13: invokevirtual java/util/ArrayList.add:(Ljava/lang/Object;)Z
...
19: invokevirtual java/util/ArrayList.get:(I)Ljava/lang/Object;
22: checkcast java/lang/Integer

之所以会出现这种情况,是因为 Java 泛型的类型擦除。这是个什么概念呢?简单地说,那便是 Java 程序里的泛型信息,在 Java 虚拟机里全部都丢失了。这么做主要是为了兼容引入泛型之前的代码。

当然,并不是每一个泛型参数被擦除类型后都会变成 Object 类。对于限定了继承类的泛型参数,经过类型擦除后,所有的泛型参数都将变成所限定的继承类。也就是说,Java 编译器将选取该泛型所能指代的所有类中层次最高的那个,作为替换泛型的类。

class GenericTest<T extends Number> {
  T foo(T t) {
    return t;
  }
}

举个例子,在上面这段 Java 代码中,我定义了一个 T extends Number 的泛型参数。它所对应的字节码如下所示。可以看到,foo 方法的方法描述符所接收参数的类型以及返回类型都为 Number。方法描述符是 Java 虚拟机识别方法调用的目标方法的关键。

T foo(T);
  descriptor: (Ljava/lang/Number;)Ljava/lang/Number;
  flags: (0x0000)
  Code:
    stack=1, locals=2, args_size=2
       0: aload_1
       1: areturn
  Signature: (TT;)TT;

不过,字节码中仍存在泛型参数的信息,如方法声明里的 T foo(T),以及方法签名(Signature)中的“(TT;)TT;”。这类信息主要由 Java 编译器在编译他类时使用。

既然泛型会被类型擦除,那么我们还有必要用它吗?

我认为是有必要的。Java 编译器可以根据泛型参数判断程序中的语法是否正确。举例来说,尽管经过类型擦除后,ArrayList.add 方法所接收的参数是 Object 类型,但是往泛型参数为 Integer 类型的 ArrayList 中添加字符串对象,Java 编译器是会报错的。

ArrayList<Integer> list = new ArrayList<>();
list.add("0"); // 编译出错

桥接方法

泛型的类型擦除带来了不少问题。其中一个便是方法重写。在第四篇的课后实践中,我留了这么一段代码:

class Merchant<T extends Customer> {
  public double actionPrice(T customer) {
    return 0.0d;
  }
}
 
class VIPOnlyMerchant extends Merchant<VIP> {
  @Override
  public double actionPrice(VIP customer) {
    return 0.0d;
  }
}

VIPOnlyMerchant 中的 actionPrice 方法是符合 Java 语言的方法重写的,毕竟都使用 @Override 来注解了。然而,经过类型擦除后,父类的方法描述符为 (LCustomer;)D,而子类的方法描述符为 (LVIP;)D。这显然不符合 Java 虚拟机关于方法重写的定义。

为了保证编译而成的 Java 字节码能够保留重写的语义,Java 编译器额外添加了一个桥接方法。该桥接方法在字节码层面重写了父类的方法,并将调用子类的方法。

class VIPOnlyMerchant extends Merchant<VIP>
...
  public double actionPrice(VIP);
    descriptor: (LVIP;)D
    flags: (0x0001) ACC_PUBLIC
    Code:
         0: dconst_0
         1: dreturn
 
  public double actionPrice(Customer);
    descriptor: (LCustomer;)D
    flags: (0x1041) ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
    Code:
         0: aload_0
         1: aload_1
         2: checkcast class VIP
         5: invokevirtual actionPrice:(LVIP;)D
         8: dreturn
 
// 这个桥接方法等同于
public double actionPrice(Customer customer) {
  return actionPrice((VIP) customer);
}

在我们的例子中,VIPOnlyMerchant 类将包含一个桥接方法 actionPrice(Customer),它重写了父类的同名同方法描述符的方法。该桥接方法将传入的 Customer 参数强制转换为 VIP 类型,再调用原本的 actionPrice(VIP) 方法。

当一个声明类型为 Merchant,实际类型为 VIPOnlyMerchant 的对象,调用 actionPrice 方法时,字节码里的符号引用指向的是 Merchant.actionPrice(Customer) 方法。Java 虚拟机将动态绑定至 VIPOnlyMerchant 类的桥接方法之中,并且调用其 actionPrice(VIP) 方法。

需要注意的是,在 javap 的输出中,该桥接方法的访问标识符除了代表桥接方法的 ACC_BRIDGE 之外,还有 ACC_SYNTHETIC。它表示该方法对于 Java 源代码来说是不可见的。当你尝试通过传入一个声明类型为 Customer 的对象作为参数,调用 VIPOnlyMerchant 类的 actionPrice 方法时,Java 编译器会报错,并且提示参数类型不匹配。

    Customer customer = new VIP();
    new VIPOnlyMerchant().actionPrice(customer); // 编译出错    

当然,如果你实在想要调用这个桥接方法,那么你可以选择使用反射机制。

class Merchant {
  public Number actionPrice(Customer customer) {
    return 0;
  }
}
 
class NaiveMerchant extends Merchant {
  @Override
  public Double actionPrice(Customer customer) {
    return 0.0D;
  }
}

除了前面介绍的泛型重写会生成桥接方法之外,如果子类定义了一个与父类参数类型相同的方法,其返回类型为父类方法返回类型的子类,那么 Java 编译器也会为其生成桥接方法。

class NaiveMerchant extends Merchant
  public java.lang.Double actionPrice(Customer);
    descriptor: (LCustomer;)Ljava/lang/Double;
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: dconst_0
         1: invokestatic Double.valueOf:(D)Ljava/lang/Double;
         4: areturn
 
  public java.lang.Number actionPrice(Customer);
    descriptor: (LCustomer;)Ljava/lang/Number;
    flags: (0x1041) ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: aload_1
         2: invokevirtual actionPrice:(LCustomer;)Ljava/lang/Double;
         5: areturn         

我之前曾提到过,class 文件里允许出现两个同名、同参数类型但是不同返回类型的方法。这里的原方法和桥接方法便是其中一个例子。由于该桥接方法同样标注了 ACC_SYNTHETIC,因此,当在 Java 程序中调用 NaiveMerchant.actionPrice 时,我们只会调用到原方法。

其他语法糖

在前面的篇章中,我已经介绍过了变长参数、try-with-resources 以及在同一 catch 代码块中捕获多种异常等语法糖。下面我将列举另外两个常见的语法糖。

foreach 循环允许 Java 程序在 for 循环里遍历数组或者 Iterable 对象。对于数组来说,foreach 循环将从 0 开始逐一访问数组中的元素,直至数组的末尾。其等价的代码如下面所示:

public void foo(int[] array) {
  for (int item : array) {
  }
}
// 等同于
public void bar(int[] array) {
  int[] myArray = array;
  int length = myArray.length;
  for (int i = 0; i < length; i++) {
    int item = myArray[i];
  }
}

对于 Iterable 对象来说,foreach 循环将调用其 iterator 方法,并且用它的 hasNext 以及 next 方法来遍历该 Iterable 对象中的元素。其等价的代码如下面所示:

public void foo(ArrayList<Integer> list) {
  for (Integer item : list) {
  }
}
// 等同于
public void bar(ArrayList<Integer> list) {
  Iterator<Integer> iterator = list.iterator();
  while (iterator.hasNext()) {
    Integer item = iterator.next();
  }
}

字符串 switch 编译而成的字节码看起来非常复杂,但实际上就是一个哈希桶。由于每个 case 所截获的字符串都是常量值,因此,Java 编译器会将原来的字符串 switch 转换为 int 值 switch,比较所输入的字符串的哈希值。

由于字符串哈希值很容易发生碰撞,因此,我们还需要用 String.equals 逐个比较相同哈希值的字符串。

四、即时编译

分层编译模式

HotSpot 虚拟机包含多个即时编译器 C1、C2 和 Graal。

其中,Graal 是一个实验性质的即时编译器,可以通过参数 -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler 启用,并且替换 C2。

在 Java 7 以前,我们需要根据程序的特性选择对应的即时编译器。对于执行时间较短的,或者对启动性能有要求的程序,我们采用编译效率较快的 C1,对应参数 -client。

对于执行时间较长的,或者对峰值性能有要求的程序,我们采用生成代码执行效率较快的 C2,对应参数 -server。

Java 7 引入了分层编译(对应参数 -XX:+TieredCompilation)的概念,综合了 C1 的启动性能优势和 C2 的峰值性能优势。

分层编译将 Java 虚拟机的执行状态分为了五个层次。为了方便阐述,我用“C1 代码”来指代由 C1 生成的机器码,“C2 代码”来指代由 C2 生成的机器码。五个层级分别是:

0.解释执行;
1.执行不带 profiling 的 C1 代码;
2.执行仅带方法调用次数以及循环回边执行次数 profiling 的 C1 代码;
3.执行带所有 profiling 的 C1 代码;
4.执行 C2 代码。

通常情况下,C2 代码的执行效率要比 C1 代码的高出 30% 以上。然而,对于 C1 代码的三种状态,按执行效率从高至低则是 1 层 > 2 层 > 3 层。

其中 1 层的性能比 2 层的稍微高一些,而 2 层的性能又比 3 层高出 30%。这是因为 profiling 越多,其额外的性能开销越大。

这里解释一下,profiling 是指在程序执行过程中,收集能够反映程序执行状态的数据。这里所收集的数据我们称之为程序的 profile。

你可能已经接触过许许多多的 profiler,例如 JDK 附带的 hprof。这些 profiler 大多通过注入(instrumentation)或者 JVMTI 事件来实现的。Java 虚拟机也内置了 profiling。我会在下一篇中具体介绍 Java 虚拟机的 profiling 都在做些什么。

在 5 个层次的执行状态中,1 层和 4 层为终止状态。当一个方法被终止状态编译过后,如果编译后的代码并没有失效,那么 Java 虚拟机是不会再次发出该方法的编译请求的。 这里列举了 4 个不同的编译路径(Igor 的演讲列举了更多的编译路径)。通常情况下,热点方法会被 3 层的 C1 编译,然后再被 4 层的 C2 编译。

如果方法的字节码数目比较少(如 getter/setter),而且 3 层的 profiling 没有可收集的数据。

那么,Java 虚拟机断定该方法对于 C1 代码和 C2 代码的执行效率相同。在这种情况下,Java 虚拟机会在 3 层编译之后,直接选择用 1 层的 C1 编译。由于这是一个终止状态,因此 Java 虚拟机不会继续用 4 层的 C2 编译。

在 C1 忙碌的情况下,Java 虚拟机在解释执行过程中对程序进行 profiling,而后直接由 4 层的 C2 编译。在 C2 忙碌的情况下,方法会被 2 层的 C1 编译,然后再被 3 层的 C1 编译,以减少方法在 3 层的执行时间。

Java 8 默认开启了分层编译。不管是开启还是关闭分层编译,原本用来选择即时编译器的参数 -client 和 -server 都是无效的。当关闭分层编译的情况下,Java 虚拟机将直接采用 C2。

如果你希望只是用 C1,那么你可以在打开分层编译的情况下使用参数 -XX:TieredStopAtLevel=1。在这种情况下,Java 虚拟机会在解释执行之后直接由 1 层的 C1 进行编译。

即时编译的触发

Java 虚拟机是根据方法的调用次数以及循环回边的执行次数来触发即时编译的。前面提到,Java 虚拟机在 0 层、2 层和 3 层执行状态时进行 profiling,其中就包含方法的调用次数和循环回边的执行次数。

这里的循环回边是一个控制流图中的概念。在字节码中,我们可以简单理解为往回跳转的指令。(注意,这并不一定符合循环回边的定义。)

public static void foo(Object obj) {
  int sum = 0;
  for (int i = 0; i < 200; i++) {
    sum += i;
  }
}

举例来说,上面这段代码将被编译为下面的字节码。其中,偏移量为 18 的字节码将往回跳至偏移量为 7 的字节码中。在解释执行时,每当运行一次该指令,Java 虚拟机便会将该方法的循环回边计数器加 1。

public static void foo(java.lang.Object);
  Code:
     0: iconst_0
     1: istore_1
     2: iconst_0
     3: istore_2
     4: goto 14
     7: iload_1
     8: iload_2
     9: iadd
    10: istore_1
    11: iinc 2, 1
    14: iload_2
    15: sipush 200
    18: if_icmplt 7
    21: return

在即时编译过程中,我们会识别循环的头部和尾部。在上面这段字节码中,循环的头部是偏移量为 14 的字节码,尾部为偏移量为 11 的字节码。

循环尾部到循环头部的控制流边就是真正意义上的循环回边。也就是说,C1 将在这个位置插入增加循环回边计数器的代码。

解释执行和 C1 代码中增加循环回边计数器的位置并不相同,但这并不会对程序造成影响。

实际上,Java 虚拟机并不会对这些计数器进行同步操作,因此收集而来的执行次数也并非精确值。不管如何,即时编译的触发并不需要非常精确的数值。只要该数值足够大,就能说明对应的方法包含热点代码。

具体来说,在不启用分层编译的情况下,当方法的调用次数和循环回边的次数的和,超过由参数 -XX:CompileThreshold 指定的阈值时(使用 C1 时,该值为 1500;使用 C2 时,该值为 10000),便会触发即时编译。

当启用分层编译时,Java 虚拟机将不再采用由参数 -XX:CompileThreshold 指定的阈值(该参数失效),而是使用另一套阈值系统。在这套系统中,阈值的大小是动态调整的。

所谓的动态调整其实并不复杂:在比较阈值时,Java 虚拟机会将阈值与某个系数 s 相乘。该系数与当前待编译的方法数目成正相关,与编译线程的数目成负相关。

系数的计算方法为:
s = queue_size_X / (TierXLoadFeedback * compiler_count_X) + 1
 
其中 X 是执行层次,可取 3 或者 4;
queue_size_X 是执行层次为 X 的待编译方法的数目;
TierXLoadFeedback 是预设好的参数,其中 Tier3LoadFeedback 为 5,Tier4LoadFeedback 为 3;
compiler_count_X 是层次 X 的编译线程数目。

在 64 位 Java 虚拟机中,默认情况下编译线程的总数目是根据处理器数量来调整的(对应参数 -XX:+CICompilerCountPerCPU,默认为 true;当通过参数 -XX:+CICompilerCount=N 强制设定总编译线程数目时,CICompilerCountPerCPU 将被设置为 false)。

Java 虚拟机会将这些编译线程按照 1:2 的比例分配给 C1 和 C2(至少各为 1 个)。举个例子,对于一个四核机器来说,总的编译线程数目为 3,其中包含一个 C1 编译线程和两个 C2 编译线程。

对于四核及以上的机器,总的编译线程的数目为:
n = log2(N) * log2(log2(N)) * 3 / 2
其中 N 为 CPU 核心数目。

当启用分层编译时,即时编译具体的触发条件如下。

当方法调用次数大于由参数 -XX:TierXInvocationThreshold 指定的阈值乘以系数,或者当方法调用次数大于由参数 -XX:TierXMINInvocationThreshold 指定的阈值乘以系数,并且方法调用次数和循环回边次数之和大于由参数 -XX:TierXCompileThreshold 指定的阈值乘以系数时,便会触发 X 层即时编译。
 
触发条件为:
i > TierXInvocationThreshold * s || (i > TierXMinInvocationThreshold * s  && i + b > TierXCompileThreshold * s)

其中 i 为调用次数,b 为循环回边次数。

OSR 编译

可以看到,决定一个方法是否为热点代码的因素有两个:方法的调用次数、循环回边的执行次数。即时编译便是根据这两个计数器的和来触发的。为什么 Java 虚拟机需要维护两个不同的计数器呢?

实际上,除了以方法为单位的即时编译之外,Java 虚拟机还存在着另一种以循环为单位的即时编译,叫做 On-Stack-Replacement(OSR)编译。循环回边计数器便是用来触发这种类型的编译的。

OSR 实际上是一种技术,它指的是在程序执行过程中,动态地替换掉 Java 方法栈桢,从而使得程序能够在非方法入口处进行解释执行和编译后的代码之间的切换。事实上,去优化(deoptimization)采用的技术也可以称之为 OSR。

在不启用分层编译的情况下,触发 OSR 编译的阈值是由参数 -XX:CompileThreshold 指定的阈值的倍数。

该倍数的计算方法为:

(OnStackReplacePercentage - InterpreterProfilePercentage)/100
 
其中 -XX:InterpreterProfilePercentage 的默认值为 33,当使用 C1 时 -XX:OnStackReplacePercentage933,当使用 C2 时为 140

也就是说,默认情况下,C1 的 OSR 编译的阈值为 13500,而 C2 的为 10700。

在启用分层编译的情况下,触发 OSR 编译的阈值则是由参数 -XX:TierXBackEdgeThreshold 指定的阈值乘以系数。

OSR 编译在正常的应用程序中并不多见。它只在基准测试时比较常见,因此并不需要过多了解。

Profiling

分层编译中的 0 层、2 层和 3 层都会进行 profiling,收集能够反映程序执行状态的数据。其中,最为基础的便是方法的调用次数以及循环回边的执行次数。它们被用于触发即时编译。

此外,0 层和 3 层还会收集用于 4 层 C2 编译的数据,比如说分支跳转字节码的分支 profile(branch profile),包括跳转次数和不跳转次数,以及非私有实例方法调用指令、强制类型转换 checkcast 指令、类型测试 instanceof 指令,和引用类型的数组存储 aastore 指令的类型 profile(receiver type profile)。

分支 profile 和类型 profile 的收集将给应用程序带来不少的性能开销。据统计,正是因为这部分额外的 profiling,使得 3 层 C1 代码的性能比 2 层 C1 代码的低 30%。

在通常情况下,我们不会在解释执行过程中收集分支 profile 以及类型 profile。只有在方法触发 C1 编译后,Java 虚拟机认为该方法有可能被 C2 编译,方才在该方法的 C1 代码中收集这些 profile。

只要在比较极端的情况下,例如等待 C1 编译的方法数目太多时,Java 虚拟机才会开始在解释执行过程中收集这些 profile。

那么这些耗费巨大代价收集而来的 profile 具体有什么作用呢?

答案是,C2 可以根据收集得到的数据进行猜测,假设接下来的执行同样会按照所收集的 profile 进行,从而作出比较激进的优化。

基于分支 profile 的优化

举个例子,下面这段代码中包含两个条件判断。第一个条件判断将测试所输入的 boolean 值。

如果为 true,则将局部变量 v 设置为所输入的 int 值。如果为 false,则将所输入的 int 值经过一番运算之后,再存入局部变量 v 之中。

第二个条件判断则测试局部变量 v 是否和所输入的 int 值相等。如果相等,则返回 0。如果不等,则将局部变量 v 经过一番运算之后,再将之返回。显然,当所输入的 boolean 值为 true 的情况下,这段代码将返回 0。

public static int foo(boolean f, int in) {
  int v;
  if (f) {
    v = in;
  } else {
    v = (int) Math.sin(in);
  }
 
  if (v == in) {
    return 0;
  } else {
    return (int) Math.cos(v);
  }
}
// 编译而成的字节码:
public static int foo(boolean, int);
  Code:
     0: iload_0
     1: ifeq          9
     4: iload_1
     5: istore_2
     6: goto          16
     9: iload_1
    10: i2d
    11: invokestatic  java/lang/Math.sin:(D)D
    14: d2i
    15: istore_2
    16: iload_2
    17: iload_1
    18: if_icmpne     23
    21: iconst_0
    22: ireturn
    23: iload_2
    24: i2d
    25: invokestatic java/lang/Math.cos:(D)D
    28: d2i
    29: ireturn

假设应用程序调用该方法时,所传入的 boolean 值皆为 true。那么,偏移量为 1 以及偏移量为 18 的条件跳转指令所对应的分支 profile 中,跳转的次数都为 0。

C2 可以根据这两个分支 profile 作出假设,在接下来的执行过程中,这两个条件跳转指令仍旧不会发生跳转。基于这个假设,C2 便不再编译这两个条件跳转语句所对应的 false 分支了。

我们暂且不管当假设错误的时候会发生什么,先来看一看剩下来的代码。经过“剪枝”之后,在第二个条件跳转处,v 的值只有可能为所输入的 int 值。因此,该条件跳转可以进一步被优化掉。最终的结果是,在第一个条件跳转之后,C2 代码将直接返回 0。 这里我打印了 C2 的编译结果。可以看到,在地址为 2cee 的指令处进行过一次比较之后,该机器码便直接返回 0。

Compiled method (c2)      95   16       4       CompilationTest::foo (30 bytes)
...
CompilationTest.foo  [0x0000000104fb2ce0, 0x0000000104fb2d38]  88 bytes
[Entry Point]
[Verified Entry Point]
[Constants]
  # {method} {0x000000012629e380} 'foo' '(ZI)I' in 'CompilationTest'
  # parm0:    rsi       = boolean
  # parm1:    rdx       = int
  #           [sp+0x30]  (sp of caller)
  0x0000000104fb2ce0: mov    DWORD PTR [rsp-0x14000],eax
  0x0000000104fb2ce7: push   rbp
  0x0000000104fb2ce8: sub    rsp,0x20
  0x0000000104fb2cec: test   esi,esi
  0x0000000104fb2cee: je     0x0000000104fb2cfe // 跳转至?
  0x0000000104fb2cf0: xor    eax,eax            // 将返回值设置为 0
  0x0000000104fb2cf2: add    rsp,0x20
  0x0000000104fb2cf6: pop    rbp
  0x0000000104fb2cf7: test   DWORD PTR [rip+0xfffffffffca32303],eax // safepoint
  0x0000000104fb2cfd: ret    
  ...

总结一下,根据条件跳转指令的分支 profile,即时编译器可以将从未执行过的分支剪掉,以避免编译这些很有可能不会用到的代码,从而节省编译时间以及部署代码所要消耗的内存空间。此外,“剪枝”将精简程序的数据流,从而触发更多的优化。

在现实中,分支 profile 出现仅跳转或者仅不跳转的情况并不多见。当然,即时编译器对分支 profile 的利用也不仅限于“剪枝”。它还会根据分支 profile,计算每一条程序执行路径的概率,以便某些编译器优化优先处理概率较高的路径。

基于类型 profile 的优化

另外一个例子则是关于 instanceof 以及方法调用的类型 profile。下面这段代码将测试所传入的对象是否为 Exception 的实例,如果是,则返回它的系统哈希值;如果不是,则返回它的哈希值。

public static int hash(Object in) {
  if (in instanceof Exception) {
    return System.identityHashCode(in);
  } else {
    return in.hashCode();
  }
}
// 编译而成的字节码:
public static int hash(java.lang.Object);
  Code:
     0: aload_0
     1: instanceof java/lang/Exception
     4: ifeq          12
     7: aload_0
     8: invokestatic java/lang/System.identityHashCode:(Ljava/lang/Object;)I
    11: ireturn
    12: aload_0
    13: invokevirtual java/lang/Object.hashCode:()I
    16: ireturn

假设应用程序调用该方法时,所传入的 Object 皆为 Integer 实例。那么,偏移量为 1 的 instanceof 指令的类型 profile 仅包含 Integer,偏移量为 4 的分支跳转语句的分支 profile 中不跳转的次数为 0,偏移量为 13 的方法调用指令的类型 profile 仅包含 Integer。 在 Java 虚拟机中,instanceof 测试并不简单。如果 instanceof 的目标类型是 final 类型,那么 Java 虚拟机仅需比较测试对象的动态类型是否为该 final 类型。

在讲解对象的内存分布那一篇中,我曾经提到过,对象头存有该对象的动态类型。因此,获取对象的动态类型仅为单一的内存读指令。

如果目标类型不是 final 类型,比如说我们例子中的 Exception,那么 Java 虚拟机需要从测试对象的动态类型开始,依次测试该类,该类的父类、祖先类,该类所直接实现或者间接实现的接口是否与目标类型一致。

不过,在我们的例子中,instanceof 指令的类型 profile 仅包含 Integer。根据这个信息,即时编译器可以假设,在接下来的执行过程中,所输入的 Object 对象仍为 Integer 实例。

因此,生成的代码将测试所输入的对象的动态类型是否为 Integer。如果是的话,则继续执行接下来的代码。(该优化源自 Graal,采用 C2 可能无法复现。)

然后,即时编译器会采用和第一个例子中一致的针对分支 profile 的优化,以及对方法调用的条件去虚化内联。

我会在接下来的篇章中详细介绍内联,这里先说结果:生成的代码将测试所输入的对象动态类型是否为 Integer。如果是的话,则执行 Integer.hashCode() 方法的实质内容,也就是返回该 Integer 实例的 value 字段。

public final class Integer ... {
    ...
    @Override
    public int hashCode() {
        return Integer.hashCode(value);
    }
 
    public static int hashCode(int value) {
        return value;
    }
    ...
}

和第一个例子一样,根据数据流分析,上述代码可以最终优化为极其简单的形式。 这里我打印了 Graal 的编译结果。可以看到,在地址为 1ab7 的指令处进行过一次比较之后,该机器码便直接返回所传入的 Integer 对象的 value 字段。

Compiled method (JVMCI)     600   23       4
...
----------------------------------------------------------------------
CompilationTest.hash (CompilationTest.hash(Object))  [0x000000011d811aa0, 0x000000011d811b00]  96 bytes
[Entry Point]
[Verified Entry Point]
[Constants]
  # {method} {0x00000001157053c8} 'hash' '(Ljava/lang/Object;)I' in 'CompilationTest'
  # parm0:    rsi:rsi   = 'java/lang/Object'
  #           [sp+0x20]  (sp of caller)
  0x000000011d811aa0: mov    DWORD PTR [rsp-0x14000],eax
  0x000000011d811aa7: sub    rsp,0x18
  0x000000011d811aab: mov    QWORD PTR [rsp+0x10],rbp
  // 比较 [rsi+0x8],也就是所传入的 Object 参数的动态类型,是否为 Integer。这里 0xf80022ad 是 Integer 类的内存地址。
  0x000000011d811ab0: cmp    DWORD PTR [rsi+0x8],0xf80022ad
  // 如果不是,跳转至?
  0x000000011d811ab7: jne    0x000000011d811ad3
  // 加载 Integer.value。在启用压缩指针时,该字段的偏移量为 12,也就是 0xc
  0x000000011d811abd: mov    eax,DWORD PTR [rsi+0xc]
  0x000000011d811ac0: mov    rbp,QWORD PTR [rsp+0x10]
  0x000000011d811ac5: add    rsp,0x18
  0x000000011d811ac9: test   DWORD PTR [rip+0xfffffffff272f537],eax        
  0x000000011d811acf: vzeroupper 
  0x000000011d811ad2: ret

和基于分支 profile 的优化一样,基于类型 profile 的优化同样也是作出假设,从而精简控制流以及数据流。这两者的核心都是假设。

对于分支 profile,即时编译器假设的是仅执行某一分支;对于类型 profile,即时编译器假设的是对象的动态类型仅为类型 profile 中的那几个。

那么,当假设失败的情况下,程序将何去何从?我们继续往下看。

去优化

Java 虚拟机给出的解决方案便是去优化,即从执行即时编译生成的机器码切换回解释执行。

在生成的机器码中,即时编译器将在假设失败的位置上插入一个陷阱(trap)。该陷阱实际上是一条 call 指令,调用至 Java 虚拟机里专门负责去优化的方法。与普通的 call 指令不一样的是,去优化方法将更改栈上的返回地址,并不再返回即时编译器生成的机器码中。

在上面的程序控制流图中,我画了很多红色方框的问号。这些问号便代表着一个个的陷阱。一旦踏入这些陷阱,便将发生去优化,并切换至解释执行。

去优化的过程相当复杂。由于即时编译器采用了许多优化方式,其生成的代码和原本的字节码的差异非常之大。

在去优化的过程中,需要将当前机器码的执行状态转换至某一字节码之前的执行状态,并从该字节码开始执行。这便要求即时编译器在编译过程中记录好这两种执行状态的映射。

举例来说,经过逃逸分析之后,机器码可能并没有实际分配对象,而是在各个寄存器中存储该对象的各个字段(标量替换,具体我会在之后的篇章中进行介绍)。在去优化过程中,Java 虚拟机需要还原出这个对象,以便解释执行时能够使用该对象。

当根据映射关系创建好对应的解释执行栈桢后,Java 虚拟机便会采用 OSR 技术,动态替换栈上的内容,并在目标字节码处开始解释执行。

此外,在调用 Java 虚拟机的去优化方法时,即时编译器生成的机器码可以根据产生去优化的原因来决定是否保留这一份机器码,以及何时重新编译对应的 Java 方法。

如果去优化的原因与优化无关,即使重新编译也不会改变生成的机器码,那么生成的机器码可以在调用去优化方法时传入 Action_None,表示保留这一份机器码,在下一次调用该方法时重新进入这一份机器码。

如果去优化的原因与静态分析的结果有关,例如类层次分析,那么生成的机器码可以在调用去优化方法时传入 Action_Recompile,表示不保留这一份机器码,但是可以不经过重新 profile,直接重新编译。

如果去优化的原因与基于 profile 的激进优化有关,那么生成的机器码需要在调用去优化方法时传入 Action_Reinterpret,表示不保留这一份机器码,而且需要重新收集程序的 profile。

这是因为基于 profile 的优化失败的时候,往往代表这程序的执行状态发生改变,因此需要更正已收集的 profile,以更好地反映新的程序执行状态。