在前端编译器中,“优化”手段主要用于提升程序的编码效率,之所以把javac这类将Java代码变为字节码的编译器称作前端编译器,是因为它只完成了从程序到抽象语法树或中间字节码的生成,而在此之后,还有一组内置于Java虚拟机内部的后端编译器来完成代码优化以及从字节码生成本地机器码的过程,即之前多次提到的即时编译器或提前编译器,这个后端编译器的编译速度及编译结果质量高低,是衡量Java虚拟机性能最重要的一个指标。
本节中提及的即时编译器都是特指HotSpot虚拟机内置的即时编译器,虚拟机也是特指HotSpot虚拟机
目前主流的两款商用Java虚拟机(HotSpot、OpenJ9)里,Java程序最初都是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”,为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。
主流的商用Java虚拟机内部都同时包含解释器与编译器,解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。
HotSpot虚拟机内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别称为客户端编译器和服务端编译器,或者简称为C1编译器和C2编译器。第三个是在JDK10才出现的、长期目标是替代C2的Graal编译器,Graal编译器目前还处于实验状态。
无论采用的编译器是客户端编译器还是服务端编译器,解释器与编译器搭配使用的方式在虚拟机中被称为混合模式。用户也可以控制让虚拟机运行于解释模式,这样编译器不介入工作,全部代码由解释方式执行。也可以强制虚拟机运行于编译模式,这时将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。
为了在程序启动速度与运行效率之间达到最佳平衡,HotSpot虚拟机在编译子系统中加入了分层编译的功能。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次。
在运行过程中会被即时编译器编译的目标是热点代码,主要有两类:
- 被多次调用的方法
- 被多次执行的循环体
对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。
要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为热点探测,其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主流的热点探测判定方式有两种:
- 基于采样的热点探测:周期性地检查各个线程的调用栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是热点方法。
- 基于计数器的热点探测:采用这种方法的虚拟机会为每个方法建立计数器,统计方法的执行此时,如果执行次数超过一定的阈值就认为它是热点方法。
在HotSpot虚拟机中使用的是第二种基于计数器的热点探测方法,为了实现热点计数,HotSpot为每个方法准备了两类计数器:方法调用计数器和回边计数器(回边的意思是指在循环边界往回跳转)。
当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将该方法的调用计数器值加1,然后判断方法调用计数器与回边计数器之和是否超过方法调用计数器的阈值。
执行引擎默认不会同步等待编译请求完成,而是继续进行解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了,整个即时编译的交互过程如下图所示:
方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减,而这段时间被称为此方法的半衰周期。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的。
回边计数器,它的作用是统计一个方法中循环体代码执行的次数。当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就将回边计数器的值加1,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。
默认情况下,无论是方法调用产生的标准编译请求,还是栈上替换编译请求,虚拟机在编译器还未完成编译之前,都仍然将按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行。
在后台执行编译的过程中,对于客户端编译器来说,它是一个相对简单快速的三段式编译器,主要的关注点在于局部的优化,而放弃了许多耗时较长的全局优化手段
- 第一阶段,一个平台独立的前端将字节码构造成一种高级中间代码(HIR)表示
- 第二阶段,一个平台相关的后台从HIR中产生低级中间代码(LIR)表示。
- 第三阶段,在平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器代码。
而服务端编译器则是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器。它会执行大部分经典的优化动作,如无用代码消除、循环展开、常量传播、消除公共子表达式等。
2013年,在Android中使用提前编译的ART横空出世。
现在提前编译产生和对其的研究有着两条明显的分支,一条分支是做与传统C、C++编译器类似的,在程序运行之前把程序代码编译成机器码的静态翻译工作;另外一条分支是把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码(譬如公共库代码在被同一台机器其他Java进行使用)时直接把它加载进来使用。
先说第一条,这是传统的提前编译应用形式,它在Java中存在的加载直指即时编译的最大弱点:即时编译要占用程序运行时间与运算资源。
如果是在程序运行之前进行的静态编译,耗时的优化就可以放心大胆地进行了。这也是ART打败Dalvik的主要武器之一,连副作用也是相似的。在Android 5.0和6.0版本,安装一个稍微大一点的Android应用就得很长时间,以至于从Android 7.0版本起重新启用了解释执行和即时编译(但这与Dalvik无关,它彻底凉了),等空闲时系统再在后台自动进行提前编译。
关于提前编译的第二条路径,本质是给即时编译器做缓存加速,去改善Java程序的启动时间,以及需要一段时间预热后才能达到最高性能的问题。
尽管即时编译在时间和运算资源方面的劣势是无法忽视的,但其依然有自己的优势:
- 性能分析制导优化:如果一个条件分支的某一条路径执行特别频繁,而其他路径鲜有问津,那就可以把热的代码集中放到一起,集中优化和分配更好的资源(分支预测、寄存器、缓存等)给它。
- 激进预测性优化:如果性能监控信息能够支持它做出一些正确的可能性很大但无法保证绝对正确的预测判断,就已经可以大胆地按照高概率的假设进行优化,万一真的走到罕见分支上,大不了退回到低级编译器甚至解释器上去执行,不会出现无法挽救的后果。
- 链接时优化:Java语言天生就是动态链接的,一个个class文件在运行期被加载到虚拟机内存当中,然后在即时编译器里产生优化后的本地代码。
编译器的目标虽然是做由程序代码翻译为本地机器码的工作,但其实难点并不在于能不能成功翻译出机器码,输出代码优化质量的高低才是决定编译器优秀与否的关键。
即时编译器对这些代码优化变化是建立在代码的中间表示或者是机器码之上的,绝不是直接在Java源码上去做的,这里只是笔者为了方便讲解,使用了Java语言的语法来表示这些优化技术所发挥的作用。
先来个简单示例,这是优化前的原始代码:
static class B {
int value;
final int get() {
return value;
}
}
public void foo() {
y = b.get();
// ...do stuff...
z = b.get();
sum = y + z;
}
首先,第一个要进行的优化是方法内联,它的主要目的有两个:一是去除方法调用的成本(如查找方法版本、建立栈帧等);二是为其他优化建立良好的基础。
//内联后的代码
public void foo() {
y = b.value;
// ...do stuff...
z = b.value;
sum = y + z;
}
第二步进行冗余访问消除
public void foo() {
y = b.value;
// ...do stuff...
z = y;
sum = y + z;
}
第三步进行复写传播,因为这段程序的逻辑之中没有必要使用一个额外的变量z,它与变量y是完全相等的,因此我们可以使用y来代替z。
public void foo() {
y = b.value;
// ...do stuff...
y = y;
sum = y + y;
}
第四步进行无用代码消除,可能是永远不会执行的代码,也可能是完全没有意义的代码。
public void foo() {
y = b.value;
// ...do stuff...
sum = y + y;
}
方法内联是编译器最重要的优化手段。除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础。 方法内联的优化行为不过就是把目标方法的代码原封不动地“复制”到发起调用的方法之中,避免发生真实的方法调用而已。但实际上,虚拟机的实现内联过程非常复杂。
对于一个虚方法,编译器静态地去做内联的时候很难确定应该使用哪个方法版本。如有ParentB和SubB是两个具有继承关系的父子类型,并且子类重写了父类的get()方法,那么b.get()是执行父类的get()方法还是子类的get()方法,这应该是根据实际类型动态分派的,而实际类型必须在实际运行到这一行代码时才能确定,编译器很难在编译时得出绝对准确的结论。
为了解决虚方法的内联问题,Java虚拟机首先引入了一种名为类型继承关系分析(CHA)的技术,这是整个应用程序范围内的类型分析技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。编译器进行内联时就会分不同情况采取不同的处理:如果是非虚方法,那么直接进行内联;如果是虚方法,则先向CHA查询此方法在当前程序状态下是否真的有多个目标版本可供选择,如果查到只有一个版本,那就假设应用程序的全貌就是现在运行的这个样子来进行内联,这种内联被称为守护内联。如果加载了导致继承关系发生变化的新类,那么就必须抛弃已经编译的代码,退回到解释状态进行执行,或者重新进行编译。
假如向CHA查询出来的结果是该方法确实有多个版本的目标方法可供选择,那即时编译器还将进行最后一次努力,使用内联缓存的方式来缩短方法调用的开销。
所以,大多数情况下Java虚拟机进行的方法内联都是一种激进优化。
逃逸分析是目前Java虚拟机中比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,,而是为其他优化措施提供依据的分析技术。
逃逸分析的基本原理:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
如果能证明一个对象不会逃逸到方法或线程之外(别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化,如:
- 栈上分配:如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不支持线程逃逸。
- 标量替换:若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。把一个Java对象拆散,根据程序访问额情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
- 同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以完全地消除掉。
下面举个例子来分析一下逃逸分析是如何工作的,下面这些是伪代码:
// 完全未优化的代码
public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getX();
}
第一步,将Point的构造函数和getX()方法进行内联优化:
// 步骤1:构造函数内联后的样子
public int test(int x) {
int xx = x + 2;
Point p = point_memory_alloc(); // 在堆中分配P对象的示意方法
p.x = xx; // Point构造函数被内联后的样子
p.y = 42
return p.x; // Point::getX()被内联后的样子
}
经过逃逸分析,发现在真个test()方法的范围内Point对象实例不会发生任何程序的逃逸,这样可以对它进行标量替换优化,把其内部的x和y直接置换出来,分解为test()方法内的局部变量,从而避免Point对象实例被实际创建,优化后如下:
// 步骤2:标量替换后的样子
public int test(int x) {
int xx = x + 2;
int px = xx;
int py = 42;
return px;
}
通过数据流分析,发现py的值其实对方法不会造成任何影响,那就可以去做无效代码消除得到最终优化结果:
// 步骤3:做无效代码消除后的样子
public int test(int x) {
return x + 2;
}
尽管目前逃逸分析技术仍在发展之中,未完全成熟,但它是即时编译器优化技术的一个重要前进方向,在日后的Java虚拟机中,逃逸分析技术肯定会支撑起一系列更实用、有效的优化技术。
公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术:如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。如果这种优化仅限于程序基本块内,便可以称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除。
例如:
int d = (c * b) * 12 + a + (a + b * c);
其中c*b
和b*c
是一样的表达式,而且再计算期间b与c的值是不变的,所以这条表达式可能被视为:
int d = E * 12 + a + (a + E);
这时编译器还可能进行另外一种优化——代数化简,在E本来就有乘法运算的前提下,把表达式变为:
int d = E * 13 + a + a;
数组边界检查消除是即时编译器中的一项语言相关的经典优化技术。如果有一个数组foo[],在Java语言中访问数组元素f00[i]的时候系统将会自动进行上下界的范围检查,即i必须满足i>=0 && i<foo.length
的访问条件,否则将抛出一个运行时异常:java.lang.ArrayIndexOutOfBoundsException
。
为了安全,数组边界检查肯定是要做的,但是数组边界检查是不是必须在运行期间一次不漏地进行则是可以商量的事情。常见的情况是,数组访问发生在循环之中,并且使用循环变量来进行数组的访问。如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,foo.length)之内,那么在循环中就可以把整个数组的上下界检查消除掉,可以节省很多次的条件判断操作。
Java要做很多检查判断,为了消除这些隐式开销,除了如数组边界检查优化这种尽可能把运行期检查提前到编译器完成的思路之外,还有一种避开的处理思路——隐式异常处理,Java中空指针检查和算术运算中除数为零的检查都采用了这种方案。
//伪代码 虚拟机访问foo.value
if (foo != null) {
return foo.value;
}else{
throw new NullPointException();
}
//隐式异常优化之后
try {
return foo.value;
} catch (segment_fault) {
uncommon_trap();
}
虚拟机会注册一个Segment Fault信号的异常处理器,务必注意这里是指进程层面的异常处理器,并非真的Java的try-catch语句的异常处理器。进入异常处理器的过程涉及进程从用户态转到内核态中处理的过程,结束后会再回到用户态,速度远比一次判空检查要慢得多。当foo极少为空的时候,隐式异常优化是值得的,但加入foo经常为空,这样的优化反而会让程序更慢。幸好HotSpot虚拟机足够聪明,它会根据运行期收集到的性能监控信息自动选择最合适的方案。