Java即时编译器原理解析

Dcr 1年前 ⋅ 1012 阅读

Java的执行过程

java的执行过程整体可以分为两部分 1.由javac将源码编译成字节码,这个过程中会进行__词法分析__,语法分析,语义分析,编译原理中这部分称为前端编译; 2.逐行字节码解释执行(虚拟机同时对程序运行的信息进行收集,在这些信息的基础上,编译器会逐渐发挥作用,进行后端编译--把字节码编译成机器码(这里只针对热点代码))

  • 热点代码 JVM中会设置一个阈值,当方法或者代码块在一定时间内的调用次数超过这个阈值就会被编译,存入codeCache中,当下次执行时,就会从codeCache中读取机器码,直接执行

1-1.png

JVM中的编译器

JVM中集成了两种编译器

  • Client Compiler 注重启动速度和局部的优化 HotSpot VM带有一个Client Compiler C1编译器.C1会做三件事:
  • 局部简单可靠的优化,比如字节码上进行的一些基础优化,方法内联,常量传播等,放弃许多耗时较长的全局优化.
  • 将字节码构造成高级中间表示(High-level Intermediate Representation,以下称为HIR),HIR与平台无关,通常采用图结构,更适合JVM对程序进行优化.
  • 最后将HIR转换成低级中间表示(Low-level Intermediate Representation,以下称为LIR),在LIR的基础上会进行寄存器分配、窥孔优化(局部的优化方式,编译器 在一个基本块或者多个基本块中,针对已经生成的代码,结合CPU自己指令的特点,通过一些认为可能带来性能提升的转换规则或者通过整体的分析,进行指令转换,来提升 代码性能)等操作,最终生成机器码。
  • Server Compiler 作用:全局的优化,性能会更好,但由于会进行更多的全局分析,所以启动速度会变慢. 两种编译器有着不同的应用场景,在虚拟机中同时发挥作用. Server Compiler主要关注一些编译耗时较长的全局优化,甚至会还会根据程序运行的信息进行一些不可靠的激进优化。这种编译器的启动时间长,适用于长时间运行的后台程序,它的性能通常比Client Compiler高30%以上。目前,Hotspot虚拟机中使用的Server Compiler有两种:C2和Graal.

C2 Compiler 在Hotspot VM中,默认的Server Compiler是C2编译器.

C2编译器在进行编译优化时,会使用一种控制流与数据流结合的图数据结构,称为Ideal Graph。 Ideal Graph表示当前程序的数据流向和指令间的依赖关系,依靠这种图结构, 某些优化步骤(尤其是涉及浮动代码块的那些优化步骤)变得不那么复杂。

Ideal Graph的构建是在解析字节码的时候,根据字节码中的指令向一个空的Graph中添加节点,Graph中的节点通常对应一个指令块,每个指令块包含多条相关联的指令, JVM会利用一些优化技术对这些指令进行优化,比如Global Value Numbering、常量折叠等,解析结束后,还会进行一些死代码剔除的操作。生成Ideal Graph后, 会在这个基础上结合收集的程序运行信息来进行一些全局的优化,这个阶段如果JVM判断此时没有全局优化的必要,就会跳过这部分优化。

无论是否进行全局优化,Ideal Graph都会被转化为一种更接近机器层面的MachNode Graph,最后编译的机器码就是从MachNode Graph中得的,生成机器码前还会有一些包括寄存器分配、 窥孔优化等操作。关于Ideal Graph和各种全局的优化手段会在后面的章节详细介绍。Server Compiler编译优化的过程如下图所示:

1-2.png

Graal Compiler 从JDK9开始,Hotspot VM中集成了一种新的Server Compiler, Graal编译器.相比C2编译器,Graal有这样几种关键特性:

  • 前文有提到,JVM会在解释执行的时候收集程序运行的各种信息,然后编译器会根据这些信息进行一些基于预测的激进优化,比如分支预测,根据程序不同分支的运行概率,选择性地编译一些概率较大的分支。Graal比C2更加青睐这种优化,所以Graal的峰值性能通常要比C2更好。
  • 使用Java编写,对于Java语言,尤其是新特性,比如Lambda、Stream等更加友好。
  • 更深层次的优化,比如虚函数的内联、部分逃逸分析等。

Graal编译器可以通过Java虚拟机参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler启用。当启用时,它将替换掉HotSpot中的C2编译器,并响应原本由C2负责的编译请求。

分层编译

