JVM:后端编译优化

即时编译器

HotSpot 中的即时编译器有3种,分别称为客户端编译器(Client Complier) 和服务端编译器 (Server Complier),简称为 C1 和 C2,还有一个在JDK10时出现的Graal编译器,长期目标是代替服务端编译器。

程序使用哪个编译器取决于虚拟机运行模式,虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式。也可以使用 “-clien”或 “-server” 参数去强制指定编译器。

目前虚拟机一般采用解释器和一个即时编译器直接配合的方式来运行,称为 混合模式。

也可以使用参数 “-Xint” 强制虚拟机运行“解释模式”,这时候编译器完全不介入工作,全部代码都使用解释方式执行。

也可以使用参数 “-Xcomp”强制虚拟机运行于 “编译模式”,这时将优先采用编译方式执行,但是解释器仍然要在编译器无法进行的情况下介入执行过程。

可以使用虚拟机的 “version”命令的输入结果显示这三种模式

 

一:分层编译

由于即时编译器 编译本地代码需要占用程序时间,而且编译出优化程度越高的代码话费的时间越长,同时,解释器可能还 要提编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。为了达到程序启动响应速度和运行效率之间的最佳平衡,在JDK6后,虚拟机在编译子系统中加入了分层编译的功能。

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

第0 层:程序纯解释执行,且解释器不开启性能监控功能

第1层:使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化。

第2层:仍然客户端编译器执行。仅开启方法及 回边次数统计 等有限的性能监控功能

第3层:仍然客户端编译器执行。开启全部性能监控,并且还会收集分支跳转,虚方法调用版本等全部的统计信息

第4层:使用服务端编译器将字节码编译为本地代码。相比较客户端编译器,会启用更多耗时更长的编译优化。还会根据性能监控信息进行一些不可靠的激进优化。

 

二:编译对象

即使编译目标是热点代码,热点代码主要分为2类:

被多次调用的方法

被多次执行的循环体 (一个方法只被调用一次或少量几次,但是方法内部存在循环次数较多的循环体,尽管热点只是方法一部分,但编译器依然以整个方法作为编译对象)

 

三:触发条件(热点探测)

基于采样的热点探测

周期性的检查栈顶,如果一段代码频繁出现在栈帧顶部,那么就判断其是热点代码

优点:实现简单,快;

缺点:探测很容易收到线程阻塞的影响。例如一个方法因为线程阻塞,一直在栈顶,但其实其执行次数并不多,那么将其判定为热点代码就是不合理的

 

基于计数器的热点探测

为每个方法甚至是代码块建立计数器来统计执行次数,如果统计的次数达到了一定的条件则说明是热点代码

优点:结果精确

缺点:实现就比较麻烦了,需要维护计数器

 

HotSpot 虚拟机采取的是基于计数器的热点探测方法

为了实现热点计数,虚拟机为每个方法准备了两类计数器,都有一个明确的阀值,溢出就会触发即时编译

1:方法调用计数器

方法调用计数器用于统计方法被调用的次数。它的默认阀值在客户端模式下是 1500次,服务端模式下是10000次,可以通过虚拟机参数 -XX:CompileTreshold来配置。

运行流程:

当一个方法被调用时,虚拟机会线检查该方法是否存在被即时编译过的版本。

如果存在,优先使用编译后的本地代码来执行。

如果不存在,就将该方法的调用计数器值+1,然后判断方法调用计数器与回边计数器值的和 是否超过 方法调用计数器的阀值。若超过,就向即时编译器提交该方法的代码编译请求。

如果没有做个任何设置,该方法继续进入解释器按照解释执行方式执行字节码,直到提交的请求被即时编译器编译。

编译完成后,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本。

 

热度衰减和半衰周期

方法调用计数器统计的并不是方法被调用过的绝对次数,而是一个相对频率,就是一段时间内被调用的次数。

当超过一定的时间限度,如果方法的调用次数仍然不足让它提交给即时编译器编译,那该方法调用计数器就会被减少一半, 这个过程被称为 方法调用计数器热度的衰减,而这段时间就称为 此方法统计的半衰周期。

进行热度衰减的动作是在虚拟机进行垃圾收集是顺便进行的。

可以通过虚拟机参数 -XX-UseCounterDecay来关闭热度衰减。

也可以通过参数 -XX:CounterHalfLifeTime 设置半衰周期的时间。

 

 

2:回边计数器

统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的执行就称为 “回边”

回边计数器阀值计算公式

虚拟机运行在客户端模式下:

方法调用计数器阀值 * OSR比率 / 100 ,OSR默认值为933.

虚拟机运行在服务端模式下:

(方法调用计数器阀值 * OSR比率 - 解释器监控比率)/100,OSR默认值为140,解释器监控比率默认值为33

 

运行流程:

解释器遇到一条回边指令时,会线查找将要执行的代码片段是否有已经编译好的版本。

如果有,会优先执行已编译的代码。否则就把回边计数器的值+1.

然后判断方法调用计数器与回边计数器的和是否超过回边计数器的阀值。

超过阀值时,就会提交一个栈上替换编译请求,并且把回边计数器的值稍微降低。以便继续在解释器中执行循环,然后等待输出编译结果。

 

