gitbook/深入拆解Java虚拟机/docs/14070.md
2022-09-03 22:05:03 +08:00

304 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 17 | 即时编译(下)
今天我们来继续讲解Java虚拟机中的即时编译。
## Profiling
上篇提到分层编译中的0层、2层和3层都会进行profiling收集能够反映程序执行状态的数据。其中最为基础的便是方法的调用次数以及循环回边的执行次数。它们被用于触发即时编译。
此外0层和3层还会收集用于4层C2编译的数据比如说分支跳转字节码的分支profilebranch profile包括跳转次数和不跳转次数以及非私有实例方法调用指令、强制类型转换checkcast指令、类型测试instanceof指令和引用类型的数组存储aastore指令的类型profilereceiver 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
```
![](https://static001.geekbang.org/resource/image/53/0e/53d57c8c7645d8e2292a08ee97557b0e.png)
假设应用程序调用该方法时所传入的boolean值皆为true。那么偏移量为1以及偏移量为18的条件跳转指令所对应的分支profile中跳转的次数都为0。
![](https://static001.geekbang.org/resource/image/90/cc/90eb47e4c9b202c45804ef7383a9d6cc.png)
C2可以根据这两个分支profile作出假设在接下来的执行过程中这两个条件跳转指令仍旧不会发生跳转。基于这个假设C2便不再编译这两个条件跳转语句所对应的false分支了。
我们暂且不管当假设错误的时候会发生什么先来看一看剩下来的代码。经过“剪枝”之后在第二个条件跳转处v的值只有可能为所输入的int值。因此该条件跳转可以进一步被优化掉。最终的结果是在第一个条件跳转之后C2代码将直接返回0。
![](https://static001.geekbang.org/resource/image/d9/9a/d997a7ea02b7f85136974a54dce7589a.png)
这里我打印了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。
![](https://static001.geekbang.org/resource/image/2c/77/2c13a1af8632a2bbf77338e57c957b77.png)
在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;
}
...
}
```
![](https://static001.geekbang.org/resource/image/ef/b6/ef02474d3474e96c6f55b07493652fb6.png)
和第一个例子一样,根据数据流分析,上述代码可以最终优化为极其简单的形式。
![](https://static001.geekbang.org/resource/image/53/be/53e470037dd49d3d27695a5174fc3dbe.png)
这里我打印了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以更好地反映新的程序执行状态。
## 总结与实践
今天我介绍了Java虚拟机的profiling以及基于所收集的数据的优化和去优化。
通常情况下,解释执行过程中仅收集方法的调用次数以及循环回边的执行次数。
当方法被3层C1所编译时生成的C1代码将收集条件跳转指令的分支profile以及类型相关指令的类型profile。在部分极端情况下Java虚拟机也会在解释执行过程中收集这些profile。
基于分支profile的优化以及基于类型profile的优化都将对程序今后的执行作出假设。这些假设将精简所要编译的代码的控制流以及数据流。在假设失败的情况下Java虚拟机将采取去优化退回至解释执行并重新收集相关的profile。
今天的实践环节,你可以使用参数
```
-XX:CompileCommand='print,*ClassName.methodName'
```
来打印程序运行过程中即时编译器生成的机器码。官方的JDK可能不包含反汇编器动态链接库如hsdis-amd64.dylib。你可能需要另外下载。
```
// java -XX:CompileCommand='print,CompilationTest.foo' CompilationTestjava -XX:CompileCommand='print,CompilationTest.foo' CompilationTest
public class CompilationTest {
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 void main(String[] args) throws InterruptedException {
for (int i = 0; i < 500000; i++) {
foo(true, 2);
}
Thread.sleep(2000);
}
}
// java -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler -XX:CompileCommand='print,CompilationTest2.hash' CompilationTest2
public class CompilationTest2 {
public static int hash(Object input) {
if (input instanceof Exception) {
return System.identityHashCode(input);
} else {
return input.hashCode();
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 500000; i++) {
hash(i);
}
Thread.sleep(2000);
}
}
```