Java即时编译器原理解析及实践

Java即时编译器原理解析及实践


如想了解更多更全面的Java必备内容可以阅读:所有JAVA必备知识点面试题文章目录:


一、序言

常见的编译型语言如C++,通常会把代码直接编译成CPU所能理解的机器码来运行。而Java为了实现“一次编译,处处运行”的特性,把编译的过程分成两部分,首先它会先由javac编译成通用的中间形式 字节码,然后再由解释器逐条将字节码解释为机器码来执行。所以在性能上,Java通常不如C++这类编译型语言。

为了优化Java的性能 ,JVM在解释器之外引入了即时(Just In Time)编译器:当程序运行时,解释器首先发挥作用,代码可以直接执行。随着时间推移,即时编译器逐渐发挥作用,把越来越多的代码编译优化成本地代码,来获取更高的执行效率。

即时编译器极大地提高了Java程序的运行速度,而且跟静态编译相比,即时编译器可以选择性地编译热点代码,省去了很多编译时间,也节省很多的空间。

二、Java的执行过程

1、Java的执行过程整体可以分为两个部分
  • 【第一步】由javac将源码编译成字节码,在这个过程中会进行词法分析、语法分析、语义分析,编译原理中这部分的编译称为前端编译(源码编译成字节码)。
  • 【第二步】接下来无需编译直接逐条将字节码解释执行,在解释执行的过程中,虚拟机同时对程序运行的信息进行收集,在这些信息的基础上,编译器会逐渐发挥作用,它会进行后端编译(把字节码编译成机器码),但不是所有的代码都会被编译,只有被JVM认定为的热点代码,才可能被编译。
2、怎么样才会被认为是热点代码

JVM中会设置一个阈值,当方法或者代码块的在一定时间内的调用次数超过这个阈值时就会被编译,存入codeCache中。当下次执行时,再遇到这段代码,就会从codeCache中读取机器码,直接执行,以此来提升程序运行的性能。
在这里插入图片描述

3、JVM中集成了两种编译器

HotSpot VM带有一个Client Compiler(C1编译器) 和一个Server Compiler(C2编译器)

  • Client Compiler(C1编译器):启动速度快,性能相比C2要差一些。C1编译器会做三件事:
    • 局部简单可靠的优化(比如字节码上进行的一些基础优化,方法内联、常量传播等,放弃许多耗时较长的全局优化)
    • 将字节码构造成高级中间表示(High-level Intermediate Representation,以下称为HIR),HIR与平台无关,通常采用图结构,更适合JVM对程序进行优化。
    • 最后将HIR转换成低级中间表示(Low-level Intermediate Representation,以下称为LIR),在LIR的基础上会进行寄存器分配、窥孔优化等操作,最终生成机器码
  • Server Compiler(C2编译器):启动时间长,适用于长时间运行的后台程序,它的性能通常比C1高30%以上。在Hotspot VM中使用的Server Compiler有两种:
    • C2 Compiler:在Hotspot VM中,默认的Server Compiler是C2编译器。
      C2编译器在进行编译优化时,会使用一种控制流与数据流结合的图数据结构,称为Ideal Graph。 Ideal Graph表示当前程序的数据流向和指令间的依赖关系,依靠这种图结构,某些优化步骤(尤其是涉及浮动代码块的那些优化步骤)变得不那么复杂。
      Ideal Graph的构建是在解析字节码的时候,根据字节码中的指令向一个空的Graph中添加节点,Graph中的节点通常对应一个指令块,每个指令块包含多条相关联的指令,JVM会利用一些优化技术对这些指令进行优化,比如Global Value Numbering、常量折叠等,解析结束后,还会进行一些死代码剔除的操作。生成Ideal Graph后,会在这个基础上结合收集的程序运行信息来进行一些全局的优化。
      无论是否进行全局优化,Ideal Graph都会被转化为一种更接近机器层面的MachNode Graph,最后编译的机器码就是从MachNode Graph中得的,生成机器码前还会有一些包括寄存器分配、窥孔优化等操作。
      Server Compiler编译优化的过程如下图所示:
      在这里插入图片描述

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

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

      Graal编译器可以通过Java虚拟机参数【-XX:+UnlockExperimentalVMOptions】【-XX:+UseJVMCICompiler】启用。

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

