后端编译:编译期把class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码的过程
后端编译器性能的好坏、代码优化质量的高低是衡量一款商用虚拟机优秀与否的关键指标之一。
热点代码:Java程序通过解释器进行解释执行时,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),
即时编译器:为了提高热点代码的执行效率,在运行时,虚拟机将会把这些热点代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。
**解释器:**解释器是一行一行地将字节码解析成机器码,解释到哪就执行到哪,狭义地说,就是for循环100次,你就要将循环体中的代码逐行解释执行100次。
即时编译器(JIT):以方法为单位,将热点代码的字节码一次性转为机器码,并在本地缓存起来的工具。避免了部分代码被解释器逐行解释执行的效率问题。
解释器还可以作为编译器激进优化时后备的“逃生门”:
在编译阶段,如果发现激进优化的假设不成立,如加载了新类以后,类型继承结构出现变化、出现“罕见陷阱”(Uncommon Trap)时可以通过逆优化(Deoptimization)退回到解释状态继续执行,
由上图可知:在整个Java虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作
在JDK7之后引入的默认编译策略——分层编译:
分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:
实施分层编译后:
编译对象:热点代码主要包括两类:
对于上面两种情况,编译的目标对象都是整个方法体;
对于循环体,虽然其只是方法的一部分,但编译器依然必须以整个方法作为编译对象,只是执行入口(从方法第几条字节码指令开始执行)不同,编译时会传入执行入口点字节码序号(Byte Code Index,BCI)。
这种编译方式因为编译发生在方法执行的过程中,因此被很形象地称为栈上替换(On Stack Replacement,OSR),即方法的栈帧还在栈上,方法就被替换了。
要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”。
热点探测的两种方式:
基于采样的热点探测:周期检查每个线程栈顶,统计哪个方法出现次数多,但是不准确
容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
基于计数器的热点探测:Hotspot虚拟机目前在用,为每个方法建立计数器,统计方法的调用次数。计数器分为方法调用计数器(默认阈值C1是1500次,C2是1w,到达阈值则触发即时编译,可通过参数调整)和回边计数器(统计一个方法中循环体的执行次数)。
需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但计算结果准确
方法调用即时编译的流程:
如果没有做过任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了,整个即时编译的交互过程如图11-3所示。
回边计数即时编译的流程:
当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求, 并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,整 个执行过程如图11-4所示:
方法调用计数器默认不是方法被调用的绝对次数
在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一段时间之内方法被调用的次数:
回边计数器是方法被调用的绝对次数
与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
在默认条件下,虚拟机在编译器还未完成编译之前,都仍然将按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行。
可以通过参数-XX:-BackgroundCompilation来禁止后台编译,后台编译被禁止后,当达到触发即时编译的条件时,执行线程向虚拟机提交编译请求以后将会一直阻塞等待,直到编译过程完成再开始执行编译器输出的本地代码。
客户端编译器的编译过程——简短的三段式编译器:
在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR,即与目标机器指令集无关的中间表示)。
在此之前编译器已经会在字节码上完成一部分基础优化,如方法内联、 常量传播等优化将会在字节码被构造成HIR之前完成。
在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation,LIR,即与目标机器指令集相关的中间表示)
在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。
最后的阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。
服务端编译器的编译过程:
服务端编译器则是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器
大部分的优化动作在服务端编译器上都能实现:
提前编译器(Ahead Of Time Compiler,AOT编译器):直接把程序编译成与目标机器指令集相关的二进制代码的过程。目前有两种主要的实现方式:
两种方式的优点:
但是即时编译相比于提前编译也有很多优点:
方法内联的优化行为理解起来是没有任何困难的,不过就是把目标方法的代码原封不动地“复 制”到发起调用的方法之中,避免发生真实的方法调用而已。
主要目的:
,所以,按照经典编译原理的优化理论,大多数的Java方法都无法进行内联。
由于Java中大多数的方法是虚方法,由于动态链接的规则,所以按照经典编译原理的优化理论,大多数的Java方法在编译期都无法进行内联。
对于一个虚方法,编译器静态的去做内联的时候很难确定应该使用哪个方法版本?
为了解决虚方法的内敛问题,Java虚拟机引入了**类型继承关系分析(Class Hierarchy Analysis,CHA)**技术。主要用于确定整个应用程序范围内,目前已加载的类中,某个接口是否有多于一种实现、某个类是否存在子类、某个子类是否覆盖了父亲的某个虚方法等信息。
如果是非虚方法,那么直接进 行内联就可以了,这种的内联是有百分百安全保障的;
如果是虚方法,则会向CHA查询此方法在当前程序状态下是否真的有多个目标版本,如果查询只有一个版本,那么就可以假设“应用程序的全貌就是现在运行的这个样子”来进行内联,这种内联被称为守护内联(Guarded Inlining)。
不过由于Java程序动态连接的特性,可能在运行过程中会加载新的类型改变CHA结论,因此这种内联属于激进预测性优化,必须预留好逃生门,即当假设条件不成立时的退路。
如果在过程中加载了导致继承关系发生变化的新类,那么就必须抛弃已经编译的代码,退回到解释状态进行执行,或者重新进行编译。
如果是多个版本的,编译器会使用内联缓存(Inline Cache)的方式来缩减方法调用的开销:内联缓存是一个建立在目标方法正常入口之前的缓存,它的工作原理大致为:
在Java虚拟机中运行方法内敛多数情况下是一种激进优化
逃逸分析的基本原理:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
根据逃逸程度,可能为这个对象实例采取不同程度的优化,如:
栈上分配:如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。
标量替换:
标量:一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。
聚合量:如果一个数据可以继续分解,那它就被称为聚合量。Java 中的对象就是典型的聚合量。
如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。
假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。
同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉。
公共子表达式消除的原理:如果一 个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E 的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直 接用前面计算过的表达式结果代替E。
假设存在如下代码:
int d = (c * b) * 12 + a + (a + b * c)
如果这段代码交给Javac编译器则不会进行任何优化,那生成的代码将如代码清单11-12所示,是完全遵照Java源码的写法直译而成的。
iload_2 // b
imul // 计算b*c
bipush 12 // 推入12
imul // 计算(c * b) * 12
iload_1 // a
iadd // 计算(c * b) * 12 + a
iload_1 // a
iload_2 // b
iload_3 // c
imul // 计算b * c
iadd // 计算a + b * c
iadd // 计算(c * b) * 12 + a + a + b * c
istore 4
当这段代码进入虚拟机即时编译器后,它将进行如下优化
int d = E * 12 + a + (a + E);
这时候,编译器还可能(取决于哪种虚拟机的编译器以及具体的上下文而定)进行另外一种优化 ——代数化简(Algebraic Simplification),在E本来就有乘法运算的前提下,把表达式变为:
int d = E * 13 + a + a;
由于Java语言是一门动态安全检查的语言,对于数组foo[],访问数组元素foo[i]的时候系统会自动进行上下界范围检查,即i必须满足i>=0 && i
有时数组边界检查不是必须继续进行的这些情况下就可以省略:
隐式异常处理:
数组边界检查的例子放在更高的视角来看,大量的安全检查使编写Java程序比编写C和 C++程序容易了很多。例如系统会提醒我们的各种异常。但这些安全检查也导致出现相同的程序, 从而使Java比C和C++要做更多的事情(各种检查判断),这些事情就会导致一些隐式开销。
为了消除这些隐式开销,有一种避开的处理思路—— 隐式异常处理,Java中空指针检查和算术运算中除数为零的检查都采用了这种方案
举个例子,程序中访问一个对象(假设对象叫foo)的某个属性(假设属性叫value),那以Java伪代码来表示虚拟机访 问foo.value的过程为:
if (foo != null) {
return foo.value;
}else{
throw new NullPointException();
}
在使用隐式异常优化之后,虚拟机会把上面的伪代码所表示的访问过程变为如下伪代码:
try {
return foo.value;
} catch (segment_fault) {
uncommon_trap();
}
虚拟机会注册一个Segment Fault信号的异常处理器(伪代码中的uncommon_trap(),务必注意这里是指进程层面的异常处理器,并非真的Java的try-catch语句的异常处理器)。