- 即时编译是用来 提升应用运行效率 的技术
- 代码会先在JVM上 解释执行 ,之后反复执行的 热点代码 会被 即时翻译成为机器码 ,直接运行在 底层硬件 上
分层编译
- HotSpot包含多个即时编译器:C1、C2和Graal(Java 10,实验性)
- 在Java 7之前,需要根据程序的特性选择对应的 即时编译器
- 对于 执行时间较短 或 对启动性能有要求 的程序,采用 编译效率较快的C1 ,对应参数:
-client
- 对于 执行时间较长 或 对峰值性能有要求 的程序,采用 生成代码执行效率较快的C2 ,对应参数:
-server
- Java 7引入了 分层编译 (-XX:+TieredCompilation),综合了 C1的启动性能优势 和 C2的峰值性能优势
- 分层编译将 JVM的执行状态 分了5个层次
- 0:解释执行(也会profiling)
- 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:收集的数据
- JDK附带的 hprof (CPU+Heap)
- JVM 内置profiling
- Java 8默认开启了分层编译,无论开启还是关闭分层编译,原本的
-client
和 -client
都是无效的
- 如果 关闭分层编译 ,JVM将直接采用 C2
- 如果只想用C1,在打开分层编译的同时,使用参数:-XX:TieredStopAtLevel=1
编译路径
- 1层和4层是 终止状态
- 当一个 方法 被 终止状态 编译后,如果 编译后的代码没有失效 ,那么JVM 不会再次发出该方法的编译请求
- 通常情况下,热点方法会被3层的C1编译,然后再被4层的C2编译
- 如果方法的 字节码数目较少 (如getter/setter),并且 3层的profiling没有可收集的数据
- JVM会断定 该方法对于C1和C2的执行效率相同
- JVM会在3层的C1编译后, 直接选用1层的C1编译
- 由于1层是 终止状态 ,JVM不会继续用4层的C2编译
- 在C1忙碌的情况下,JVM在 解释执行过程 中对程序进行 profiling ,而后直接由4层的C2编译
- 在C2忙碌的情况下,方法会被2层的C1编译,然后再被3层的C1编译,以减少方法在3层的执行时间
触发JIT的条件
- JVM是依据 方法的调用次数 以及 循环回边的执行次数 来触发JIT的
- JVM将在0层、2层和3层执行状态时进行profiling,其中包括方法的调用次数和循环回边的执行次数
- 循环回边是一个控制流程图中的概念,在字节码中,可以简单理解为 往回跳 的指令
- 在即时编译过程中,JVM会识别循环的头部和尾部, 循环尾部到循环头部的控制流就是真正意义上的循环回边
- C1将在 循环回边 插入 循环回边计数器 的代码
- 解释执行和C1代码中增加循环回边计数的 位置 并不相同,但这不会对程序造成影响
- JVM不会对这些 计数器 进行 同步 操作,因此收集到的执行次数也 不是精确值
- 只要该数值 足够大 ,就能表示对应的方法包含热点代码
- 在 不启动 分层编译时,当 方法的调用次数和循环回边的次数的和 超过-XX:CompileThreshold,便会触发JIT
- 使用 C1 时,该值为 1500
- 使用 C2 时,该值为 10000
- 当 启用 分层编译时,阈值大小是 动态调整 的
系数
系数的计算方法:
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位JVM中,默认情况下,编译线程的总数目是根据 处理器数量 来调整的
- -XX:+CICompilerCountPerCPU=true, 编译线程数依赖于处理器数量
- -XX:+CICompilerCountPerCPU=false -XX:+CICompilerCount=N, 强制设定总编译线程数
- JVM会将这些编译线程按照1:2的比例分配给C1和C2(至少1个),对于4核CPU,总编译线程数为3
// -XX:+CICompilerCountPerCPU=true
n = log2(N) * log2(log2(N)) * 3 / 2
其中 N 为 CPU 核心数目,N >= 4
触发条件
当启用分层编译时,触发JIT的条件
i > TierXInvocationThreshold * s || (i > TierXMinInvocationThreshold * s && i + b > TierXCompileThreshold * s)
其中i为方法调用次数,b为循环回边执行次数
Profiling
- 在分层编译中的0层、2层和3层,都会进行profiling,最为基础的是 方法的调用次数 以及 循环回边的执行次数
- 此外,0层和3层还会收集用于4层C2编译的数据,例如
- branch profiling
- type profiling
- 非私有实例方法调用指令: invokevirtual
- 强制类型转换指令: checkcast
- 类型测试指令: instanceof
- 引用类型数组存储指令: aastore
- branch profiling和type profiling将给应用带来不少的 性能开销
- 3层C1的性能比2层C1的性能低30%
- 通常情况下,我们不会在 解析执行 过程中进行branch profiling和type profiling
- 只有在方法 触发C1编译后 ,JVM认为该方法 有可能被C2编译 ,才会在该方法的C1代码中收集这些profile
- 只有在 极端 情况下(如等待C1编译的方法数目太多),才会开始在 解释 执行过程中收集这些profile
- C2可以根据收集得到的数据进行 猜测和假设 ,从而作出比较 激进的优化
branch profiling
Java代码
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);
descriptor: (ZI)I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=2
0: iload_0
1: ifeq 9 // false,跳转到偏移量为9的字节码
4: iload_1
5: istore_2
6: goto 16
9: iload_1
10: i2d
11: invokestatic // Method java/lang/Math.sin:(D)D
14: d2i
15: istore_2
16: iload_2
17: iload_1
18: if_icmpne 23 // 如果v!=in,跳转到偏移量为23的字节码
21: iconst_0
22: ireturn
23: iload_2
24: i2d
25: invokestatic // Method java/lang/Math.cos:(D)D
28: d2i
29: ireturn
优化过程
正常分支
profiling
假设应用程序调用该方法,所传入的都是true,那么偏移量为1和偏移量为18的条件跳转指令所对应的分支profile中,其跳转的次数都是0。实际执行的分支如下:
剪枝
C2根据这两个分支profile作出假设,在后续的执行过程中,这两个条件跳转指令仍旧不会执行,基于这个假设,C2不会在编译这两个条件跳转语句所对应的false分支(剪枝)。最终的结果是在第一个条件跳转之后,C2代码直接返回0
小结
- 根据条件跳转指令的分支profile,即时编译器可以将 从未执行过 的分支减掉
- 避免编译这些不会用到的代码
- 节省 编译时间 以及部署代码所要消耗的 内存空间
- 剪枝同时也能精简数据流,从而触发更多的优化
- 现实中,分支profile出现仅跳转或者不跳转的情况并不常见
- 即时编译器对分支profile的利用也不仅仅限于剪枝
- 还可以依据分支profile, 计算每一条执行路径的概率
- 以便于某些编译器优化优先处理概率较高的路径
type profiling
Java代码
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);
descriptor: (Ljava/lang/Object;)I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: instanceof // class java/lang/Exception
4: ifeq 12 // 不是Exception,跳转到偏移量为12的字节码
7: aload_0
8: invokestatic // Method java/lang/System.identityHashCode:(Ljava/lang/Object;)I
11: ireturn
12: aload_0
13: invokevirtual // Method java/lang/Object.hashCode:()I
16: ireturn
优化过程
正常分支
profiling+优化
- 假设应用调用该方法时,所传入的Object皆为Integer实例
- 偏移量为1的 instanceof 指令的 类型profile 仅包含Integer
- 偏移量为4的分支跳转语句的 分支profile 不跳转次数为0
- 偏移量为13的方法调用指令的 类型profile 仅包含Integer
- 测试instanceof
- 如果instanceof的 目标类型是final类型 ,那么JVM仅需比较测试 对象的动态类型 是否为该final类型
- 如果 目标类型不是final类型 ,JVM需要依次按下列顺序测试是否与目标类型一致
- 该类本身
- 该类的父类、祖先类
- 该类所直接实现或间接实现的接口
- instanceof指令的类型profile仅包含Integer
- JVM会假设在接下来的执行过程中,所输入的Object对象仍为Integer对象
- 生成的代码将 直接测试所输入的动态类型是否为Integer ,如果是继续执行接下来的代码
- 然后,即时编译器会采用 针对分支profile的优化 以及 对方法调用的条件去虚化内联
- 内联结果:生成的代码将测试所输入对象的动态类型是否为Integer,如果是,执行
Integer.hashCode()
方法的代码
public final class Integer ... {
@Override
public int hashCode() {
return Integer.hashCode(value);
}
public static int hashCode(int value) {
return value;
}
}
针对上面三个profile的分支图
进一步优化(剪枝)
小结
- 和基于分支profile的优化一样,基于类型profile的优化同样也是作出假设,从而精简控制流以及数据流,两者的 核心是假设
- 对于 分支profile ,即时编译器假设 仅执行某一分支
- 对于 类型profile ,即时编译器假设的是 对象的动态类型仅为类型profile中的那几个
- 如果 假设失败 ,将进入 去优化
原文
http://zhongmingmao.me/2019/01/02/jvm-advanced-jit/