以一段代码来说明编译器是怎么优化代码的:
优化前的原始代码:
static class B {
int value;
final int get() {
return value;
}
}
public void foo() {
y = b.get();
// ...do stuff...
z = b.get();
sun = y + z;
}
第一步进行方法内联(Method Inlining),方法内联的重要性要高于其它优化措施,它的主要目的有两个,一是去除方法的调用成本(如建立栈帧等),二是为其他优化建立良好的基础,方法内联膨胀之后可以便于在更大范围上采取后续的优化手段,从而获得更好的优化效果。因此,各种编译器一般都会把内联优化放在优化序列的最靠前位置。内敛后的代码如下:
public void foo() {
y = b.value;
// ...do stuff...
z = b.value;
sun = y + z;
}
第二步进行冗余访问消除(Redundant Loads Elimination),假设代码中间注释掉的“do stuff…” 所代表的操作不会改变 b.value 的值,那就可以把 “z=b.value” 替换为 “z=y”,因为上一句 “y=b.value” 已经保证了变量 y 与 b.value 是一致的,这样就可以不再去访问对象 b 的局部变量了,如果把 b.value 看作是一个表达式,那也可以把这项优化看成是公共子表达式消除,优化后的代码如下:
public void foo() {
y = b.value;
// ...do stuff...
z = y;
sun = y + z;
}
第三步进行复写传播(Copy Propagation),因为在这段程序的逻辑中并没有必要时用一个额外的变量 “z”,它与变量 “y” 是完全相等的,因此可以使用 “y” 来代替 “z”。复写传播之后的代码如下:
public void foo() {
y = b.value;
// ...do stuff...
y = y;
sun = y + y;
}
第四步进行无用代码扫除(Dead Code Elimination)。无用代码可能是永远不会执行的代码,也可能是完全没有意义的代码,在上述代码中,“y=y” 是没有意义的,把他消除之后的代码如下:
public void foo() {
y = b.value;
// ...do stuff...
sun = y + y;
}
经过四次优化之后,代码省略了许多语句,执行效率也会变得更高。
下面我们了解一下几项最具有代表性的优化技术是如何运作的,他们分别是:
公共子表达式消除是一个普遍应用于各种编译器的经典优化技术,它的含义是:如果一个表达式 E 已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成为了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替 E 就可以了。如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除。举个例子说明它的优化过程,假如存在如下代码:
int d = (c * b) * 12 + a + (a + b * c);
如果这段代码交给 Javac 编译器则不会得到任何优化,当这段代码进入到虚拟机的即时编译器后,他将进行如下优化:编译器检测到 “cb” 与 “bc” 是一样的表达式,而且计算期间 b 与 c 的值是不变的。因此,这条表达式可能被视为:
int d = E * 12 + a + (a + E);
这时,编译器还可能进行另外一种优化:代数化简,把表达式变为:
int d = E * 13 + a * 2;
表达式进行变换后,在计算起来就可以节省一些时间了。
数组边界检查消除是即时编译器中的一项语言相关的经典优化技术。Java 是一门动态安全的语言,大量的安全检查令编写 Java 程序比编写 C/C++ 程序容易的多,如数组越界会得到 ArrayIndexOutOfBoundsException 异常,空指针访问会得到 NullPointException 等。但这些安全检查也导致了相同的程序,Java 要比 C/C++ 做更多的事情,这些事情就成为一种隐式开销,要消除这种隐式开销,除了尽可能把运行期检查提到编译期完成的思路之外,还有另一种避免思路——隐式异常处理,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 信号的异常处理器,这样 foo 不为空时,对 value 的访问是不会额外消耗一次对 foo 判空的开销的。代价就是 foo 为空时,必须转入到异常处理器中恢复并抛出 NullPointException 异常,这个过程必须从用户态转到内核态中处理,结束后再回到用户态,速度远比一次判空检查慢。
方法内联是编译器最重要的优化手段之一,除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础,比如下面的代码中,testInline() 方法的内部全都是无用代码,如果不做内联,后续即使进行了无用代码消除的优化,也无法发现任何无用代码,因为如果分开来看,foo() 和 testInline() 两个方法里面的操作都可能是有意义的。
public static void foo(Object obj) {
if (obj != null) {
System.out.println("do somthing");
}
}
public static void testInline(String[] args) {
Object obj = null;
foo(obj);
}
由于 Java 是动态语言,许多类型在编译时并不能得知,所以无法直接进行内联优化,这种动态类型的方法称为“虚方法”。
为了解决虚方法的内联问题,Java 虚拟机设计团队引入了一种名为“类型继承关系分析”(Class Hierarchy Analysis,CHA)技术,这是一个基于整个应用程序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类、子类是否为抽象类等信息。
编译器在内联时,如果是非虚方法,那么直接进行内联,这时的内联是有稳定前提保障的。如果是虚方法,则会向 CHA 查询此方法在当前程序下是否有多个目标版本可供选择,如果只有一个版本,也可以进行内联,不过这种内联属于激进优化,需要预留一个“逃生门”,称为守护内联。如果加载导致了继承关系发生变化,那就需要抛弃已经编译的代码,退回到解释状态执行,或者重新进行编译。
如果 CHA 检测到有多个版本的目标方法可供选择,则编译器还会进行最后一次努力,使用内联缓存来完成方法内联,这是一个建立在目标方法正常入口之前的缓存,它的工作原理大致是:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本,如果以后进来的每次调用的方法接收者版本都是一样的,那这个内联还可以一直用下去,否则说明程序真正使用了虚方法的多态性,这时才会取消内联,查找虚方法表进行方法分派。
逃逸分析与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义之后,它可能被外部方法所引用,例如作为参数传递到其他方法中,称为方法逃逸。甚至可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化,如下所示: