字节跳动青训营bitdance tg---- Java虚拟机的即时编译器及优化

72 阅读9分钟

[这是我参与「第四届青训营 」笔记创作活动的第13天]

java程序最初是通过解释器进行解析执行的,当虚拟机发现某个方法或代码块运行的特别频繁时,会把这些代码认定为“热点代码”(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机把这些代码编译成本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(JIT编译器(Just In Time))。

要了解HotSpot虚拟机内的即时编译器的运作过程,要解决几个问题:

  • 为何HotSpot虚拟机要使用解释器和编译器并存的架构?
  • 为何HotSpot虚拟机要实现两个不同的即时编译器?
  • 程序何时使用解释器执行?何时使用编译器执行?
  • 哪些程序代码会被编译成本地代码?如何编译?
  • 如何从外部观察即时编译器的编译过程和编译结果?

解释器与编译器

二者的优势比较:

当程序需要快速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,越来越多的代码被编译成本地代码,可以获取更好的执行效率。解释器比较节约内存,编译器的效率比较高。解释器还可以作为编译器激进优化操作的“逃生门”,当激进优化的假设不成立,就退回到解释状态继续执行。

HotSpot内置了两个编译器,分别是Client Compiler和Server Compiler,或者简称为C1和C2编译器。同时用到两个编译器的分层编译(Tiered Compilation)策略,使用后,C1和C2同时工作,有些代码可能多次编译,用C1获取更高的编译速度,C2获取更好的编译质量:

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

编译对象和触发条件

在运行过程中被即时编译器编译的“热点代码”有两类,即:

被多次调用的方法 被多次执行的循环体 对第一种情况,由于是方法调用触发的编译,因此编译器会以整个方法作为编译对象,即标准的JIT编译方式。后一种,虽然是循环体触发的编译动作,但编译器依然按照整个方法(而不是单独的循环体)作为编译对象。这种编译方式称为栈上替换(On Stack Replacement,简称为OSR编译)。

判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为热点探测(Hot Spot Detection),目前有两种方法:

基于采样的热点探测:采用这样的方法的虚拟机会周期性的检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。其好处就是实现简单、高效,还可以很容易的获取方法调用关系(将调用栈展开即可),缺点是很难精确的确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响。 基于计数器的热点探测:为每一个方法(甚至是代码块)建立计数器,统计方法的执行次数,超过一定的阈值就认为是“热点方法”。缺点是实现起来更麻烦,需要为每个方法建立并维护计数器,并且不能直接获取到方法的调用关系,优点是它的统计结果相对来说更加精确和严谨。

HotSpot虚拟机使用第二种,它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,用于统计一个方法中循环体代码执行的次数)。

以下的说明都是相对与Client Compiler来描述的,由于Server Compiler过于复杂,就不去深究。

方法调用计数器触发即时编译流程:

方法调用计数器触发即时编译

回边计数器触发即时编译流程:

对于上面的计数器一般会在每个Java方法中都会有记录。存放在methodoop中,还有存放这编译器方法from_compiled_entry和解释器方法from_interpreted_entry的入口指针。

编译过程

在默认设置下,无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中运行。用户可以通过设置-XX:-BackgroundCompilation来禁止后台编译,在禁止后台编译后,一旦达到JIT的编译条件,执行线程想虚拟机提交编译请求后会一直等待,直到编译完成后再开始执行编译器输出的本地代码。

对于Client Compiler编译器,是一个简单快速的三段式编译器,主要在于局部性的优化。优化过程如下图所示,进行了三个阶段,分别为HIR、LIR(前端)、LTR(后端)。

编译优化技术

  • 针对某个例子的代码优化

    • 方法内联 的重要性要高于其他优化措施,它的主要目的有两个,一是去除方法调用的成本(如建立栈帧等),二是为其他优化建立良好的基础,方法内联膨胀之后可以便于在更大范围上采取后续的优化手段,从而获取更好的优化效果。 因此,各种编译器一般都会把内联优化放在优化序列的最靠前位置。
    • 冗余访问消除 (Redundant Loads Elimination),假设代码中间注释掉的“dostuff……”所代表的操作不会改变b.value的值,那就可以把“z=b.value”替换为“z=y”,因为上一句“y=b.value”已经保证了变量y与b.value是一致的,这样就可以不再去访问对象b的局部变量了。 如果把b.value看做是一个表达式,那也可以把这项优化看成是公共子表达式消除(Common Subexpression Elimination)
    • 复写传播 (Copy Propagation),因为在这段程序的逻辑中并没有必要使用一个额外的变量“z”,它与变量“y”是完全相等的,因此可以使用“y”来代替“z”
    • 无用代码消除 (Dead Code Elimination)。无用代码可能是永远不会被执行的代码,也可能是完全没有意义的代码
  • 经典优化技术

    • 语言无关的经典优化技术之一:公共子表达式消除。
    • 语言相关的经典优化技术之一:数组范围检查消除。
    • 最重要的优化技术之一:方法内联。
    • 最前沿的优化技术之一:逃逸分析。

方法内联

  • 方法内联的优化行为只是把目标方法的代码“复制”到发起调用的方法之中,避免发生真实的方法调用而已。 但是即时编译器其实还是做了很多工作的,否则无法进行内联 – 因为Java中只有使用invokespecial指令调用的私有方法、实例构造器、父类方法以及使用invokestatic指令进行调用的静态方法才是在编译期进行解析的,除了上述4种方法之外,其他的Java方法调用都需要在运行时进行方法接收者的多态选择,并且都有可能存在多于一个版本的方法接收者(final方法使用invokevirtual指令调用,但也是非虚方法),简而言之, Java语言中默认的实例方法是虚方法 。对于一个虚方法, 编译期做内联 的时候根本无法确定应该使用哪个方法版本。
  • 为了解决虚方法的内联问题,Java虚拟机引入了”类型继承关系分析”(Class Hierarchy Analysis,CHA)技术,其基于整个应用程序的类型进行分析,用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类、子类是否为抽象类等信息。编译器在进行内联时,如果是 非虚方法,那么直接进行内联 就可以了,这时候的内联是有稳定前提保障的。如果遇到 虚方法,则会向CHA查询此方法在当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,那也可以进行内联,不过这种内联就属于激进优化 ,需要预留一个“逃生门”(Guard条件不成立时的Slow Path),称为守护内联(Guarded Inlining)。如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码就可以一直使用下去。但如果加载了导致继承关系发生变化的新类,那就需要抛弃已经编译的代码,退回到解释状态执行,或者重新进行编译。
  • 如果向CHA查询出来的结果是有 多个版本的目标方法可供选择 ,则编译器会使用 内联缓存(Inline Cache)来完成方法内联,这是建立在目标方法正常入口之前的缓存,它的工作原理大致是:在未发生方法调用之前,内联缓存状态为空,当 第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者版本 ,如果以后进来的每次调用的方法接收者版本都是一样的,那这个内联还可以一直用下去。如果方法接收者 不一致 – 说明程序真正使用了 虚方法的多态特性,这时才会取消内联,查找虚方法表进行方法分派 。

\