分层编译将JVM的执行状态分为了五个层次。五个层级分别是:

  • 【第0层】解释执行
  • 【第1层】执行不带profiling的C1代码
  • 【第2层】执行仅带方法调用次数以及循环回边执行次数profiling的C1代码
  • 【第3层】执行带所有profiling的C1代码
  • 【第4层】执行C2代码

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

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

按执行效率从高至低排序:1层>2层>3层;1层和4层都是终止状态(当一个方法到达终止状态后,只要编译后的代码并没有失效,那么JVM就不会再次发出该方法的编译请求的)。

服务实际运行时,JVM会根据服务运行情况,从解释执行开始,选择不同的编译路径,直到到达终止状态。下图中就列举了 [ 几种常见的编译路径 ] :
在这里插入图片描述

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

Java虚拟机根据方法的调用次数以及循环回边的执行次数来触发即时编译。

当方法的调用次数和循环回边的次数的和,超过由参数**【-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。

三、编译优化

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

1、中间表达形式(Intermediate Representation)

在编译原理中,通常把编译器分为前端编译和后端编译:
前端编译: 由javac将源码编译成字节码,在这个过程中经过词法分析、语法分析、语义分析生成中间表达形式(Intermediate Representation,以下称为IR);
后端编译: 会对IR进行优化,生成目标代码;

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

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

###############上面代码的SSA IR形式的伪代码可以表示为:
public void DeadCodeElimination{
  int a = 1;
  int b = 0;
  add(a,b)
}

C1中的中间表达形式:前文提及C1编译器内部使用高级中间表达形式HIR,低级中间表达形式LIR来进行各种优化,这两种IR都是SSA形式的。
C2编译器中的Ideal Graph:采用的是一种名为Sea-of-Nodes中间表达形式,同样也是SSA形式的。

2、方法内联

方法内联:是指在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。JIT大部分的优化都是在内联的基础上进行的,方法内联是即时编译器中非常重要的一环。

方法内联的条件:编译器的大部分优化都是在方法内联的基础上。所以一般来说,内联的方法越多,生成代码的执行效率越高。但是对于即时编译器来说,内联的方法越多,编译时间也就越长,程序达到峰值性能的时刻也就比较晚。

可以通过虚拟机参数-XX:MaxInlineLevel调整内联的层数,以及1层的直接递归调用(可以通过虚拟机参数-XX:MaxRecursiveInlineLevel调整)。一些常见的内联相关的参数如下表所示:
在这里插入图片描述
虚函数内联:内联是JIT提升性能的主要手段,但是虚函数使得内联是很难的,因为在内联阶段并不知道他们会调用哪个方法。
C2编译器的能力有限,对于多个实现方法的虚调用就“无能为力”了。比如下面这段代码:

public class SimpleInliningTest
{
    public static void main(String[] args) throws InterruptedException {
        VirtualInvokeTest obj = new VirtualInvokeTest();
        VirtualInvoke1 obj1 = new VirtualInvoke1();
        VirtualInvoke2 obj2 = new VirtualInvoke2();
        for (int i = 0; i < 100000; i++) {
            invokeMethod(obj);
            invokeMethod(obj1);
        invokeMethod(obj2);
        }
        Thread.sleep(1000);
    }public static void invokeMethod(VirtualInvokeTest obj) {
        obj.methodCall();
    }private static class VirtualInvokeTest {
        public void methodCall() {
            System.out.println("virtual call");
        }
    }private static class VirtualInvoke1 extends VirtualInvokeTest {
        @Override
        public void methodCall() {
            super.methodCall();
        }
    }
    private static class VirtualInvoke2 extends VirtualInvokeTest {
        @Override
        public void methodCall() {
            super.methodCall();
        }
    }
}


###############经过反编译得到下面的汇编代码:
 0x000000011f5f0a37: callq  0x000000011f4fd2e0  ; OopMap{off=28}
                                                ;*invokevirtual methodCall  //代表虚调用
                                                ; - SimpleInliningTest::invokeMethod@1 (line 20)
                                                ;   {virtual_call}  //虚调用未被优化

Graal编译器:针对这种情况,会去收集这部分执行的信息,比如在一段时间,发现前面的接口方法的调用add和sub是各占50%的几率,那么JVM就会在每次运行时,遇到add就把add内联进来,遇到sub的情况再把sub函数内联进来,这样这两个路径的执行效率就会提升。在后续如果遇到其他不常见的情况,JVM就会进行去优化的操作,在那个位置做标记,再遇到这种情况时切换回解释执行。

