不思,故有惑;不求,故无得;不问,故不知 -- 佚名

虚拟机--逃逸分析_标量

逃逸分析(个人理解):就是方法内的对象,可以被方法外所访问。

方法逃逸的方式如下:

public class EscapeTest {

public static Object obj;

public void globalVariableEscape() {

// 给全局变量赋值,发生逃逸

obj = new Object();

}

public Object methodEscape() {

// 方法返回值,发生逃逸

return new Object();

}

public void instanceEscape() {

// 实例引用发生逃逸 test(this);

}

}

栈上分配:就是把没发生逃逸的对象,在栈分配空间。(一般对象分配空间是在堆)逃逸

二者联系:jvm根据对象是否发生逃逸,会分配到不同(堆或栈)的存储空间。

如果对象发生逃逸,那会分配到堆中。(因为对象发生了逃逸,就代表这个对象可以被外部访问,换句话说,就是可以共享,能共享数据的,无非就是堆或方法区,这里就是堆。)

如果对象没发生逃逸,那会分配到栈中。(因为对象没发生逃逸,那就代表这个对象不能被外部访问,换句话说,就是不可共享,这里就是栈。)

那我们再想深一层,为什么会有逃逸分析,有栈上分配这些东西?

当然是为了主体的考虑,主体就是jvm,或者直接说为了GC考虑也不为过。大家想想,GC主要回收的对象是堆和方法区。GC不会对栈、程序计数器这些进行回收的,因为没东西可以回收。

说回来,如果方法逃逸,那么对象就会分配在堆中,这个时候,GC就要工作了。如果没发生方法逃逸,那么对象就分配在栈中,当方法结束后,资源就自动释放了,GC压根不用操心。所以哈,方法逃逸这东西,主要也是为GC打工的,或者说为GC服务吧!说到这里,可能有人会问,那方法逃逸和性能还是没关系哈?其实有,想深一层,GC不运行的时候,程序的性能肯定会好点,不会占用程序运行的时间。虽然GC清扫垃圾的速度很快,但是当一个程序足够大的时候,对象就自然多了,垃圾也自然多了,这个时候GC就忙了。

巴拉巴拉这么多,主要就是想说:能在方法内创建对象,就不要再方法外创建对象。毕竟这是为了GC好,也是为了提高性能。

基于逃逸分析的优化

即时编译器可以根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化。

我们先来看一下锁消除。如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没有意义。这是因为其他线程并不能获得该锁对象,因此也不可能对其进行加锁。在这种情况下,即时编译器可以消除对该不逃逸锁对象的加锁、解锁操作。

实际上,传统编译器仅需证明锁对象不逃逸出线程,便可以进行锁消除。由于 Java 虚拟机即时编译的限制,上述条件被强化为证明锁对象不逃逸出当前编译的方法。

在介绍 Java 内存模型时,我曾提过synchronized (new Object()) {}会被完全优化掉。这正是因为基于逃逸分析的锁消除。由于其他线程不能获得该锁对象,因此也无法基于该锁对象构造两个线程之间的 happens-before 规则。

synchronized (escapedObject) {}则不然。由于其他线程可能会对逃逸了的对象escapedObject进行加锁操作,从而构造了两个线程之间的 happens-before 关系。因此即时编译器至少需要为这段代码生成一条刷新缓存的内存屏障指令。

不过,基于逃逸分析的锁消除实际上并不多见。一般来说,开发人员不会直接对方法中新构造的对象进行加锁。事实上,逃逸分析的结果更多被用于将新建对象操作转换成栈上分配或者标量替换。

我们知道,Java 虚拟机中对象都是在堆上分配的,而堆上的内容对任何线程都是可见的。与此同时,Java 虚拟机需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。

如果逃逸分析能够证明某些新建的对象不逃逸,那么 Java 虚拟机完全可以将其分配至栈上,并且在 new 语句所在的方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间。这样一来,我们便无须借助垃圾回收器来处理不再被引用的对象。

