Java中Iterable对象的foreach循环遍历时一个语法糖,Java编译器会将该语法糖编译为调用Iterable对象的iterator方法,并用所返回的Iterator对象的hasNext以及next方法,来完成遍历。
1 | public void forEach(ArrayList<Object> list, Consumer<Object> f) { |
1 | public class ArrayList ... { |
ArrayList.iterator方法将创建一个ArrayList$Itr实例。(不能错误的认为,应当避免在热点代码中使用foreach循环,并且直接使用基于ArrayList.size以及ArrayList.get的循环方式,以减少对Java堆的压力)
1 | public void forEach(ArrayList<Object> list, Consumer<Object> f) { |
实际上,JVM中的即时编译器可以将ArrayList.iterator方法中的实例创建操作给优化掉。但是需要方法内联以及逃逸分析的协作。
逃逸分析
逃逸分析:一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针。
在JVM的即时编译语境下,逃逸分析将判断新建的对象是否逃逸。即时编译器判断对象是否逃逸的依据,一是对象是否被存入堆中(静态字段或者堆中对象的实例字段),二是对象是否被传入未知代码中。
前者:一旦对象被存入堆中,其他线程便能获得该对象的引用。即时编译器也因此无法追踪所有使用该对象的代码位置。
后者:由于JVM的即时编译器是以方法为单位的,对于方法中未被内联的方法调用,即时编译器会将其当成未知代码,毕竟它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中。因此可以认为方法调用的调用者以及参数是逃逸的。
通常来说,即时编译器里的逃逸分析是放在方法内联之后的,以便消除这些“未知代码”入口。
理想情况下,即时编译器能够内联对ArrayList$Itr构造器的调用,对hasNext以及next方法的调用,以及当内联了Itr.next方法后,对checkForComodification方法的调用。
1 | public void forEach(ArrayList<Object> list, Consumer<Object> f) { |
这段代码所新建的ArrayList$Ite实例既没有被存入任何字段之中,也没有作为任何方法调用的调用者或者参数。因此,逃逸分析将断定该实例不逃逸
基于逃逸分析的优化
即时编译器可以根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化。
锁消除(同步消除)
如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没有意义。这是因为其他线程并不能获得该锁对象,因此也不可能对其进行加锁。在这种情况下,即时编译器可以消除对该不逃逸锁对象的加锁、解锁操作。
实际上,传统编译器仅需证明锁对象不逃逸出线程,便可以进行锁消除。由于JVM即时编译的限制,上述条件被强化为证明所对象不逃逸出当前编译的方法。
synchronized(new Object()) {}会被完全优化掉,这正是因为基于逃逸分析的锁消除。由于其他线程不能获得该锁对象,因此也无法基于该锁对象构造两个线程之间的happens-before规则。
synchronized(escapedObject) {}则不然。由于其他线程可能会对逃逸了的对象escapedObject进行加锁操作,从而构造了两个线程之间的happens-before关系。因此即时编译器至少需要为这段代码生成一条刷新缓存的内存屏障指令。
基于逃逸分析的锁消除实际上并不多见。一般来说,开发人员不会直接对方法中新构造的对象进行加锁。事实上,逃逸分析的结果更多被用于将新建对象操作转换成栈上分配或者标量替换。
栈上分配
JVM中对象都是在堆上分配的,而堆上的内容对任何线程都是可见的。与此同时,JVM需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。
如果逃逸分析能够证明某些新建的对象不逃逸,那么JVM完全可以将其分配至栈上,并且在new语句所在的方法退出时,通过弹出当前方法的栈帧来自动回收所分配的内存空间。这样便无须借助垃圾回收器来处理不再被引用的对象。
由于实现起来需要更改大量假设了”对象只能堆分配”的代码,因此HotSpot虚拟机并没有采用栈上分配,而是使用了标量替换这项技术。
标量替换
所谓的标量,就是仅能存储一个值的变量,比如Java代码中的局部变量。与之相反,聚合量则可能同时存储多个值,其中一个典型的例子便是Java对象。
标量替换这项优化技术,可以看成将原本对对象的字段的访问,替换为一个个局部变量的访问。
1 | public void forEach(ArrayList<Object> list, Consumer<Object> f) { |
原本需要在内存中连续分布的对象,现已被拆散为一个个单独的字段cursor,lastRet,以及expectedModCount。这些字段既可以存储在栈上,也可以直接存储在寄存器中。而该对象的对象头信息则直接消失了,不再被保存至内存之中。
由于该对象没有被实际分配,因此和栈上分配一样,它同样可以减轻垃圾回收的压力。与栈上分配相比,它对字段的内存连续性不做要求,而且,这些字段甚至可以直接在寄存器中维护,无须浪费任何内存空间。
部分逃逸分析
C2的逃逸分析与控制流无关,相对来说比较简单。Graal则引入了一个与控制流有关的逃逸分析–部分逃逸分析(partial escape analysis)。它解决了所新建的实例仅在部分程序路径中逃逸的情况。
1 | public static void bar(boolean cond) { |
示例代码中,新建实例只会在进入if-then分支时逃逸。(对hashCode方法的调用是一个HotSpot intrinsic,将被替换为一个无法内联的本地方法调用。)
假设if语句的条件成立的可能性只有1%,那么在99%的情况下,程序没有必要新建对象。其手工优化的版本正是部分逃逸分析想要自动达到的成果。
部分逃逸分析将根据控制流信息,判断出新建对象仅在部分分支中逃逸,并且将对象的新建操作推延至对象逃逸的分支中。这将使得原本因对象逃逸而无法避免的新建对象操作,不再出现在只执行if-else分支的程序路径之中。
综上,与C2所使用的逃逸分析相比,Graal所使用的部分逃逸分析能够优化更多的情况,不过它编译的时间也更长一些。
总结
在JVM的即时编译语境下,逃逸分析将判断新建的对象是否会逃逸。即时编译器判断对象逃逸的依据有两个:一是看对象是否被存入堆中,二是看对象是否作为方法调用的调用者或者参数。
即时编译器会根据逃逸分析的结果进行优化,如锁消除以及标量替换。后者指的是将原本连续分配的对象拆散为一个个单独的字段,分布在栈上或者寄存器中。
逃逸分析的缺点是:分析过程比较耗费性能或者分析完毕后发现非逃逸的对象很少。
逃逸程度:不逃逸,方法逃逸,线程逃逸;
其中栈上分配不支持线程逃逸,标量替换不支持方法逃逸。
方法逃逸:作为调用参数传递到其他方法中;
线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量。
部分逃逸分析是一种附带了控制流信息的逃逸分析。它将判断新建对象真正逃逸的分支,并且支持将新建操作推延至逃逸分析。