四:编译过程

客户端编译器和服务端编译器的编译过程是有区别的

客户端编译过程

客户端编译器是一个简单的三段式编译器,主要关注 局部性的优化,放弃了很多耗时较长的全局优化手段。

第一阶段:平台独立的前端将字节码转成HIR,HIR使用了静态单分配的形式来代表代码值,方法内联等可以在这过程中完成。

第二阶段:平台的后端将HIR转成LIR,在转化过程前就是将HIR转成优化后的HIR会完成空值检查消除和范围检查消除等方法。

最后阶段:平台的后端使用线性扫描算法在LIR分配寄存器,例如寄存器分配等生成机器代码。

 

服务端编译过程

服务端编译采用寄存分配器,它是一个全局图着色分配器,可以充分利用某些处理器架构上的大寄存器集合。

相对于客户端编译器编译出的代码质量有很大提高,可以大幅减少本地代码的执行时间,从而抵消掉额外的编译时间开销

它也是一个能容忍很高优化复杂度的高级编译器,它可以执行大部分经典的优化动作

如: 无用代码消除,循环展开,循环表达式外体,消除公共子表达式,常量传播,基本块重排序。

还会实施一些与Java相关的优化技术,如:范围检查消除,空值检查消除。

还可能根据解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化,如守护内联,分支频率预测

 

五:编译优化:

逃逸分析

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用

例如作为调用参数传递到其他地方中,称为方法逃逸。如果能证明一个对象不会逃逸到方法或线程外,则可能为这个变量进行一些高效的优化。

优化

栈上分配

我们都知道Java中的对象都是在堆上分配的,而垃圾回收机制会回收堆中不再使用的对象,但是筛选可回收对象,回收对象还有整理内存都需要消耗时间。如果能够通过逃逸分析确定某些对象不会逃出方法之外,那就可以让这个对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

在一般应用中,如果不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了。

标量替换

Java虚拟机中的原始数据类型(int,long等数值类型以及reference类型等)都不能再进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解,那它称为聚合量,Java中最典型的聚合量是对象。如果逃逸分析证明一个对象不会被外部访问,并且这个对象是可分解的,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。拆散后的变量便可以被单独分析与优化, 可以各自分别在栈帧或寄存器上分配空间,原本的对象就无需整体分配空间了。

同步消除

线程同步本身比较耗,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,对这个变量的同步措施就可以消除掉。单线程中是没有锁竞争。

 

开启和关闭逃逸分析: 开启逃逸分析,对象没有分配在堆上,没有进行GC,而是把对象分配在栈上。 关闭逃逸分析,对象全部分配在堆上,当堆中对象存满后,进行多次GC,导致执行时间大大延长。堆上分配比栈上分配慢上百倍。

 

方法内联

方法内联就是把被调用方函数代码”复制”到调用方函数中,来减少因函数调用开销的技术

JIT根据以下信息决定是否进行内联:

被调用方法是否是热点方法。这个取决于该方法被调用的次数,次数阈值默认值为10,000。即运行时被调用次数超过10,000的方法,可以被认为是hot

被调用方法大小是否合适,方法大小阈值由-XX:FreqInlineSize指定。大于这个阈值size的方法,不考虑进行内联

被调用方法运行时其实现是否可以唯一确定。对于类方法、私有方法和final方法,JIT是可以唯一确定它们的具体实现代码的。另一方面,对于public方法调用,它所指向的具体实现可能是自身、父类、子类的方法实现代码(多态),只有当JIT能唯一确定方法的具体实现时,才有可能完成内联

 

公共子表达式消除

如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。对于这种表达式,没有必要花时间再对他进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除(Local Common Subexpression Elimination)

如果这种优化范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination)

 

数组边界检查消除

系统将自动进行数组上下界的范围检查

隐式异常处理:Java中空指针和算术运算中除数为零的检查。此外还有:自动装箱消除、安全点消除、消除反射等等

 

冗余访问消除

冗余访问消除(Redundant Loads Elimination)指的是如果能保证一个方法的两次调用之间的代码不会引起其返回值的更改,那么这第二次调用的结果可以直接用第一次调用结果去赋值

 

守护内联

由于 Java 语言提倡使用面向对象的编程方式进行编程,而 Java 对象的方法默认就是虚方法

为了解决虚方法的内联问题,Java 虚拟机设计团队想了很多办法,首先是引入了一种名为 “类型继承关系分析”(Class Hierarchy Analysis,CHA)的技术,这是一种基于整个应用程序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类、子类是否为抽象类等信息。

编译器在进行内联时,如果是非虚方法,那么直接进行内联就可以了,如果遇到虚方法,则会向 CHA 查询此方法在当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,那也可以进行内联,不过这种内联就属于激进优化,需要预留一个 “逃生门”(Guard 条件不成立时的 Slow Path),称为守护内联。如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码就可以一直使用下去。但如果加载了导致继承关系发生变化的新类,那就需要抛弃已经编译的代码,退回到解释状态执行,或者重新进行编译。

 

 

你可能感兴趣的:(JVM的逐步渗透)