在Java 7以前,需要研发人员根据服务的性质去选择编译器。对于需要快速启动的,或者一些不会长期运行的服务,可以采用编译效率较高的C1,对应参数-client。长期运行的服务,或者对峰值性能有要求的后台服务,可以采用峰值性能更好的C2,对应参数-server。Java 7开始引入了分层编译的概念,它结合了C1和C2的优势,追求启动速度和峰值性能的一个平衡。分层编译将JVM的执行状态分为了五个层次。五个层级分别是:

1.解释执行 2.执行不带profiling的C1代码 3.执行仅带方法调用次数以及循环回边执行次数的profiling的C1代码 4.执行带所有profiling的C1代码 5.执行C2代码

profiling就是收集能够反映程序执行状态的数据。其中最基本的统计数据就是方法的调用次数,以及循环回边的执行次数。

通常情况下,C2代码的执行效率要比C1代码的高出30%以上。C1层执行的代码,按执行效率排序从高至低则是1层>2层>3层。这5个层次中,1层和4层都是终止状态,当一个方法到达终止状态后,只要编译后的代码并没有失效,那么JVM就不会再次发出该方法的编译请求的。服务实际运行时,JVM会根据服务运行情况,从解释执行开始,选择不同的编译路径,直到到达终止状态。下图中就列举了几种常见的编译路径:

1-3.png

  • 图中第①条路径,代表编译的一般情况,热点方法从解释执行到被3层的C1编译,最后被4层的C2编译.
  • 如果方法比较小(比如Java服务中常见的getter/setter方法),3层的profiling没有收集到有价值的数据,JVM就会断定该方法对于C1代码和C2代码的执行效率相同,就会执行图中第②条路径。在这种情况下,JVM会在3层编译之后,放弃进入C2编译,直接选择用1层的C1编译运行.
  • 在C1忙碌的情况下,执行图中第③条路径,在解释执行过程中对程序进行profiling ,根据信息直接由第4层的C2编译.
  • 前文提到C1中的执行效率是1层>2层>3层,第3层一般要比第2层慢35%以上,所以在C2忙碌的情况下,执行图中第④条路径。这时方法会被2层的C1编译,然后再被3层的C1编译,以减少方法在3层的执行时间.
  • 如果编译器做了一些比较激进的优化,比如分支预测,在实际运行时发现预测出错,这时就会进行反优化,重新进入解释执行,图中第⑤条执行路径代表的就是反优化.

总的来说,C1的编译速度更快,C2的编译质量更高,分层编译的不同编译路径,也就是JVM根据当前服务的运行情况来寻找当前服务的最佳平衡点的一个过程。从JDK 8开始,JVM默认开启分层编译.

即时编译的触发

Java虚拟机根据方法的调用次数以及循环回边的执行次数来触发即时编译.循环回边是一个控制流图中的概念,程序中可以简单理解为往回跳转的指令,比如下面这段代码:

  • 循环回边
public void nlp(Object obj) {
  int sum = 0;
  for (int i = 0; i < 200; i++) {
    sum += i;
  }
}

经过编译生成下面的字节码.其中偏移量为18的字节码将往回跳至偏移量为4的字节码中.在解释执行时,每当运行一次该指令,java虚拟机便会将该方法的循环回边计数器加1.

  • 字节码
public void nlp(java.lang.Object);
    Code:
       0: iconst_0
       1: istore_1
       2: iconst_0
       3: istore_2
       4: iload_2
       5: sipush        200
       8: if_icmpge     21
      11: iload_1
      12: iload_2
      13: iadd
      14: istore_1
      15: iinc          2, 1
      18: goto          4
      21: return

在即时编译过程中,编译器会识别循环的头部和尾部。上面这段字节码中,循环体的头部和尾部分别为偏移量为11的字节码和偏移量为15的字节码。编译器将在循环体结尾增加循环回边计数器的代码,来对循环进行计数。 当方法的调用次数和循环回边的次数的和,超过由参数-XX:CompileThreshold指定的阈值时(使用C1时,默认值为1500;使用C2时,默认值为10000),就会触发即时编译。 开启分层编译的情况下,-XX:CompileThreshold参数设置的阈值将会失效,触发编译会由以下的条件来判断:

  • 方法调用次数大于由参数-XX:TierXInvocationThreshold指定的阈值乘以系数。
  • 方法调用次数大于由参数-XX:TierXMINInvocationThreshold指定的阈值乘以系数,并且方法调用次数和循环回边次数之和大于由参数-XX:TierXCompileThreshold指定的阈值乘以系数时。

分层编译触发条件公式

i > TierXInvocationThreshold * s || (i > TierXMinInvocationThreshold * s  && i + b > TierXCompileThreshold * s) 
i为调用次数,b是循环回边次数

