JIT即时编译

JIT (just-in-time compilation) 是指程序在运行过程中对热点代码进行编译的过程,编译后的机器码存入CodeCache,下次再遇到这段代码,就会从CodeCache中读取机器码,直接执行,以此来提升程序运行的性能。

有的JVM全程都是JIT编译运行,例如:JRockit;有的JVM是解释器 + JIT运行,例如:HotSpot, J9(也支持AOT);有的JVM没有解释器,只支持AOT+JIT或者纯AOT,例如:Excelsior JET

以HotSpot JVM举例,JIT还可以分为C1, C2等。C1编译快,执行效率低;C2编译慢,效率高。可以通过 -XX:+TieredCompilation 开启分层编译,对热点代码先C1编译以尽快进入到编译执行模式;随着应用继续运行,收集到足够多的指标后,再进行C2编译,以期获得最好的执行效率。

JIT在编译过程中会采用一些优化手段,包括:公共子表达式消除、数组范围检查消除、方法内联、逃逸分析(目的是栈上分配、同步消除、标量替换、循环变型、窥孔优化与寄存器分配)

1. 编译器与解释器

不做特别说明的话,我们讲的,

编译器:程序运行前将其编译成机器码的程序

解释器:程序运作中逐行解释源码得到结果的程序

特别注意的是,解释器也是一个程序,输入源码,输出结果,并没有显示的将源码转换成机器码的过程 解释器与 JIT

无论是编译器还是解释器,从 源码结果都需要将源码经过:词法分析 -> 语法分析 -> 语义分析 处理,

一个比较简单的编译器的处理步骤看起来:

编译流程:
  源码 [字符流]
- 词法分析 -> 单词(token)流
- 语法分析 -> 语法树 / 抽象语法树
- 语义分析 -> 标注了属性的抽象语法树
- 代码生成 -> 目标代码

执行流程:
  目标代码
- 操作系统/硬件 -> 执行结果

狭义的解释器处理步骤看起来:

解释执行流程:
  源码 [字符流]
- 需要做词法分析+语法分析+类型检查的字符流解释器 -> 执行结果

特别注意的是,解释器真正的输入往往并直接是源码,使用解释器实现的编程语言实现里,通常:

  • 至少会在解释执行前做完语法分析,然后通过树解释器来实现解释执行;
  • 兼顾易于实现、跨平台、执行效率这几点,会选择使用字节码解释器实现解释执行。

为什么大多数解释器都将AST转化成字节码再用虚拟机执行,而不是直接解释AST?

2. 解释型语言

很多资料会说,Python、Ruby、JavaScript都是“解释型语言”,是通过解释器来实现的。这么说其实很容易引起误解:语言一般只会定义其抽象语义,而不会强制性要求采用某种实现方式。

例如说C一般被认为是“编译型语言”,但C的解释器也是存在的,例如Ch。同样,C++也有解释器版本的实现,例如Cint

一般被称为“解释型语言”的是主流实现为解释器的语言,但并不是说它就无法编译。例如说经常被认为是“解释型语言”的Scheme就有好几种编译器实现,其中率先支持R6RS规范的大部分内容的是Ikarus,支持在x86上编译Scheme;它最终不是生成某种虚拟机的字节码,而是直接生成x86机器码。

虚拟机随谈(一):解释器,树遍历解释器,基于栈与基于寄存器,大杂烩

3. JVM是否JIT全程编译?

  • HotSpot VM、J9 VM:不是,这两个JVM默认用混合模式执行引擎,以解释为基础,然后对热点做编译;这两者同时还支持AOT编译执行。J9 VM对AOT编译的支持早就有了;HotSpot VM的将在JDK9的某个更新版中发布,请参考Java Goes AOT(打不开请自备工具…)
  • JRockit VM:,JRockit VM没有解释器,只能对所有Java方法都做JIT编译;
  • Jikes RVMMaxine VMJato VM等:,跟JRockit类似,只有JIT编译器而没有解释器,因而只能JIT编译执行;
  • Excelsior JET不是,可配置为用纯AOT编译,或者AOT+JIT编译执行。

4. 为什么有些JVM会选择不总是做JIT编译,而是选择用解释器+JIT编译器的混合执行引擎

