编译优化技术
Java程序员有一个共识以编译方式执行本地代码比解释方式更快,值所以有这样的共识,除去虚拟机解释执行字节码时额外消耗时间的原因外,还有一个很重要的原因就是虚拟机设计团队几乎把所有对代码的优化措施都集中在了即时编译器,因此一般来说,即时编译器产生本地代码会比Javac产生的字节码更优秀。
接下来,我们来看看几种具有代表性优化技术是如何运行的。
- 语言无关的经典优化技术之一:公共子表达式消除。
- 语言相关性的经典优化技术之一:数组范围检查消除。
- 最终要的优化技术之一:方法内连。
- 最前沿的优化技术之一:逃逸分析。
一.公共子表达式消除
公共子表达式消除是一个普遍应用与各种编译器的经典技术,它的含义是:如果一个表达式E已经计算过了,并且先前计算到现在E中所有变量值都没有发生变化,那么E的这次出现就成为公共子表达式。对于这种表达式,没有必要花时间去在对它进行计算了,只需要用前面计算过的表达式计算结果代替E就可以了。如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除,如过这种优化的范围涵盖多个基本块,那就称为全局公共子表达式消除。
二.数组边界检查消除
数组边界检查消除是即时编译器中的一项语言相关的经典优化技术。我们知道Java语言是一门动态安全的语言,对数组的读写访问也不像C,C++那样在本质上是裸指针操作。
什么是数组边界检查?
如果有一个数组foo[],在Java语言中访问数组元素foo[i]的时候将会对这一操作进行上下界的范围检查,即检查i必须满足i>0 && i<foo.length 这个条件,否则将抛出一个运行时异常:java.lang.ArrayIndexOutOfBoundException。这对软件开发者来说是一件很好的事情。但是对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑也是一种性能负担。
数组边界清楚如和实现?
无论如何,为了安全,数组边界检查肯定是必须做的,但数组边界检查是不是必须在运行其一次不漏的检查则是可以“商量”的。例如下面这个简单的情况:数组下标是一个常量,如foo[3],只要在编译期根据数据流分析来确定foo.length的值,并判断下标“3”没有越界,执行的时候就无需判断了。更常见的情况是数组访问发生在循环之中并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在[0,foo.length]之内,那在整个循环之中就可以把数组的上下界检查消除,这就节省很多次的条件判断操作。
三.方法内联
方法内联的重要地位:
方法内联是编译器优化最重要的手段之一,除了消除方法调用成本之外,它更重要的意义是为其他优化手段建立良好的基础。
方法内联看起来简单,不过就是把目标方法的代码块“复制”到发起调用的方法之中,避免发生真实的方法调用而已。
方法内联的瓶颈:
方法内联看起来简单但是Java虚拟机中的内联过程却是比较复杂的。无法内联的原因前面介绍过。只有使用invokespecial指令调用的私有方法,实例构造器,父类方法以及使用invokestatic指令进行调用的静态方法才是在编译器进行解析的,除了上述四种方法外,其他的Java方法都是虚方法(最多再出去final修饰的方法这种特殊情况,尽管它使用invokevirtual指令调用,但也是非虚方法),所谓虚方法,就是只是一个名字,没有实际指向,至于为什么没有实际指向是因方法分派(无法准确确定该方法的版本)。需要在运行期才能确定该虚方法的实际版本,编译器无法得到结论。
Java语言与方法内联的矛盾:
由于Java语言提倡使用面向对象编程的方式进行编程,而Java对象的方法默认就是虚方法,因此Java间接鼓励程序员使用大量的虚方法来完成逻辑。根据上面的分析,如果内联与虚方法之间产生“矛盾”,那该怎么办呢?
如何在虚拟机中实现方法内联?
为了解决虚方法的内联问题,Java虚拟机团队想了很多办法,首先是引入了一种名为“类型分析技术”,这是一种基于整个应用程序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多于一种实现,某个类是否存在子类,子类是否为抽象类等信息。
编译器在进行内联时,如果是非虚方法,那么直接进行内联就可以了,这时候的内联是有稳定前提保障的。如果遇到虚方法,则会向“类型继承关系分析”查询次方法在当前程序下是否有多个目标版本可提供,如果查询得出只有一个版本,那就可以进行内联,不过这种内联属于激进优化,需要预留一个“逃生门”,称为守护内联。
如果向“类型继承关系分析”查询出来得结果有多个版本得目标方法可供选择,使用内联缓存来完成方法内联。
方法内联,激进不安全!
在许多情况下,虚拟机进行的内联都是一种激进优化,激进优化得手段在高性能得商用虚拟机中很常见,除了内联之外,对于出现率很小的隐式异常,使用概率很小的分支等都可以被激进优化“移除”,如果真的出现了小概率事件,这时才会从“逃生门”会到解析状态重新执行。
四.逃逸分析
逃逸分析是什么?
逃逸分析是目前Java虚拟机中比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。
什么是逃逸分析?
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为方法调用参数传递到其他方法中,称为方法逃逸分析。甚至还有可能被外部线程访问到,譬如复制给类变量或可以在其他线程中防卫的实例变量,称为线程逃逸。
逃逸分析可以为程序编译器优化做些什么?
如果证明一个对象不会逃逸到方法或线程之外,也就是说方法或线程无法通过任何途径方法到这个对象,则可能为这个变量进行一些高效的优化,如下所示。
- 栈顶分配:Java虚拟机中,在Java堆上分配创建对象的内存空间几乎是Java程序员都清楚的常识了,Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储该对象的数据。如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存就会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。一般的应用中,不会逃逸的局部对象占比例很大,如果使用栈上分配,那大量的对象就会随着方法的结束而自动销毁,垃圾收集系统的眼里将会减小很多。
- 同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其它线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施就可以消除掉。
- 标量替换:标量,是指一个数据已经无法在分解成更小的数据表示了,Java虚拟机中的原始数据类型(int,long,referebce等)都不能进一步分解了,它们称为标量。Java中的对象可以继续分解,所以称为聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其中使用到的成员变量恢复原始数据类型,来访问就叫做标量替换。
逃逸分析技术至今为止尚未成熟,仍有很大改进的余地。不成熟的原因主要是不能保证逃逸分析的新能收益必定高于它的消耗。但是逃逸分析技术目前却是编译器优化技术的一个重要的发展方向,在今后的虚拟机中,逃逸分析技术肯定会大有可为的!