3、逃逸分析

逃逸分析:“一种确定指针动态范围静态分析,它可以分析在程序的哪些地方可以访问到指针”。
Java虚拟机的即时编译器会对新建的对象进行逃逸分析,判断对象是否逃逸出 【线程】 或者 【方法】

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

  1. 对象是否被存入堆中(静态字段或者堆中对象的实例字段),一旦对象被存入堆中,其他线程便能获得该对象的引用,即时编译器就无法追踪所有使用该对象的代码位置。
  2. 对象是否被传入未知代码中,即时编译器会将未被内联的代码当成未知代码,因为它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中,这种情况,可以直接认为方法调用的调用者以及参数是逃逸的。

逃逸分析通常是在方法内联的基础上进行的,即时编译器可以根据逃逸分析的结果进行诸如锁消除栈上分配以及标量替换的优化。下面这段代码的就是对象未逃逸的例子:

/*说明:在这个例子中,创建了两个对象foo和bar,其中一个作为另一个方法的参数提供。
	该方法setFoo()存储对收到的Foo对象的引用。如果Bar对象在堆上,则对Foo的引用将逃逸。
	但是在这种情况下,编译器可以通过逃逸分析确定Bar对象本身不会对逃逸出example()的调用。
	这意味着对Foo的引用也不能逃逸。因此,编译器可以安全地在栈上分配两个对象。
*/
pulbic class Example{
    public static void main(String[] args) {
      example();
    }
    public static void example() {
      Foo foo = new Foo();
      Bar bar = new Bar();
      bar.setFoo(foo);
    }
  }class Foo {}class Bar {
    private Foo foo;
    public void setFoo(Foo foo) {
      this.foo = foo;
    }
  }
}

锁消除:在学习Java并发编程时会了解锁消除,而锁消除就是在逃逸分析的基础上进行的。
如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没就有意义。因为线程并不能获得该锁对象。在这种情况下,即时编译器会消除对该不逃逸锁对象的加锁、解锁操作。实际上,编译器仅需证明锁对象不逃逸出线程,便可以进行锁消除。由于Java虚拟机即时编译的限制,上述条件被强化为证明锁对象不逃逸出当前编译的方法。不过,基于逃逸分析的锁消除实际上并不多见。

栈上分配:我们都知道Java的对象是在堆上分配的,而堆是对所有对象可见的。同时,JVM需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。
如果逃逸分析能够证明某些新建的对象不逃逸,那么JVM完全可以将其分配至栈上,并且在new语句所在的方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间。这样一来,我们便无须借助垃圾回收器来处理不再被引用的对象。
不过Hotspot虚拟机,并没有进行实际的栈上分配,而是使用了标量替换这一技术。

标量替换:编译器会在方法内将未逃逸的聚合量分解成多个标量,以此来减少堆上分配。下面是一个标量替换的例子:

标量:就是仅能存储一个值的变量,比如Java代码中的基本类型。
聚合量:则是时存储多个值,其中一个典型的例子便是Java的对象。
public class Example{
  @AllArgsConstructor
  class Cat{
    int age;
    int weight;
  }
  public static void example(){
    Cat cat = new Cat(1,10);
    addAgeAndWeight(cat.age,Cat.weight);
  }
}

##########经过逃逸分析,cat对象未逃逸出example()的调用,因此可以对聚合量cat进行分解,得到两个标量age和weight,进行标量替换后的伪代码:
public class Example{
  @AllArgsConstructor
  class Cat{
    int age;
    int weight;
  }
  public static void example(){
    int age = 1;
    int weight = 10;
    addAgeAndWeight(age,weight);
  }
}

部分逃逸分析:是Graal对于概率预测的应用。通常来说,如果发现一个对象逃逸出了方法或者线程,JVM就不会去进行优化,但是Graal编译器依然会去分析当前程序的执行路径,它会在逃逸分析基础上收集、判断哪些路径上对象会逃逸,哪些不会。然后根据这些信息,在不会逃逸的路径上进行锁消除、栈上分配这些优化手段。

4、循环转换(Loop Transformations)

C2编译器在构建Ideal Graph后会进行很多的全局优化,其中就包括对循环的转换,最重要的两种转换就是循环展开循环分离

循环展开:是一种循环转换技术,通过减少或消除控制程序循环的指令,来减少计算开销,它试图以牺牲程序二进制码大小为代价来优化程序的执行速度,是一种用空间换时间的优化手段。比如下面这个循环:

public void loopRolling(){
  for(int i = 0;i<200;i++){
    delete(i);  
  }
}

##########通过循环展开可以得到下面这段代码:
public void loopRolling(){
  for(int i = 0;i<200;i+=5){
    delete(i);
    delete(i+1);
    delete(i+2);
    delete(i+3);
    delete(i+4);
    ......
  }
}

循环分离:也是循环转换的一种手段。它把循环中一次或多次的特殊迭代分离出来,在循环外执行。举个例子,下面这段代码:

int a = 10;
for(int i = 0;i<10;i++){
  b[i] = x[i] + x[a];
  a = i;
}

######可以看出这段代码除了第一次循环a = 10以外,其他的情况a都等于i-1。
######所以可以把特殊情况分离出去,变成下面这段代码:
b[0] = x[0] + 10;
for(int i = 1;i<10;i++){
  b[i] = x[i] + x[i-1];
}
5、窥孔优化与寄存器分配

窥孔优化是优化的最后一步,这之后就会程序就会转换成机器码,窥孔优化就是将编译器所生成的中间代码(或目标代码)中相邻指令,将其中的某些组合替换为效率更高的指令组,常见的比如强度削减、常数合并等,看下面这个例子就是一个强度削减的例子:

y1=x1*3  【强度削减】经过强度削减后得到  y1=(x1<<1)+x1

寄存器分配:也是一种编译的优化手段,在C2编译器中普遍的使用。它是通过把频繁使用的变量保存在寄存器中,CPU访问寄存器的速度比内存快得多,可以提升程序的运行速度。

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

四、实践

1、编译相关的【重要参数】
  • -XX:+TieredCompilation:开启分层编译,JDK8之后默认开启
  • -XX:+CICompilerCount=N:编译线程数,设置数量后,JVM会自动分配线程数,C1:C2 = 1:2
  • -XX:TierXBackEdgeThreshold:OSR编译的阈值
  • -XX:TierXMinInvocationThreshold:开启分层编译后各层调用的阈值
  • -XX:TierXCompileThreshold:开启分层编译后的编译阈值
  • -XX:ReservedCodeCacheSize:codeCache最大大小
  • -XX:InitialCodeCacheSize:codeCache初始大小

-XX:TierXMinInvocationThreshold是开启分层编译的情况下,触发编译的阈值参数,当方法调用次数大于由参数-XX:TierXInvocationThreshold指定的阈值乘以系数,或者当方法调用次数大于由参数-XX:TierXMINInvocationThreshold指定的阈值乘以系数,并且方法调用次数和循环回边次数之和大于由参数-XX:TierXCompileThreshold指定的阈值乘以系数时,便会触发X层即时编译。分层编译开启下会乘以一个系数,系数根据当前编译的方法和编译线程数确定,降低阈值可以提升编译方法数,一些常用但是不能编译的方法可以编译优化提升性能。

由于编译情况复杂,JVM也会动态调整相关的阈值来保证JVM的性能,所以不建议手动调整编译相关的参数。除非一些特定的Case,比如codeCache满了停止了编译,可以适当增加codeCache大小,或者一些非常常用的方法,未被内联到,拖累了性能,可以调整内敛层数或者内联方法的大小来解决。

2、合理使用Graal编译器

为了提升性能,在服务中尝试了最新的Graal编译器。只需要使用-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler就可以启动Graal编译器来代替C2编译器,并且响应C2的编译请求,不过要注意的是,Graal编译器与ZGC不兼容,只能与G1搭配使用。

Graal是一个用Java写的即时编译器,它从Java 9开始便被集成自JDK中,作为实验性质的即时编译器。


本文转载于:Java即时编译器原理解析及实践,作为学习笔记记录的同时,分享资源!!!


······
帮助他人,快乐自己,最后,感谢您的阅读!
所以如有纰漏或者建议,还请读者朋友们在评论区不吝指出!

个人网站…知识是一种宝贵的资源和财富,益发掘,更益分享…

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值