上述满足其中一个条件就会触发即时编译,并且JVM会根据当前的编译方法数以及编译线程数动态调整系数s。

编译优化

即时编译器会对正在运行的服务进行一系列的优化,包括字节码解析过程中的分析,根据编译过程中代码的一些中间形式来做局部优化,还会根据程序依赖图进行全局优化,最后才会生成机器码。

  • 中间表达形式(Intermediate Representation) 在编译原理中,通常把编译器分为前端和后端,前端编译经过词法分析,语法分析,语义分析生成中间表达形式(Intermediate Representation),后端会对IR进行优化,生成目标代码.

Java字节码就是一种IR,但是字节码的结构复杂,字节码这样代码形式的IR也不适合做全局的分析优化.现代编译器一般采用图结构的IR,静态单赋值(Static Single Assignment, SSA)IR是目前比较常用的一种.这种IR的特点是每个变量只能被赋值一次,而且只有当变量被赋值之后才能使用.

SSA IR

Plain Text
{
  a = 1;
  a = 2;
  b = a;
}

上述代码中我们可以轻易地发现a = 1 的赋值是冗余的,但是编译器不能.传统的编译器需要借助数据流分析,从后至前依次确认哪些变量的值被覆盖掉.不过,如果借助了SSA IR,编译器则可以很容易识别冗余赋值. 上面代码的SSA IR形式的伪代码可以表示为: SSA IR

Plain Text
{
  a_1 = 1;
  a_2 = 2;
  b_1 = a_2;
}

由于SSA IR中每个变量只能赋值一次,所以代码中的a在SSA IR中会分成a_1、a_2两个变量来赋值,这样编译器就可以很容易通过扫描这些变量来发现a_1的赋值后并没有使用,赋值是冗余的.

除此之外,SSA IR对其他优化方式也有很大的帮助,例如下面这个死代码删除(Dead Code Elimination)的例子: DeadCodeElimination

public void DeadCodeElimination{
  int a = 2;
  int b = 0
  if(2 > 1){
    a = 1;
  } else{
    b = 2;
  }
  add(a,b)
}

SSA IR伪代码: DeadCodeElimination

a_1 = 2;
b_1 = 0
if true:
  a_2 = 1;
else
  b_2 = 2;
add(a,b)

编译器通过执行字节码可以发现 b_2 赋值后不会被使用,else分支不会被执行。经过死代码删除后就可以得到代码:

DeadCodeElimination

public void DeadCodeElimination{
  int a = 1;
  int b = 0;
  add(a,b)
}

我们可以将编译器的每一种优化看成一个图优化算法,它接收一个IR图,并输出经过转换后的IR图。编译器优化的过程就是一个个图节点的优化串联起来的。

  • C1中的中间表达形式

前文提及C1编译器内部使用高级中间表达形式HIR,低级中间表达形式LIR来进行各种优化,这两种IR都是SSA形式的。

HIR是由很多基本块(Basic Block)组成的控制流图结构,每个块包含很多SSA形式的指令。基本块的结构如下图所示:

1-4.png

其中,predecessors表示前驱基本块(由于前驱可能是多个,所以是BlockList结构,是多个BlockBegin组成的可扩容数组)。同样,successors表示多个后继基本块BlockEnd。除了这两部分就是主体块,里面包含程序执行的指令和一个next指针,指向下一个执行的主体块。

从字节码到HIR的构造最终调用的是GraphBuilder,GraphBuilder会遍历字节码构造所有代码基本块储存为一个链表结构,但是这个时候的基本块只有BlockBegin,不包括具体的指令。第二步GraphBuilder会用一个ValueStack作为操作数栈和局部变量表,模拟执行字节码,构造出对应的HIR,填充之前空的基本块,这里给出简单字节码块构造HIR的过程示例,如下所示: 字节码构造HIR 1-5

   字节码                     Local Value             operand stack              HIR
      5: iload_1                  [i1,i2]                 [i1]
      6: iload_2                  [i1,i2]                 [i1,i2]   
                                  ................................................   i3: i1 * i2
      7: imul                                   
      8: istore_3                 [i1,i2,i3]              [i3]

可以看出,当执行iload_1时,操作数栈压入变量i1,执行iload_2时,操作数栈压入变量i2,执行相乘指令imul时弹出栈顶两个值,构造出HIR i3 : i1 * i2,生成的i3入栈。

C1编译器优化大部分都是在HIR之上完成的。当优化完成之后它会将HIR转化为LIR,LIR和HIR类似,也是一种编译器内部用到的IR,HIR通过优化消除一些中间节点就可以生成LIR,形式上更加简化。

全部评论: 0

    我有话说: