JVM - JIT即时编译器

Posted 一点代码

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM - JIT即时编译器相关的知识,希望对你有一定的参考价值。


点击上方蓝色“一点代码”,选择“设为星标”
更方便获取学习资源!


常规情况下,我们使用javac.exe编译的代码是依赖JVM的解释器去解释执行的,但是在某些情况下,JVM也会将其直接编译成平台相关的代码,以此来提升执行效率。 这就是解释执行与编译执



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

即时编译器并不是虚拟机必要的部分,Java虚拟机没有规定必须存在即时编译器。但是即时编译器的性能好坏、代码优化程度,是虚拟机优秀与否的关键指标之一。 

在深入了解解释执行与编译执行之前,我相信你肯定会有以下疑问:
  • 为何HotSopt虚拟机要使用解释器与编译器并存的架构?
  • 为何HotSpot虚拟机要实现两个不同的即时编译器?
  • 程序何时使用解释器执行?何时使用编译器执行?
  • 哪些程序代码会被编译为本地代码?如何编译为本地代码?
  • 如何从外部观察即时编译器的编译过程和编译结果?


解释器与编译器

解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间(这里的编译指的是编译为本地代码),立即执行。
在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。

当程序运行环境中内存资源限制较大(如嵌入式系统),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。

同时,解释器还可以根据概率选择一些大多数时候都能提升运行速度的优化手段,当优化手段假设不成立,比如加载了新类后类型继承结构出现了变化、出现罕见陷阱(Uncommon Trap)时可以通过逆优化退回到解释状态继续执行。
因此,在整个虚拟机执行架构中,解释器与编译器经常配合工作, 如下图:

HotSopt虚拟机中内置了两个即时编译器,分别称为Client Compiler与Server Compiler(在虚拟机中习惯将Client Compiler称为C1,Server Compiler称为C2),程序使用哪一种即时编译器,取决于程序运行的模式。
用户也可以使用-client或-server参数去强制指定虚拟机运行在Client模式还是Server模式。

无论采用的是Client模式还是Server模式,解释器与编译器搭配使用的方式在虚拟机中称为混合模式(Mixed Model,JDK1.8 HotSopt默认),用户可以使用参数-Xint强制虚拟机运行于解释模式,这时即时编译器完全不会介入工作,全部代码都以解释方式执行。

另外,也可以使用参数-Xcomp强制虚拟机运行于编译模式,这时将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的时候介入执行过程。
可以通过-version命令输出结果显示出这三种模式,如java -version打印的结果(加粗部分表示为混合模式):

JVM - JIT即时编译器



编译对象与触发条件

在运行过程中会被即时编译器编译的热点代码有两类:
  • 被多次调用的方法
  • 被多次执行的循环体
    
前者很好理解,一个方法被调用多次,方法内的代码自然也被执行多次,因此理所当然的是热点代码。
而后者,可能一个方法被调用的次数就只有一次或者少量的几次,但是方法内存在循环次数较多的循环体问题,这样的循环体代码也被执行了多次,因此这些代码也被称为热点代码。

对于第一种情况,由于是方法调用触发的即时编译,因此编译器是理所当然的以整个方法作为编译对象,这种编译方式也是JIT中标准的编译方式。
而对于后一种情况,尽管编译动作是有循环体代码块触发的,但编译器仍然会以整个方法(而不是单独的循环体代码块)作为编译对象。
这种编译方式因为编译发生在方法执行过程之中,因此形象的称之为栈上替换(On Stack Replacement,简称OSR编译,即方法栈帧还在栈上,就被替换了)。

JIT如何判断一个方法或者代码块被执行“多次”?如何才算是多次执行?虚拟机如何统计一个方法被执行了多少次?(也就是即时编译器被触发的条件)
判断一个代码是不是热点代码,是不是需要触发即时编译,这样的行为称为“热点探测”,其实热点探测并不一定要知道方法具体被调用了多少次,目前主要的热点探测判定方式有两种,分别如下:

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

悄悄的说,在HotSpot中采用的就是第二种热点探测方式

编译过程

一般情况下,不论是方法调用过程产生的即时编译请求,还是OSR编译请求,虚拟机在代码还未编译完成之前,都会继续以解释器的方式执行代码, 而编译动作则在后台的编译线程中进行。
可以通过参数-XX:-BackgroundCompilation来禁止后台编译,在禁止后,执行线程向即时编译器提交编译请求后将会一直等待,直到编译完成再开始执行编译器输出的本地代码。


优化技术

在JIT将字节码编译为本地代码的过程中,可能会针对部分代码进行一定程度的优化(字节码优化),掌握这部分优化场景会帮助写出更加利于JIT编译出的高效代码。
具体的优化技术有很多,感兴趣的朋友可以针对JIT字节码优化去专门研究学习,下面将列出较为贴合我们日常开发的几项优化手段:

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

如果一个表达式E已经计算过了,并且从先前的计算到现在E中的所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。
对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。
假设有以下表达式:
int d = (c * d) * 12 + a + (a + b * c)
如果交给javac去编译,那么编译后的字节码与程序源码不会有什么差别,但是当交给即时编译器之后,它将进行如下优化:
编译器检测到c * b与b * c是一样的表达式,而且在计算期间b与c的值都是不变的,因此这条表达式很可能被优化为:
int d = E * 12 + a + (a + E)
某些情况下,编译器还可能进行另外一种优化,即代数化简,把表达式变为:
int d = E * 13 + a * 2
表达式进行变换之后,再计算起来就可以节省一些时间了。