1. 编译的时间开销

解释器的执行,抽象的看是这样的:

输入的代码 -> [ 解释器 解释执行 ] -> 执行结果

而要JIT编译然后再执行的话,抽象的看则是:

输入的代码 -> [ 编译器 编译 ] -> 编译后的代码 -> [ 执行 ] -> 执行结果

说JIT比解释快,其实说的是“执行编译后的代码”比“解释器解释执行”要快,并不是说“编译”这个动作比“解释”这个动作快。

然而这JIT编译再怎么快,至少也比解释执行一次略慢一些,而要得到最后的执行结果还得再经过一个“执行编译后的代码”的过程。

所以,对“只执行一次”的代码而言,解释执行其实总是比JIT编译执行要快。 怎么算是“只执行一次的代码”呢?粗略说,下面两个条件同时满足时就是严格的“只执行一次”

  • 只被调用一次,例如类的初始化器(class initializer,()V)
  • 没有循环

对只执行一次的代码做JIT编译再执行,可以说是得不偿失。 对只执行少量次数的代码,JIT编译带来的执行速度的提升也未必能抵消掉最初编译带来的开销。 只有对频繁执行的代码,JIT编译才能保证有正面的收益。

况且,并不是说JIT编译了的代码就一定会比解释执行快。切不可盲目认为有了JIT就可以鄙视解释器了,还是得看实现细节如何。 有个很经典的例子:LuaJIT 2里有一个实现得非常优化的解释器,它解释执行的速度甚至比LuaJIT 1的JIT编译后的代码的速度还要快。

2. 编译的空间开销

举个最简单的例子:

  public static int foo() {
    return 42;
  }

其字节码大小只有3字节:

  public static int foo();
    Code:
      stack=1, locals=0, args_size=0
         0: bipush        42
         2: ireturn

而由Linux/x86-64上的HotSpot VM的Server Compiler将其编译为机器码后,则膨胀到了56字节:

  # {method} 'foo' '()I' in 'XX'
  #           [sp+0x20]  (sp of caller)
  0x00000001017b8200: sub    $0x18,%rsp
  0x00000001017b8207: mov    %rbp,0x10(%rsp)    ;*synchronization entry
                                                ; - XX::foo@-1 (line 3)
  0x00000001017b820c: mov    $0x2a,%eax
  0x00000001017b8211: add    $0x10,%rsp
  0x00000001017b8215: pop    %rbp
  0x00000001017b8216: test   %eax,-0x146021c(%rip)        # 0x0000000100358000
                                                ;   {poll_return}
  0x00000001017b821c: retq   
  0x00000001017b821d: hlt    
  0x00000001017b821e: hlt    
  0x00000001017b821f: hlt    
[Exception Handler]
[Stub Code]
  0x00000001017b8220: jmpq   0x00000001017b50a0  ;   {no_reloc}
[Deopt Handler Code]
  0x00000001017b8225: callq  0x00000001017b822a
  0x00000001017b822a: subq   $0x5,(%rsp)
  0x00000001017b822f: jmpq   0x000000010178eb00  ;   {runtime_call}
  0x00000001017b8234: hlt    
  0x00000001017b8235: hlt    
  0x00000001017b8236: hlt    
  0x00000001017b8237: hlt    

对一般的Java方法而言,编译后代码的大小相对于字节码的大小,膨胀比达到10x是很正常的。上面的例子比较极端一些,但还是很能反映现实状况的。

同上面说的时间开销一样,这里的空间开销也是,只有对执行频繁的代码才值得编译,如果把所有代码都编译则会显著增加代码所占空间,导致“代码爆炸”(code size explosion)。

3. 编译时机对优化的影响

有些JIT编译器非常简单,基本上不做啥优化,也倒也没啥影响。

但现代做优化的JIT编译器都非常注重使用profile信息,而profile是需要通过执行用户程序来获取的。

这样,编译得太早的话,就来不及收集足够profile信息,进而会影响优化的效果;而编译太迟的话,即便收集了很多高质量的profile,但却也已经付出了profile的额外开销,编译出来的代码再快或许也弥补不过来了。

在解释器里实现收集profile的功能,等解释执行一段时间后再触发JIT编译,这样就可以很好的平衡收集profile与编译优化这两方面。

