vlambda博客
学习文章列表

JIT即时编译器(C1和C2)

上一篇文章我们已经讲述了的基本原理,今天我们看一下HotSpot虚拟机中具体的编译器。


1. Client Compiler(C1编译器)




C1编译器启动速度快,但是性能相比较Server Compiler相对来说会差一些,下面我们主要看一下C1编译器的具体步骤。


1.1 预准备工作




C1编译器会基于字节码完成部分优化,如:方法内联、常量传播。

方法内联是后面编译过程优化的关键前提。


1.2 构造HIR




C1编译器将字节码构造成一种高级代码表示(HIR),HIR使用静态单分配(SSA)的形式来代表代码值。

通过借助HIR我们可以实现冗余代码消除、死代码删除等编译优化工作,SSA的每个变量只能被赋值一次,并且只有当变量被赋值后才能使用。


1.2.1 冗余删除




a = 1;
a = 2;
b = a;

上述代码可以很容易发现a=1这一行是多余的,但是如果编译器基于字节码并不容易发现,需要借助数据流分析从后往前依次确认哪些变量的值被覆盖掉,但是借助SSA,编译器很容易识别冗余赋值,SSA的伪代码如下:

a_1=1;
a_2=2;
b_1=a_2;

借助SSA中变量的特性,原来的对a变量赋值2次转变成了对a_1、a_2变量分别赋值一次,编译器可以很容易可以发现a_1变量在赋值以后没有被使用,然后对这一行进行删除,避免多余的赋值操作。

除了进行冗余删除的优化以外,该阶段也会进行空值检查消除、范围检查消除等优化构成。


1.3 构造LIR




在构造出HIR,并且对代码优化过后,会将HIR转换成低级中间表示(LIR),LIR的表现形式也是SSA。

在LIR的基础上会进行寄存器分配、窥孔优化等操作,最终生成机器代码。

Client Compiler的编译流程大致如下图:


2. Server Compiler




Server Compiler关注的是编译耗时较长的全局优化,甚至还会根据程序运行时收集到的信息进行不可靠的激进优化。Server Compiler通常比Client Compiler启动时间长,适合用于长时间在后台运行的程序(Web服务)。

Sever Compiler几乎会执行所有经典的优化工作,如:无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序、Java语言紧密相关的优化技术(范围检查消除、空值检查消除)、分支频率预测等。

HotSpot虚拟机目前有两种:C2和Graal。


2.1 Graal




Graal编译器是JDK 9中的编译器,相比C2编译器,Graal有以下特性:

  • Graal比C2更加青睐于分支预测,选择性的编译一些运行概率较大的分支
  • 使用Java编写,对于Lambda、Stream等新特性更加友好
  • 更深层次的优化,如虚函数的内联、部分逃逸分析等

2.1 C2




C2编译器在编译优化时,使用一种控制流与数据流结合的图数据结构,成为Ideal Graph。

Ideal Graph在解析字节码的时候,根据字节码的指令向一个空的Graph中添加节点,该节点通常对应一个指令块,每个指令块包含多条相关联的指令,JVM会利用优化技术对这些指令进行优化,比如上文中提到的一些优化以及Global Value Numbering、常量折叠等,解析结束后,还会删除死代码。

生成Ideal Graph以后,JVM会判断此时有没有全局优化的必要,如果有必要,则进行优化,否则跳过。

Ideal Graph最终会被转换成更接近机器层面的MachNode Graph,然后进行寄存器的分配、窥孔优化,最终生成机器代码。

JIT即时编译器(C1和C2)


2.1.1 Ideal Graph




Ideal Graph采用的是Sea-of-Nodes中间表达形式,同样也是SSA形式的,最大特点就是去除了变量的概念,直接采用值来进行运算。

    public static int test(int count) {
        int sum = 0;
        for (int i = 0; i < count; i++) {
            sum += i;
        }
        return sum;
    }

我们使用Ideal Graph Visuallizer工具来查看一下上述代码的IR图。


红色加粗线条为控制流,蓝色线条为数据流,其他颜色的线条则是特殊的控制流或者数据流。控制流连接的是固定节点,其他的则是浮动节点(浮动节点只要能满足数据依赖关系,可以放在不同位置的节点,浮动节点变动的过程称为Schedule)。


2.1.2 Phi And Region Nodes




Ideal Graph是SSA IR,由于Ideal Graph没有变量的概念,不同的执行路径可能会对同一变量设置不同的值,因此需要解决根据不同的路径,读取不同的值。

为了达到上述目的,引入了Phi And Region Node的概念,可以根据不同的执行路径选择不同的值。


3. 编译优化





3.1 Global Value Numbering




GVN会为每一个计算的值分配一个唯一编号,通过GVN可以消除等价计算的指令。

a = 1;
b = 2;
c = a + b;
d = a + b;
e = d;

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


3.2 方法内联




方法内联是指在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代方法调用的优化手段。方法内联可以避免栈帧的入栈和出栈。


3.3 逃逸分析




逃逸分析是一种确定指针动态范围的静态分析,分析程序中哪些地方可以访问到对象指针。即时编译器会对对象进行逃逸分析,如果对象没有发生逃逸,则可以进行栈上分配(标量替换),锁消除等优化操作。


3.4 栈上分配




Java对象通常都会在堆上分配,堆上分配的对象如果回收则需要垃圾回收器的接入。假设一个对象经过逃逸分析只可能被当前线程进行访问(线程安全),则该对象则可以直接分配在栈上,分配在栈上可以随着栈帧的弹出被销毁,不需要垃圾回收器的介入。

Java的栈上分配采用标量替换的方式,标量是存储一个值的变量,例如基本类型。编译器会把一个对象中的聚合量(多个实例字段)分解成多个标量在栈上分配。

往期推荐