从 Signature 属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法的 Code 属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射手段取得参数化类型的根本依据。
接下来,笔者挑选了四项有代表性的优化技术,与大家一起观察它们是如何运作的。 它们分别是:
方法内联的优化行为理解起来是没有任何困难的,不过就是把目标方法的代码原封不动地“复制”到发起调用的方法之中,避免发生真实的方法调用而已。但实际上 Java 虚拟机中的内联过程却远没有想象中容易,甚至如果不是即时编译器做了一些特殊的努力,按照经典编译原理的优化理论,大多数的 Java 方法都无法进行内联。
无法内联的原因其实在讲解《Java 方法解析和分派调用》的时候就已经解释过:只有使用 invokespecial
指令调用的私有方法、实例构造器、父类方法和使用 invokestatic
指令调用的静态方法才会在编译期进行解析。除了上述 4 种方法之外(最多再除去被 final
修饰的方法这种特殊情况,尽管它使用 invokevirtual
指令调用,但也是非虚方法,《Java 语言规范》中明确说明了这点),其他的 Java 方法调用都必须在运行时进行方法接收者的多态选择,它们都有可能存在多于一个版本的方法接收者,简而言之,Java 语言中默认的实例方法是虚方法。
对于一个虚方法,编译器静态地去做内联的时候很难确定应该使用哪个方法版本,以 b.get()
直接内联为 b.value
为例,如果不依赖上下文,是无法确定 b
的实际类型是什么的。假如有 ParentB
和 SubB
是两个具有继承关系的父子类型,并且子类重写了父类的 get()
方法,那么 b.get()
是执行父类的 get()
方法还是子类的 get()
方法,这应该是根据实际类型动态分派的,而实际类型必须在实际运行到这一行代码时才能确定,编译器很难在编译时得出绝对准确的结论。
更糟糕的情况是,由于 Java 提倡使用面向对象的方式进行编程,而 Java 对象的方法默认就是虚方法,可以说 Java 间接鼓励了程序员使用大量的虚方法来实现程序逻辑。根据上面的分析可知,内联与虚方法之间会产生“矛盾”。
那是不是为了提高执行性能,就应该默认给每个方法都使用 final
关键字去修饰呢?C 和 C++ 语言的确是这样做的,默认的方法是非虚方法,如果需要用到多态,就用 virtual
关键字来修饰,但 Java 选择了在虚拟机中解决这个问题。
为了解决虚方法的内联问题,Java 虚拟机首先引入了一种名为〈类型继承关系分析(Class Hierarchy Analysis,CHA)〉的技术,这是整个应用程序范围内的类型分析技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。
这样,编译器在进行内联时就会分不同情况采取不同的处理:
所以说,在多数情况下 Java 虚拟机进行的方法内联都是一种激进优化。事实上,激进优化的应用在高性能的 Java 虚拟机中比比皆是,极为常见。除了方法内联之外,对于出现概率很小(通过经验数据或解释器收集到的性能监控信息确定概率大小)的隐式异常、使用概率很小的分支等都可以被激进优化“移除”,如果真的出现了小概率事件,这时才会从“逃生门”回到解释状态重新执行。(提前预留好逃生门,作为假设条件不成立时的退路,如果情况不符合预期,那么就必须抛弃已编译的代码,退回到解释状态进行执行,或者重新进行编译)
逃逸分析(Escape Analysis) 是目前 Java 虚拟机中比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。
逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为「方法逃逸」;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为「线程逃逸」;从不逃逸、方法逃逸到线程逃逸,称为“对象由低到高的不同逃逸程度”。
如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化,如:
公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术,它的含义是:如果一个表达式 E 之前已经被计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就称为「公共子表达式」。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替 E。
如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除(Local Common Subexpression Elimination),如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination)。
举个简单的例子来说明他的优化过程,假设存在如下代码:
int d = (c * b) * 12 + a + (a + b * c);
当这段代码进入虚拟机即时编译器后,它将进行如下优化:编译器检测到 c * b
与 b * c
是一样的表达式,而且计算期间b
与c
的值是不变的。因此,这条表达式就可被视为:
int d = E * 12 + a + (a + E);
这时候、编译器还可能(取决于哪种虚拟机的编译器以及具体的上下文而定)进行另外一种优化——代数化简 (Algebraic Simplification) , 在 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
” 没有越界,执行的时候就无须判断了。更加常见的情况是,数组访问发生在循环之中,并且使用循环变量来进行数组的访问。如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间 [ 0,foo.length )
之内,那么在循环中就可以把整个数组的上下界检查消除掉,这可以节省很多次的条件判断操作。
把这个数组边界检查的例子放在更高的视角来看,大量的安全检查使编写 Java 程序比编写 C 和 C++ 程序容易了很多,比如:数组越界会得到ArrayIndexOutfBoundsExcepion
异常;空指针访问会得到 NullPointExceptioen
异常;除数为零会得到 ArithmeticExceptinon
异常…在和C++程序中出现类似的问题,一个不小心就会出现 Segment Fault
信号或者 Windows 编程中常见的 “XXX内存不能为 Read/Write
” 之类的提示,处理不好程序就直接崩溃退出了。但这些安全检查也导致出现相同的程序,从而使 Java 比 C 和 C++ 要做更多的事情(各种检查判断),这些事情就会导致一些隐式开销, 如果不处理好它们,就很可能成为一项 “ Java语言天生就比较慢” 的原罪。为了消除这些隐式开销,除了如数组边界检查优化这种尽可能把运行期检查提前到编译期完成的思路之外、还有一种避开的处理思路——隐式异常处理, Java中空指针检查和算术运算中除数为零的检查都采用了这种方案。举个例子,程序中访问一个对象(假设对象叫 foo
)的某个属性(假设属性叫 value
),那以 Java 伪代码来表示虚拟机访问 foo.value
的过程为:
if (foo != null) {
return foo.value;
} else {
throw new NullPointException();
}
在使用隐式异常优化之后,虚拟机会把上面的伪代码所表示的访问过程变为如下伪代码:
try{
return foo.value;
} catch (segment_fault) {
uncommon_trap();
}
虚拟机会注册一个 Segment Fault
信号的异常处理器 ( 伪代码中的uncommon_trap()
,务必注意这里是指进程层面的异常处理器,并非真的 Java 的 try-catch 语句的异常处理器),这样当 foo
不为空的时候,对 value
的访问是不会有任何额外对 foo
判空的开销的,而代价就是当 foo
真的为空时,必须转到异常处理器中恢复中断并抛出 NullPointException
异常。进入异常处理器的过程涉及进程从用户态转到内核态中处理的过程,结束后会再回到用户态,速度远比一次判空检查要慢得多。当 foo
极少为空的时候,隐式异常优化是值得的,但假如 foo
经常为空,这样的优化反而会让程序更慢。幸好 HotSpot 虚拟机足够聪明,它会根据运行期收集到的性能监控信息自动选择最合适的方案。