如果我们将字节码看作是程序语言的一种中间表示形式,那编译器无论在何时、在何种状态下把Class文件转换成与本地基础设施相关的二进制机器码,它都可以视为整个编译过程的后端。
1 即时编译器
即时编译器是指运行时将热点代码编译成本地机器码,并以各种手段尽可能地进行代码优化的后端编译器。
1.1 解释器与编译器
1.1.1 解释器
将字节码解释成具体平台的机器码。
其主要作用是:
1)当程序需要迅速启动或执行时,解释器可以首先发挥作用,省去编译的时间。
2)节约内存。
3)作为编译器激进优化时后备的逃生门。
1.1.2 编译器
程序启动后,随着时间推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,减少解释器的中间损耗,获得更高的执行效率。
HotSpot虚拟机中内置两个(或三个)即时编译器,其中两个分别为“客户端编译器”(Client Compiler)简称为C1编译器和“服务端编译器”(Server Compiler)简称为C2编译器,第三个是JDK10才出现的,长期目标是代替C2点Graal编译器。
1.1.3 分层编译
为了在程序启动响应速度和运行效率之间达到最佳平衡,HotSpot虚拟机在编译子系统中加入了分层编译的功能。在JDK 7 服务端模式虚拟机中作为默认编译策略被开启。
第0层。程序纯解释执行,并且解释器不开启性能监控功能。
第1层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
第2层。仍使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
第3层。仍使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部等统计信息。
第4层。使用服务端编译器将字节码编译为本地代码,启用更多耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。
1.2 编译对象与触发条件
1.2.1 编译对象
会被即时编译器编译的目标是“热点代码”,主要由两类:1)被多次调用的方法。2)被多次执行的循环体。
1.2.2 触发条件
判断某段代码是不是热点代码,是否触发即时编译的这种行为称为“热点探测”。进行热点探测并不一定要知道方法具体被调用了多少次,目前主流的热点探测判定方式有两种:
1)基于采样的热点探测。虚拟机会周期性地检查各个线程的调用栈顶,如果发现某些方法经常出现在栈顶,那这个方法就是“热点方法”。
该方法实现简单高效,还可以很容易地获取方法调用关系,缺点是很难精确地确认一个方法的热度,容易受到线程阻塞的影响。
2)基于计数器的热点探测。为每个方法(甚至是代码块)建立计数器,统计方法的执行次数。
该方法实现起来要麻烦些,但是统计结果相对来说更加精确严谨。
方法调用计数器并不是方法被调用的绝对数,而是一个相对的执行频率。当超过一定时间限度,如果次数仍不足,那该方法的调用计数器就会被减少一半。这个过程被称为计数器热度的衰减,而这段时间被称为半衰周期。
1.3 编译过程
在默认条件下,虚拟机在编译器还未完成编译之前,都仍然将按照解释器方式继续执行代码,而编译动作则在后台的编译线程中进行。
服务端编译器和客户端编译器的编译过程是有所差别的。
1.3.1 客户端编译器
是一个相对简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。
图 客户端编译器大致的执行过程
第一阶段:将字节码完成一部分基础优化,如方法内联、常量传播等。然后构造成一种高级中间代码表示(HIR,即与目标机器指令集无关的中间表示)。HIR使用静态单分配(SSA)的形式来代表代码值。
第二阶段:在HIR上完成另一些优化,如空值检查消除、范围检查消除等。然后产生低级中间代码表示(LIR,即与目标机器指令集相关的中间表示)。
最后阶段:使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器代码。
1.3.2 服务端编译器
是专门面向服务端的典型应用场景,并为服务器性能配置针对性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器。
会执行大部份经典优化动作,还可能根据解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化。
2 编译器优化技术
编译器的工作,难道不在于能否成功翻译出机器码,而在于输出代码优化质量的高低。
2.1 方法内联
最重要的优化技术之一。被称为优化之母,除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础。
方法内联是把目标方法的代码原封不动地“复制”到发起调用到方法中。
public class MethodInline {
static void method1() {
System.out.println("method1");
}
static void method2() {
System.out.println("method2");
method1();
}
}
上面代码经过方法内联后变为:
static void method2() {
System.out.println("method2");
System.out.println("method1");
}
在实际的内联过程,因为Java虚方法都必须在运行时进行方法接收者都多态选择,它们都可能存在多于一个版本的方法接收者。
2.1.1 类型继承关系分析(CHA)
为了解决虚方法的内联问题,Java虚拟机首先引入类型继承关系分析技术,范围为整个应用程序。用于确定目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。
图 Java虚方法内联
2.2 逃逸分析
最前沿的优化技术之一。并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。
基本原理:分析对象动态作用域,当一个对象在方法里被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至可能被外部线程访问到,称为线程逃逸。
如果证明一个对象不会逃逸到方法或线程之外,则可能为这个对象实例采取不同程度的优化,如:
- 栈上分配。如果确定一个对象不会逃逸出线程之外,那可以让这个对象在栈上分配内存(平常在堆中存储对象数据)。对象所占用到内存空间可以随栈帧出栈而销毁。栈上分配支持方法逃逸而不支持线程逃逸。
- 标量替换。一个数据已经无法再分解成更小的数据来表示了,称为标量,相反则称为聚合量。如果一个对象不会被方法外部访问,并且这个对象可被拆散,那么程序真正执行的时候将可以不去创建这个对象,而改用直接创建它的若干个被这个方法使用的成员变量来代替。
- 同步消除。线程同步本身是一个相对耗时的过程,如果一个变量不会逃逸出线程,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施就可以安全地消除掉。
2.3 公共子表达式消除
语言无关的经典优化技术之一。含义是:如果一个表达式E之前已经被计算过,并且从先前计算到现在E中所有变量到值都没发生变化,那么E被称为公共子表达式。
int d = (c * b) * 12 + a + b * c。
优化过程为:
c * b 与 b * c是等价的,且这这段过程中,c与b的值没有发生变化。所以c * b 为公共表达式,设c * b = E,则经过优化的代码为:
int = E * 12 + a + E。
2.4 数组边界检查消除
语言相关的经典优化技术之一。Java每次数组元素的读写都带有隐含的条件判断操作(如检查下标是否合法)。
虚拟机会注册一个Segment Fault信号的异常处理器,这样,当下标合法时,对数组的访问不会有任何额外的开销,当不合法时,必须转到异常处理器中恢复中断并抛出NullPointException异常。此时速度远比上面的隐含条件判断操作慢得多。
所以假如下标经常不合法,这样的优化反而会让程序更慢。HotSpot会根据运行期收集到的性能监控信息自动选择合适的方案。