概述
即时编译器
Java程序最初都是通过解释器进行解释并执行的,当虚拟机发现某个方法或者代码块的运行特别频繁,就会把这些代码块认定为“热点代码”,为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。
解释器与编译器
解释器是一行一行地将字节码解析成机器码,解释到哪就执行到哪,狭义地说,就是for循环100次,你就要将循环体中的代码逐行解释执行100次。当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译的时间,立即执行。
即时编译器按照我的理解就是:以方法为单位,将热点代码的字节码一次性转为机器码,并在本地缓存起来的工具。避免了部分代码被解释器逐行解释执行的效率问题。 即时编译器分为两种,Client Compiler(C1编译器)和Server Compiler(C2),默认使用的是C2,因其运行性能更高。
编译对象与触发条件
热点代码主要有两类:
- 被多次调用的方法
- 被多次执行的循环体
要知道某段代码是不是热点代码,是否需要触发即时编译,这个行为称为“热点探测”,目前主流的热点探测判定方式有两种:
基于采样的热点探测: 虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这段方法代码就是“热点代码”。
- 好处是:实现简单高效,还可以很容易地获取方法调用关系,
- 缺点是:很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
基于计数的热点探测: 虚拟机会为每个方法,甚至是代码块建立计数器,统计方法的执行次数, 如果执行次数超过一定的阀值,就认为它是“热点方法”
- 好处:统计结果相对来说更加精确谨慎
- 缺点:这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护一个计数器,而且不能直接获取到方法的调用关系
编译过程
实战:查看及分析即时编译结果
提前编译器
提前编译的优劣得失
实战:Jaotc的提前编译
编译器优化技术
编译器的目标虽然是做由程序代码翻译为本地机器码的工作,但其实难点并不在于能不能成功翻译出机器吗,输出代码优化质量的高低才是决定编译器优秀与否的关键。
优化技术概览
- 最重要的优化技术之一:方法内联
- 最前沿的优化技术之一:逃逸分析
- 语言无关的经典优化技术之一:公共子表达式消除
- 语言香港的经典优化技术之一:数组边界检查消除
方法内联
//代码优化前
static class B{
int value;
final int get(){
return value;
}
}
public void foo(){
y = b.get();
// do stuff..
z = b.get();
sum = y + z;
}
//方法内联优化后
static class B{
int value;
final int get(){
return value;
}
}
public void foo(){
y = b.value;
// do stuff..
z = b.value;
sum = y + z;
}
只有使用invokespecial指令调用的私有方法、实例构造器、父类方法以及使用invokestatic指令进行调用的静态方法才是在编译器进行解析的,其他java方法都需要在运行时进行方法接收者的多态选择。java语言默认的实例方法是虚方法。
逃逸分析
分析对象动态作用域,当一个方法被定义以后,它可能被外部方法所引用,称为方法逃逸,甚至还有可能被外部线程访问到,称为线程逃逸。
若能证明一个对象不会逃逸到方法或线程之外,这可以通过栈上分配、同步消除、标量替换来进行优化。在一般应用中不会逃逸的局部对象所占比例很大,若能栈上分配就会随着方法的结束而自动销毁了,垃圾回收系统的压力将会小很多。如果一个数据可以继续分解,则称它为聚合量。对于一个对象,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。
同步消除示例代码:
public void f() {
Object hollis = new Object();
synchronized(hollis) {
System.out.println(hollis);
}
}
代码中对hollis这个对象进行加锁,但是hollis对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉。优化成:
public void f() {
Object hollis = new Object();
System.out.println(hollis);
}
标量替换示例代码: 在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
public static void main(String[] args) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
private int x;
private int y;
}
以上代码中,point对象并没有逃逸出alloc方法,并且point对象是可以拆解成标量的。那么,JIT就会不会直接创建Point对象,而是直接使用两个标量int x ,int y来替代Point对象。
以上代码,经过标量替换后,就会变成:
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x="+x+"; point.y="+y);
}
公共子表达式消除
公共子表达式消除是- -项非常经典的、普遍应用于各种编译器的优化技术,它的含义是: 如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。
示例代码:
int d = ( c * b ) * 12 + a + ( a + b * c );
int d = E * 12 + a + ( a + E );
int d = E * 13 + a + a;
数组边界检查消除
数组边界检查消除 ( Array Bounds Checking Elimination) 是即时编译器中的一项语言相关的经典优化技术。我们知道Java语言是一门动态安全的语言,对数组的读写访问也不像 C、C++ 那样实质上就是裸指针操作。如果有一个数组 foo[],在Java语言中访问数组元素 foo[i] 的时候系统将会自动进行上下界的范围检查,即 i 必须满足 " i >= 0 && i < foo.length " 的访问条件,否则将抛出一个运行时异常: java.lang.ArrayIndexOutOfBondsException。这对软件开发者来说是一件很友好的事情,即使程序员没有专门编写防御代码,也能够避免大多数的溢出攻击。但是对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这必定是一种性能负担。
无论如何,为了安全,数组边界检查肯定是要做的,但数组边界检查是不是必须在运行期间一次不漏地进行则是可以 “商量” 的事情。例如下面这个简单的情况: 数组下标是一个常量,如 foo[3],只要在编译期根据数据流分析来确定 foo.length 的值,并判断下标 “3” 没有越界,执行的时候就无须判断了。