二. 数组边界检查消除

这是即时编译器一种语言相关的优化技术,对于虚拟机来说,每次对数组元素的读写,都包含一次隐含的条件判断操作,对拥有大量数组访问的程序代码,这无疑是一种性能负担。
即时编译器在优化数组边界检查消除采用以下手段:在编译期间根据数据流分析获取数组的length值,并判断当前代码中的数组下标是否超过了length值,如果没有,则没必要再进行隐式的判断了。
更加常见的是在循环中使用循环变量去遍历数组,同样,编译器也会使用编译期间的数据流分析计算出当前循环变量的取值范围(比如循环的总次数是否会大于数组的length),如果循环迭代量取值永远位于0~数组length之间,则也会消除数组上下界检查。


除了将数组越界检查提到编译期完成的思路之外,还有另外一种隐式异常处理,Java中空指针检查和算数运算除零异常都是用这种方式。
假设虚拟机需要执行的有以下代码:
// 原代码,安全的判断对象foo是否为空if(foo != null) { return foo.value;} else { throw new NullPointerException();}// 在使用隐式异常优化之后,虚拟机会访问过程变成如下:try { return foo.value;} catch (Exception e) {    // skip}
这样当foo不为空的时,就节省掉了一次空判断的过程,代价就是当foo真的为null时,必须转入到异常处理中回复并抛出一个NullPointerException异常。
这个过程必须从用户态转到内核态,结束后再回到用户态,速度比一次判空检查慢。
但当foo极少为空的时候,这种优化是值得的。HotSpot虚拟机会自动收集信息选择最优的优化方案。

三. 方法内联(最重要的的优化技术之一)

消除方法调用成本,也是为其他优化技术建立了良好的基础。
方法内联行为看上去实际上就是将目标方法的代码“复制”到调用的方法之中,避免发生真实的方法调用。
可以简单理解为:A方法调用B方法,在优化阶段可能会直接将B方法的代码“复制”一份到A方法实际调用B方法的位置。避免了一次方法调用过程。

而Java中的虚方法是可以出现多态的(也就是你准备“复制”的方法可能是多态方法,存在多个对象的不同实现),Java虚拟机会使用 类型继承关系分析(Class Hierarchy Analysis,CHA)技术,这是一种基于整个应用程序的类型分析技术,它会确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、子类是否为抽象类等信息。

编译器在进行内联时,如果是非虚方法(则不可多态的方法,比如静态方法,final方法、私有、构造器方法),会直接进行内联。
否则,会向CHA查询此方法在当前程序下是否有多个目标方法可供选择,如果查询的版本只有一个,那也将进行内联。
这种手段属于激进优化,需要预留一个后门,也就是当在后续运行过程中方法的版本增加(比如新的类继承了该接口,并且产生了其他版本的方法实现),那么将立马退回到解释运行模式,从虚方法表中进行方法分派。这种内联也称为守护内联。
(关于方法静态分派与动态分派由于篇幅原因此处不做赘述)

四. 逃逸分析(最前沿的优化技术之一)

同类继承关系分析一样,并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。
逃逸分析的基本行为就是分析对象的动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为参数传递到其他方法中,称为方法逃逸。
甚至还可能被其他外部线程访问到,比如复制给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

逃逸分析是理论,基于逃逸分析之后强有力的依据,将可能做出以下性能优化:

  • 栈上分配:Java虚拟机中,Java对象通常都是分配在堆上,所有线程共享,由虚拟机垃圾收集来回收这些不使用的对象,但是回收动作不论是收集清除还是整理内存都需要耗费一定的时间。如果能确定一个对象不会逃逸出方法之外,那么这个对象将可能在栈上分配,随着方法栈的出栈而销毁该对象。垃圾收集系统的压力将会大大减小。
  • 同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能确定一个变量不会逃逸出线程,无法被其他线程访问到,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉。
  • 标量替换:标量是指一个数据已经无法在分解成更小的数据来表示了,如int、long、reference类型等都无法被再进一步的分解,它们被称为标量。相对的,如果一个数据可以被继续分解,那么它就被称为聚合量,比如一个对象。
    把一个对象拆散,根据程序访问情况,将其使用到的成员变量恢复原始类型来访问(也就是不需要使用这个对象来访问这个成员变量,单独提取出来)就称为标量替换。

如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以拆散的话,那程序真正执行起来可能不会创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员的变量来代替。 对象拆分后,除了可以让成员变量在栈上分配,也为了后续进一步优化提供了条件。


JVM参数
-XX:DoEscapeAnalysis 开启逃逸分析
-XX:PrintEscapeAnalysis 查看分析结果
-XX:+EliminateAllocations 开启标量替换
+XX:+EliminateLocks 开启同步消除
-XX:+PrintEliminateAllocations 查看标量替换情况



如果觉得本文给您提供了帮助,请点击转发并关注获取更多内容





以上是关于JVM - JIT即时编译器的主要内容,如果未能解决你的问题,请参考以下文章

JVM 虚拟机 AOT 和 JIT 即时编译

JVM之JIT即时编译

JVM中的 JIT 即时编译及优化技术

Java 面试——即时编译( JIT )

JVM实用参数2:参数分类和即时(JIT)编译器诊断

jit即时编译