Java 程序编译与代码优化之编译优化技术

编译优化技术

以一段代码来说明编译器是怎么优化代码的:
优化前的原始代码:

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 检测到有多个版本的目标方法可供选择,则编译器还会进行最后一次努力,使用内联缓存来完成方法内联,这是一个建立在目标方法正常入口之前的缓存,它的工作原理大致是:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本,如果以后进来的每次调用的方法接收者版本都是一样的,那这个内联还可以一直用下去,否则说明程序真正使用了虚方法的多态性,这时才会取消内联,查找虚方法表进行方法分派。

逃逸分析

逃逸分析与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义之后,它可能被外部方法所引用,例如作为参数传递到其他方法中,称为方法逃逸。甚至可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化,如下所示:

  • 栈上分配(Stack Allocation):Java 虚拟机中,在 Java 堆上分配创建对象的内存空间,Java 堆中的对象对于各个线程都是共享和可见的,虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但回收动作会消耗时间。如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多。
  • 同步消除(Synchronization Elimination):线程同步本身是一个相对耗时的过程,如果逃逸分析能确定一个变量无法被其他线程访问,那么对这个变量实施的同步措施就可以消除掉。
  • 标量替换(Scalar Replacement):标量是指一个数据已经无法再分解成更小的数据来表示了,Java 虚拟机中的原始数据类型(int、long 等数值类型以及 reference 类型等)都不能进一步分解,他们就可以称为标量。相反的,如果一个数据可以继续分解,就称为聚合量,Java 中的对象就是典型的聚合量。如果把一个对象 Java 对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,且这个对象可以被拆散的话,那程序真正执行的时候可能不在创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写外,还可以为后续进一步的优化手段创建条件。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值