当然,收集profile也可以在JIT编译器里做:一开始先JIT编译生成收集profile的版本的代码,等收集了到足够profile后触发重新编译,再生成出优化的、不带profile的版本。JRockit基本上就是这样做的。

为什么 JVM 不用 JIT 全程编译?

5. Java的执行过程

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

怎么样才会被认为是热点代码呢?JVM中会设置一个阈值,当方法或者代码块的在一定时间内的调用次数超过这个阈值时就会被编译,存入codeCache中。当下次执行时,再遇到这段代码,就会从codeCache中读取机器码,直接执行,以此来提升程序运行的性能。整体的执行过程大致如下图所示:

img

6. JVM Client 模式和 Server模式的区别

img

通过 java -version 可查看 JVM 所处的模式,并可以通过修改配置文件进行配置,那它们有什么区别呢?

Server:-Server 模式启动时,速度较慢,但是启动之后,性能更高,适合运行服务器后台程序

Client:-Client 模式启动时,速度较快,启动之后不如 Server,适合用于桌面等有界面的程序

7. 即时编译器的分类

  • Client Compiler - C1编译器
  • Server Compiler - C2编译器、Graal Compiler

目前主流的 HotSpot 虚拟机(JDK1.7 及之前版本的虚拟机)默认采用一个解释器和其中一个编译器直接配合的方式工作,程序使用哪个编译器,取决于虚拟机运行的模式,就是文章开头提到的两种模式。

在 HotSpot 中,解释器和 JIT 即时编译器是同时存在的,他们是 JVM 的两个组件。对于不同类型的应用程序,用户可以根据自身的特点和需求,灵活选择是基于解释器运行还是基于 JIT 编译器运行。HotSpot 为用户提供了几种运行模式供选择,可通过参数设定,分别为:解释模式、编译模式、混合模式,HotSpot 默认是混合模式,需要注意的是编译模式并不是完全通过 JIT 进行编译,只是优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。

8. 分层编译

产生的原因:由于即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,所花费的时间可能更长;而且要想编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行的速度也有影响。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot 虚拟机启用分层编译的策略

分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次:

  • 第 0 层:程序解释执行,解释器不开启性能监控功能,可触发第 1 层编译。
  • 第 1 层:也称为 C1 编译,将字节码编译为本地代码,进行简单,可靠的优化,如有必要将加入性能监控的逻辑。
  • 第 2 层(或 2 层以上):也称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

实施分层编译后,Client Compiler 和 Server Compiler 将会同时工作,许多代码都可能会被多次编译看,用 Client Compiler 获取更高的编译速度,用 Server Compiler 获取更好的编译质量,在解释执行的时候也无须再承担收集性能监控信息的任务

9. 热点代码

1. 热点代码的分类

  • 被多次调用的方法

一个方法被调用得多了,方法体内代码执行的次数自然就多,成为“热点代码”是理所当然的。

  • 被多次执行的循环体

一个方法只被调用过一次或少量的几次,但是方法体内部存在循环次数较多的循环体,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是“热点代码”。

2. 如何检测热点代码

判断一段代码是否是热点代码,是否需要触发即使编译,这样的行为称为热点探测,热点探测并不一定知道方法具体被调用了多少次,目前主要的热点探测判定方式有两种:

  • 基于采样的热点探测:采用这种方法的虚拟机会周期性地检查各个线程的栈顶如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”

优点:实现简单高效,容易获取方法调用关系(将调用堆栈展开即可)

缺点:不精确,容易因为因为受到线程阻塞或别的外界因素的影响而扰乱热点探测

  • 基于计数器的热点探测:采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果次数超过一定的阈值就认为它是“热点方法”

优点:统计结果精确严谨

缺点:实现麻烦,需要为每个方法建立并维护计数器,不能直接获取到方法的调用关系

HotSpot使用第二种 - 基于计数器的热点探测方法。

确定了检测热点代码的方式,如何计算具体的次数呢?

3. 计数器的种类(两种共同协作)

  • 方法调用计数器:这个计数器用于统计方法被调用的次数。默认阈值在 Client 模式下是 1500 次,在 Server 模式下是 10000 次
  • 回边计数器:统计一个方法中循环体代码执行的次数