不过,由于实现起来需要更改大量假设了“对象只能堆分配”的代码,因此 HotSpot 虚拟机并没有采用栈上分配,而是使用了标量替换这么一项技术。

所谓的标量,就是仅能存储一个值的变量,比如 Java 代码中的局部变量。与之相反,聚合量则可能同时存储多个值,其中一个典型的例子便是 Java 对象。

标量替换这项优化技术,可以看成将原本对对象的字段的访问,替换为一个个局部变量的访问。举例来说,前面经过内联之后的 forEach 代码可以被转换为如下代码:

public void forEach(ArrayList<Object> list, Consumer<Object> f) {

 // Itr iter = new Itr; // 经过标量替换后该分配无意义,可以被优化掉

 int cursor = 0;     // 标量替换

 int lastRet = -1;   // 标量替换

 int expectedModCount = list.modCount; // 标量替换

 while (cursor < list.size) {

   if (list.modCount != expectedModCount)

     throw new ConcurrentModificationException();

   int i = cursor;

   if (i >= list.size)

     throw new NoSuchElementException();

   Object[] elementData = list.elementData;

   if (i >= elementData.length)

     throw new ConcurrentModificationException();

   cursor = i + 1;

   lastRet = i;

   Object obj = elementData[i];

   f.accept(obj);

 }

}


可以看到,原本需要在内存中连续分布的对象,现已被拆散为一个个单独的字段cursor,lastRet,以及expectedModCount。这些字段既可以存储在栈上,也可以直接存储在寄存器中。而该对象的对象头信息则直接消失了,不再被保存至内存之中。

由于该对象没有被实际分配,因此和栈上分配一样,它同样可以减轻垃圾回收的压力。与栈上分配相比,它对字段的内存连续性不做要求,而且,这些字段甚至可以直接在寄存器中维护,无须浪费任何内存空间。

部分逃逸分析

C2 的逃逸分析与控制流无关,相对来说比较简单。Graal 则引入了一个与控制流有关的逃逸分析,名为部分逃逸分析(partial escape analysis)[2]。它解决了所新建的实例仅在部分程序路径中逃逸的情况。

举个例子,在下面这段代码中,新建实例只会在进入 if-then 分支时逃逸。(对hashCode方法的调用是一个 HotSpot intrinsic,将被替换为一个无法内联的本地方法调用。)

public static void bar(boolean cond) {

 Object foo = new Object();

 if (cond) {

   foo.hashCode();

 }

}

// 可以手工优化为:

public static void bar(boolean cond) {

 if (cond) {

   Object foo = new Object();

   foo.hashCode();

 }

}


假设 if 语句的条件成立的可能性只有 1%,那么在 99% 的情况下,程序没有必要新建对象。其手工优化的版本正是部分逃逸分析想要自动达到的成果。

部分逃逸分析将根据控制流信息,判断出新建对象仅在部分分支中逃逸,并且将对象的新建操作推延至对象逃逸的分支中。这将使得原本因对象逃逸而无法避免的新建对象操作,不再出现在只执行 if-else 分支的程序路径之中。

综上,与 C2 所使用的逃逸分析相比,Graal 所使用的部分逃逸分析能够优化更多的情况,不过它编译时间也更长一些。

总结与实践

今天我介绍了 Java 虚拟机中即时编译器的逃逸分析,以及基于逃逸分析的优化。

在 Java 虚拟机的即时编译语境下,逃逸分析将判断新建的对象是否会逃逸。即时编译器判断对象逃逸的依据有两个:一是看对象是否被存入堆中,二是看对象是否作为方法调用的调用者或者参数。

即时编译器会根据逃逸分析的结果进行优化,如锁消除以及标量替换。后者指的是将原本连续分配的对象拆散为一个个单独的字段,分布在栈上或者寄存器中。

部分逃逸分析是一种附带了控制流信息的逃逸分析。它将判断新建对象真正逃逸的分支,并且支持将新建操作推延至逃逸分支。