即时编译器JIT

798 阅读10分钟

一、编译型语言和解释性语言

编译型语言如C++,通常会把代码直接编译成CPU所能理解的机器码来运行。
而Java为了实现跨平台,先通过javac编译成字节码,再由解释器逐条将字节码解释为机器码来执行。
所以java在性能上不如C++这类编译型语言。

二、执行流程

image.png

  • 第一步将源码通过javac编译成字节码文件(在这个过程中会进行词法分析、语法分析、语义分析,编译原理中这部分的编译称为前端编译)。
  • 第二步先判断方法或者代码块是否已经编译成机器码(CodeCache中是否已经编译完成) 。
  • 如果没有,那就解释执行,在解释执行的过程中,虚拟机同时对程序运行的信息进行收集,直到在一定时间内调用超过阈值,认定为热点代码,触发后端编译,把字节码编译成机器码,放入方法区的CodeCache。
  • 如果有,那就直接编译执行,也就是直接执行机器码。

三、编译器

  • C1编译器:Client Compiler,这种编译器启动速度快,但是性能比较Server Compiler来说会差一些
  • C2编译器:主要关注一些编译耗时较长的全局优化,甚至会还会根据程序运行的信息进行一些不可靠的激进优化。这种编译器的启动时间长,适用于长时间运行的后台程序,它的性能通常比Client Compiler高30%以上
  • Graal编译器:Graal比C2更加青睐进行一些不可靠的激进优化,比如分支预测,根据程序不同分支的运行概率,选择性地编译一些概率较大的分支,所以Graal的峰值性能通常要比C2更好
  • 分层编译:Java 7开始引入了分层编译的概念,它结合了C1和C2的优势,追求启动速度和峰值性能的一个平衡。分层编译将JVM的执行状态分为了五个层次。
  1. 解释执行。
  2. 执行不带profiling的C1代码。
  3. 执行仅带方法调用次数以及循环回边执行次数profiling的C1代码。
  4. 执行带所有profiling的C1代码。
  5. 执行C2代码。

profiling:就是收集能够反映程序执行状态的数据。其中最基本的统计数据就是方法的调用次数,以及循环回边的执行次数。
循环回边:是一个控制流图中的概念,程序中可以简单理解为往回跳转的指令。

四:触发条件

  1. 方法的执行次数

Server编译器模式下,默认的值时10000
client编译器模式下,默认的值时1500

  1. 循环的执行次数

五:热度衰减

比如当某个方法已经执行7000次,一段时间后未执行,2倍速衰减,次数降为3500,1750次,直至降为0。再次执行后,会在衰减后的数量上开始增加。

六:执行方式

在解释执行的过程中,虚拟机同时对程序运行的信息进行收集,在一定时间内调用超过阈值,认定为热点代码,触发后端编译,将这个即时编译请求写入一个队列,最终由VM_THREAD来异步执行该编译请求。

七:编译优化

1. 中间表达形式(Intermediate Representation)
  • 静态单赋值(Static Single Assignment,SSA)

    #code
    y = 1;
    y = 2;
    x = y;
    #ssr 伪代码
    y1 = 1;
    y2 = 2;
    x1 = y2;
    
  • Sea-of-nodes 去除了变量的概念,直接采用变量所指向的值,来进行运算

  • Phi And Region Nodes

    int test(int x) {
        int a = 0;
        if(x == 1) {
            a = 5;
        } else {
            a = 6;
        }
        return a;
    }
    

    优化后

    int test(int x) {
         a_1 = 0;
         if(x == 1){
             a_2 = 5;
         }else {
             a_3 = 6;
         }
         a_4 = Phi(a_2,a_3);
         return a_4;
     } 
    

引入一个Phi Nodes的概念,能够根据不同的执行路径选择不同的值。

  • Global Value Numbering GVN是指为每一个计算得到的值分配一个独一无二的编号,然后遍历指令寻找优化的机会,它可以发现并消除等价计算的优化技术。
a = 1;
b = 2;
c = a + b;
d = a + b;
e = d;

GVN会利用Hash算法编号,计算a = 1时,得到编号1,计算b = 2时得到编号2,计算c = a + b时得到编号3,这些编号都会放入Hash表中保存,在计算d = a + b时,会发现a + b已经存在Hash表中,就不会再进行计算,直接从Hash表中取出计算过的值。最后的e = d也可以由Hash表中查到而进行复用。

2. 方法内联

JVM为了能高效地管理方法调用,其维护了一个栈结构,栈里面维护了一个个栈帧,每一个方法就是一个栈帧,其中维护了局部变量表、操作数栈、动态连接、方法返回地址和一些其他的附加信息。在前端编译时,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中。

方法调用大致过程:  
1、除非调用的方法是类方法,每一次方法执行之前,JVM会先将方法被调用的对象压入操作数栈中,除了对象的引用之外,JVM还会将方法的参数压入操作数栈。  
2、在执行方法调用指令时,JVM会将函数参数和对象引用一次从操作数栈中弹出,然后新建一个栈帧,把对象引用和函数参数分别放入局部变量表slot。  
3、JVM将新创建的栈帧压入虚拟机方法栈中,并把PC(程序计数器寄存器)指向函数的第一条待执行的指令。

JVM里面提供了4条方法调用字节码指令。分别如下:

  • invokestatic:调用静态方法

  • invokespecial:调用实例构造器方法、私有方法和父类方法(super(),super.method())

  • invokevirtual:调用所有的虚方法(静态方法、私有方法、实例构造器、父类方法、final方法都是非虚方法)

  • invokeinterface:调用接口方法,会在运行时期再确定一个实现此接口的对象
    invokestatic和invokespecial指令调用的方法都可以在解析阶段中确定唯一的调用版本,它们在类加载阶段就会把符号引用解析为该方法的直接引用。直接引用就是一个指针或偏移量,可以让JVM快速定位到具体要调用的方法。
    invokevirtual和invokeinterface指令需要运行时根据方法的符号引用查找到方法地址。具体过程如下:

  1. 在方法调用指令之前,将对象的引用压入操作数栈
  2. 在执行方法调用时,找到操作数栈顶的第一个元素所指向的对象实际类型,记作C
  3. 在类型C中找到与常量池中的描述符和方法名称都相符的方法,并校验访问权限。如果找到该方法并通过校验,则返回这个方法的引用;
  4. 否则,按照继承关系往上查找方法并校验访问权限
  5. 如果始终没找到方法,则抛出java.lang.AbstractMethodError异常;

终上所述只是借此展开一些JVM方法调用的过程,对于方法内联这个优化而言,仅仅想说明,方法调用对于JVM性能损耗非常大。所以当方法被JVM标注为热点方法,默认情况下,方法大小小于325字节会进行内联,如果方法不是经常执行的,默认情况下,方法大小小于35字节才会进行内联。
JIT内联优化只针对非虚方法。只有invokestatic和invokespecial字节码指令调用才有可能内联,也就是说只有调用静态方法、私有方法、实例构造器、父类方法、final方法才会发生内联。

3. 逃逸分析

即时编译器判断对象是否逃逸的依据有两种:

  • 对象是否被存入堆中(静态字段或者堆中对象的实例字段),一旦对象被存入堆中,其他线程便能获得该对象的引用,即时编译器就无法追踪所有使用该对象的代码位置。
  • 对象是否被传入未知代码中,即时编译器会将未被内联的代码当成未知代码,因为它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中,这种情况,可以直接认为方法调用的调用者以及参数是逃逸的。 逃逸分析通常是在方法内联的基础上进行的,即时编译器可以根据逃逸分析的结果进行诸如锁消除、栈上分配(Hotspot虚拟机,并没有进行实际的栈上分配,而是使用了标量替换这一技术)以及标量替换(所谓的标量,就是仅能存储一个值的变量,比如Java代码中的基本类型。与之相反,聚合量则可能同时存储多个值,其中一个典型的例子便是Java的对象。编译器会在方法内将未逃逸的聚合量分解成多个标量,以此来减少堆上分配)。
4. Loop Transformations
  • 循环展开通过减少或消除控制程序循环的指令,来减少计算开销,这种开销包括增加指向数组中下一个索引或者指令的指针算数等。如果编译器可以提前计算这些索引,并且构建到机器代码指令中,那么程序运行时就可以不必进行这种计算。也就是说有些循环可以写成一些重复独立的代码。
  • 循环分离也是循环转换的一种手段。它把循环中一次或多次的特殊迭代分离出来,在循环外执行。
5. 窥孔优化与寄存器分配

寄存器分配和窥孔优化是程序优化的最后一步。经过寄存器分配和窥孔优化之后,程序就会被转换成机器码保存在codeCache中。

  • 窥孔优化就是将编译器所生成的中间代码(或目标代码)中相邻指令,将其中的某些组合替换为效率更高的指令组,常见的比如强度削减、常量折叠等。
    示例:  
    x1=4*1024经过常量折叠后变为x1=4096  
    x1=4; y1=x1经过常量传播后变为x1=4; y1=4  
    y1=x1*3经过强度削减后变为y1=(x1<<1)+x1  
    if(2>1){y1=1;}else{y2=1;}经过死代码删除后变为y1=1
    
  • 寄存器分配也是一种编译的优化手段,在C2编译器中普遍的使用。它是通过把频繁使用的变量保存在寄存器中,CPU访问寄存器的速度比内存快得多,可以提升程序的运行速度。