了解了热点代码和计数器有什么用呢?达到计数器的阈值会触发后文讲解的即时编译,也就是说即时编译是需要达到某种条件才会触发的。

10. 编译优化技术

1. 语言无关的经典优化技术之一:公共子表达式消除

如果一个表达式 E 已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成为了公共子表达式。对于这种表达式,没必要花时间再对它进行计算,只需要直接使用前面计算过的表达式结果代替 E 就可以了。例子:int d = (c*b) * 12 + a + (a+ b * c) -> int d = E * 12 + a + (a+ E)

2. 语言相关的经典优化技术之一:数组范围检查消除

在 Java 语言中访问数组元素的时候系统将会自动进行上下界的范围检查,超出边界会抛出异常。对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑是一种性能负担。Java 在编译期根据数据流分析可以判定范围进而消除上下界检查,节省多次的条件判断操作。

3. 最重要的优化技术之一:方法内联

简单的理解为把目标方法的代码“复制”到发起调用的方法中,消除一些无用的代码。

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

Java服务中存在大量getter/setter方法,如果没有方法内联,在调用getter/setter时,程序执行时需要保存当前方法的执行位置,创建并压入用于getter/setter的栈帧、访问字段、弹出栈帧,最后再恢复当前方法的执行。内联了对 getter/setter的方法调用后,上述操作仅剩字段访问。在C2编译器 中,方法内联在解析字节码的过程中完成。当遇到方法调用字节码时,编译器将根据一些阈值参数决定是否需要内联当前方法的调用。如果需要内联,则开始解析目标方法的字节码。比如下面这个示例(来源于网络):

方法内联的过程

public static boolean flag = true;
public static int value0 = 0;
public static int value1 = 1;

public static int foo(int value) {
    int result = bar(flag);
    if (result != 0) {
        return result;
    } else {
        return value;
    }
}

public static int bar(boolean flag) {
    return flag ? value0 : value1;
}

bar方法的IR图:

img

内联后的IR图:

img

4. 最前沿的优化技术之一:逃逸分析

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中杯定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可以为这个变量进行一些高效的优化:

  • 栈上分配:将不会逃逸的局部对象分配到栈上,那对象就会随着方法的结束而自动销毁,减少垃圾收集系统的压力。
  • 同步消除:如果该变量不会发生线程逃逸,也就是无法被其他线程访问,那么对这个变量的读写就不存在竞争,可以将同步措施消除掉(同步是需要付出代价的)
  • 标量替换:标量是指无法在分解的数据类型,比如原始数据类型以及reference类型。而聚合量就是可继续分解的,比如 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);
  }
}

5. Loop Transformations

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

1. 循环展开

循环展开是一种循环转换技术,它试图以牺牲程序二进制码大小为代价来优化程序的执行速度,是一种用空间换时间的优化手段。

循环展开通过减少或消除控制程序循环的指令,来减少计算开销,这种开销包括增加指向数组中下一个索引或者指令的指针算数等。如果编译器可以提前计算这些索引,并且构建到机器代码指令中,那么程序运行时就可以不必进行这种计算。也就是说有些循环可以写成一些重复独立的代码。比如下面这个循环:

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

上面的代码需要循环删除200次,通过循环展开可以得到下面这段代码:

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);
  }
}

这样展开就可以减少循环的次数,每次循环内的计算也可以利用CPU的流水线提升效率。当然这只是一个示例,实际进行展开时,JVM会去评估展开带来的收益,再决定是否进行展开。

2. 循环分离

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

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];
}

这种等效的转换消除了在循环中对a变量的需求,从而减少了开销。

6. 窥孔优化与寄存器分配

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

强度削减

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

编译器使用移位和加法削减乘法的强度,使用更高效率的指令组。

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

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

参考:

基本功 | Java即时编译器原理解析及实践

你了解JVM中的 JIT 即时编译及优化技术吗?

虚拟机随谈(一):解释器,树遍历解释器,基于栈与基于寄存器,大杂烩

Java为什么解释执行时不直接解释源码?

为什么大多数解释器都将AST转化成字节码再用虚拟机执行,而不是直接解释AST?

为什么 JVM 不用 JIT 全程编译?

jvm调优之分层编译

版权

评论