JVM原理解读——即时编译
1、解释执行
编译器(javac)将源文件(.java)编译成java字节码文件(.class)的步骤是前端编译。在前端编译将字节码放入JVM后,每次执行方法调用时,JVM都会将字节码翻译成机器码并执行的过程叫解释执行
解释执行没有在启动时将字节码全部翻译成机器码,所以启动效率较高
但是由于执行时要进行翻译,所以执行效率相对较低
2、编译执行
与解释执行相反,JVM直接将第一次编译后的字节码转换为机器码,在执行方法调用时直接执行机器码,这样的过程叫编译执行
编译执行在启动时将字节码全部翻译成机器码,所以启动效率较低
但是执行时省去了翻译的步骤,所以执行效率相对较高
3、即时编译
为了平衡启动和执行的效率,JVM结合解释执行和编译执行的特点,进行解释执行并对热点代码进行编译优化,这样的执行过程叫即时编译
3.1、即时编译器
JVM包含多个即时编译器,主要有C1和C2,还有个Graal是实验性的。他们都会对字节码进行优化并生成机器码
C1会对字节码进行简单可靠的优化,包括方法内联、去虚拟化、冗余消除等,编译速度较快,可以通过-client强制指定C1编译
C2会对字节码进行激进优化,包括分支频率预测、同步擦除等,可以通过-server强制指定C2编译
3.2、分层编译模式
JVM不会直接启用C2,而是先通过C1编译收集程序的运行状态,再根据分析结果判断是否启用C2。分层编译模式下的虚拟机执行状态由简到繁、由快到慢分为5层
- 解释执行
- 执行不带profile的C1编译的代码
- 执行仅带有方法调用次数和循环执行次数的profile的C1编译的代码
- 执行带所有类型profile的C1编译的代码
- 执行C2编译的代码
3.3、profiling
profiling是C1在编译过程中收集程序执行状态的过程
收集的执行状态记录为profile,包括分支跳转频率、是否出现过空值和异常等,主要用于触发C2编译
3.4、C2触发时机
当方法调用次数profile或循环次数profile达到阈值时,会触发即时编译
阈值不仅需要通过-XX:TierXInvocationThreshold、-XX:TierXMINInvocationThreshold和-XX:TierXCompileThreshold设置,还跟待编译方法的数目和编译线程的总数有关。
编译线程的数量是处理器动态指定的,参数为-XX:+CICompilerCountPerCPU默认开启,可以通过-XX:+CICompilerCount=N强制指定编译线程总数。JVM会将这些线程以1:2的比例分配给C1和C2
3.5、去优化
去优化是当C2编译的机器码假设失败时,将即时编译切换回解释执行的过程
在C2编译生成机器码时,会在假设失败的一端设置一条指令。当假设失败时,调用指令让JVM将栈帧的方法返回地址从机器码所在的本地内存地址改回运行时常量池中的方法地址,并进行解释执行
4、方法内联
在即时编译方法时,将目标方法的方法体取代方法调用的过程叫方法内联,增加了编译的代码量,但是降低了方法调用带来的入栈出栈的成本
4.1、静态方法内联
即时编译器会根据方法调用层数,目标方法的调用次数及字节码大小等决定该方法是否允许被内联
- -XX:CompileCommand配置中的inline指令指定的方法会被强制内联,dontinline和exclude指定的方法始终不会被内联
- @ForceInline注解的jdk内部方法会被强制内联,@DontInline注解jdk内部方法始终不会被内联
- 方法的符号引用未被解析、目标方法所在类未被初始化、目标方法是native方法,都会导致方法无法内联
- C2默认不支持9层以上的方法调用(-XX:MaxInlineLevel),以及1层的直接递归调用(-XX:MaxRecursiveInlineLevel)
- 自动拆箱总会被内联,Throwable类的方法不能被其他类内联等
4.2、动态方法内联
即时编译器需要将动态绑定的虚方法转化为直接调用,才能进行方法内联,这样的过程叫虚方法的去虚化
- 根据字节码生成的IR图确定调用者类型的过程叫基于类型推导的完全去虚化
- 根据JVM中已加载的类找到接口的唯一实现的过程叫基于类层次分析的完全去虚化
- 根据编译时收集的类型profile,依次匹配方法调用者的动态类型与profile中的类型
5、逃逸分析
当方法内部定义的对象被外部代码引用时,称为该对象逃逸,JVM对对象的分析过程叫逃逸分析
根据逃逸分析,即时编译器会在编译过程中对代码做如下优化:
- 锁消除:当一个锁对象只被一个线程加锁时,即时编译器会把锁去掉
- 栈上分配:当一个对象没有逃逸时,会将对象直接分配在栈上,随着线程回收,由于JVM的大量代码都是堆分配,所以目前JVM不支持栈上分配,而是采用标量替换
- 标量替换:当一个对象没有逃逸时,会将当前对象打散成若干局部变量,并分配在虚拟机栈的局部变量表中
6、即时编译的其他优化
- 字段读取优化:缓存多次读取的数据,减少出入栈次数
public String register(User user,String username,String password){
user.username = username;
return user.username + password;
}
class User{
private String username;
}
public String register(User user,String username){
String s = user.username;//user.username被缓存成了s
s = username;
return s + password;
}
- 字段存储优化:将被覆盖的赋值操作优化掉,减少无用的入栈
private void test(){
int a = 1;
a = 2;
}
private void test(){
int a = 2;//a=1被优化掉了
}
- 循环无关代码外提:避免重复执行表达式,减少出入栈次数
private void test(String s){
String password;
for (int i=0;i<10;i++){
password = s.replaceAll("/","");
System.out.println(i);
}
}
private void test(String s){
String password = s.replaceAll("/","");//与循环无关的代码被编译器外提了
for (int i=0;i<10;i++){
System.out.println(i);
}
}
- 循环展开:将相同的循环逻辑多次重复在一次迭代中,以减少循环次数
private void test(int[] arr){
int sum=0;
for (int i=0;i<8;i++){
sum +=arr[i];
}
}
private void test(int[] arr){
int sum=0;
for (int i=0;i<8;i+=2){//循环次数减少
sum +=arr[i];
sum +=arr[i+1];//重复循环体内相同逻辑
}
}
- 循环的自动向量化:对循环中地址连续的数组操作,会按顺序批量出入栈(这段是伪代码)
private void test(int[] arr1,int[] arr2){
for (int i=0;i<arr1.length;i++){
arr1[i] = arr2[i];
}
}
private void test(int[] arr1,int[] arr2){
for (int i=0;i<arr1.length;i+=4){
arr1[i:i+4] = arr2[i:i+4];//可以看成是在循环展开的基础上,将多个数组一块出入栈
}
}