23 逃逸分析
我们知道,Java中Iterable
对象的foreach循环遍历是一个语法糖,Java编译器会将该语法糖编译为调用Iterable
对象的iterator
方法,并用所返回的Iterator
对象的hasNext
以及next
方法,来完成遍历。
public void forEach(ArrayList<Object> list, Consumer<Object> f) {
for (Object obj : list) {
f.accept(obj);
}
}
举个例子,上面的Java代码将使用foreach循环来遍历一个ArrayList
对象,其等价的代码如下所示:
public void forEach(ArrayList<Object> list, Consumer<Object> f) {
Iterator<Object> iter = list.iterator();
while (iter.hasNext()) {
Object obj = iter.next();
f.accept(obj);
}
}
这里我也列举了所涉及的ArrayList
代码。我们可以看到,ArrayList.iterator
方法将创建一个ArrayList$Itr
实例。
public class ArrayList ... {
public Iterator<E> iterator() {
return new Itr();
}
private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
...
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings("unchecked")
public E next() {
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
...
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
}
因此,有同学认为我们应当避免在热点代码中使用foreach循环,并且直接使用基于ArrayList.size
以及ArrayList.get
的循环方式(如下所示),以减少对Java堆的压力。
public void forEach(ArrayList<Object> list, Consumer<Object> f) {
for (int i = 0; i < list.size(); i++) {
f.accept(list.get(i));
}
}
实际上,Java虚拟机中的即时编译器可以将ArrayList.iterator
方法中的实例创建操作给优化掉。不过,这需要方法内联以及逃逸分析的协作。
在前面几篇中我们已经深入学习了方法内联,今天我便来介绍一下逃逸分析。
逃逸分析
逃逸分析是“一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针”(出处参见[1])。
在Java虚拟机的即时编译语境下,逃逸分析将判断新建的对象是否逃逸。即时编译器判断对象是否逃逸的依据,一是对象是否被存入堆中(静态字段或者堆中对象的实例字段),二是对象是否被传入未知代码中。
前者很好理解:一旦对象被存入堆中,其他线程便能获得该对象的引用。即时编译器也因此无法追踪所有使用该对象的代码位置。
关于后者,由于Java虚拟机的即时编译器是以方法为单位的,对于方法中未被内联的方法调用,即时编译器会将其当成未知代码,毕竟它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中。因此,我们可以认为方法调用的调用者以及参数是逃逸的。
通常来说,即时编译器里的逃逸分析是放在方法内联之后的,以便消除这些“未知代码”入口。
回到文章开头的例子。理想情况下,即时编译器能够内联对ArrayList$Itr
构造器的调用,对hasNext
以及next
方法的调用,以及当内联了Itr.next
方法后,对checkForComodification
方法的调用。
如果这些方法调用均能够被内联,那么结果将近似于下面这段伪代码:
public void forEach(ArrayList<Object> list, Consumer<Object> f) {
Itr iter = new Itr; // 注意这里是new指令
iter.cursor = 0;
iter.lastRet = -1;
iter.expectedModCount = list.modCount;
while (iter.cursor < list.size) {
if (list.modCount != iter.expectedModCount)
throw new ConcurrentModificationException();
int i = iter.cursor;
if (i >= list.size)
throw new NoSuchElementException();
Object[] elementData = list.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
iter.cursor = i + 1;
iter.lastRet = i;
Object obj = elementData[i];
f.accept(obj);
}
}
可以看到,这段代码所新建的ArrayList$Itr
实例既没有被存入任何字段之中,也没有作为任何方法调用的调用者或者参数。因此,逃逸分析将断定该实例不逃逸。
基于逃逸分析的优化
即时编译器可以根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化。
我们先来看一下锁消除。如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没有意义。这是因为其他线程并不能获得该锁对象,因此也不可能对其进行加锁。在这种情况下,即时编译器可以消除对该不逃逸锁对象的加锁、解锁操作。
实际上,传统编译器仅需证明锁对象不逃逸出线程,便可以进行锁消除。由于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虚拟机的即时编译语境下,逃逸分析将判断新建的对象是否会逃逸。即时编译器判断对象逃逸的依据有两个:一是看对象是否被存入堆中,二是看对象是否作为方法调用的调用者或者参数。
即时编译器会根据逃逸分析的结果进行优化,如锁消除以及标量替换。后者指的是将原本连续分配的对象拆散为一个个单独的字段,分布在栈上或者寄存器中。
部分逃逸分析是一种附带了控制流信息的逃逸分析。它将判断新建对象真正逃逸的分支,并且支持将新建操作推延至逃逸分支。
今天的实践环节有两项内容。
第一项内容,我们来验证一下ArrayList.iterator
中的新建对象能否被逃逸分析所优化。运行下述代码并观察GC的情况。你可以通过虚拟机参数-XX:-DoEscapeAnalysis
来关闭默认开启的逃逸分析。
// Run with
// java -XX:+PrintGC -XX:+DoEscapeAnalysis EscapeTest
import java.util.ArrayList;
import java.util.function.Consumer;
public class EscapeTest {
public static void forEach(ArrayList<Object> list, Consumer<Object> f) {
for (Object obj : list) {
f.accept(obj);
}
}
public static void main(String[] args) {
ArrayList<Object> list = new ArrayList<>();
for (int i = 0; i < 100; i++) {
list.add(i);
}
for (int i = 0; i < 400_000_000; i++) {
forEach(list, obj -> {});
}
}
}
第二项内容,我们来看一看部分逃逸分析的效果。你需要使用附带Graal编译器的Java版本,如Java 10,来运行下述代码,并且观察GC的情况。你可以通过虚拟机参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler
来启用Graal。
// Run with
// java -Xlog:gc Foo
// java -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler -Xlog:gc Foo
public class Foo {
long placeHolder0;
long placeHolder1;
long placeHolder2;
long placeHolder3;
long placeHolder4;
long placeHolder5;
long placeHolder6;
long placeHolder7;
long placeHolder8;
long placeHolder9;
long placeHoldera;
long placeHolderb;
long placeHolderc;
long placeHolderd;
long placeHoldere;
long placeHolderf;
public static void bar(boolean condition) {
Foo foo = new Foo();
if (condition) {
foo.hashCode();
}
}
public static void main(String[] args) {
for (int i = 0; i < Integer.MAX_VALUE; i++) {
bar(i % 100 == 0);
}
}
}
[1] https://zh.wikipedia.org/wiki/逃逸分析
[2] http://www.ssw.uni-linz.ac.at/Research/Papers/Stadler14/Stadler2014-CGO-PEA.pdf
- 魏春河 👍(23) 💬(10)
怎么看出来对象是否放入堆中?不是所有的对象都在堆中吗
2018-09-12 - Darren 👍(12) 💬(1)
逃逸分析的主要优化点是:栈上分配,标量替换,同步消除。其中同步消除比较少,栈上分配在HotSpot中暂未实现,主要是标量替换。 逃逸分析的缺点是:分析过程比较耗费性能或者分析完毕后发现非逃逸的对象很少。 逃逸程度:不逃逸,方法逃逸,线程逃逸;其中栈上分配不支持线程逃逸,标量替换不支持方法逃逸。
2020-03-06 - 房艳 👍(5) 💬(0)
看完老师的讲解,我又看了下面的这个文章,感觉好像更理解了一些。 逃逸分析 逃逸分析并不是直接的优化手段,而是一个代码分析,通过动态分析对象的作用域,为其它优化手段如栈上分配、标量替换和同步消除等提供依据,发生逃逸行为的情况有两种:方法逃逸和线程逃逸。 1、方法逃逸:当一个对象在方法中定义之后,作为参数传递到其它方法中; 2、线程逃逸:如类变量或实例变量,可能被其它线程访问到; 如果不存在逃逸行为,则可以对该对象进行如下优化:同步消除、标量替换和栈上分配。 同步消除 线程同步本身比较耗,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,则可以消除对该对象的同步锁,通过-XX:+EliminateLocks可以开启同步消除。 标量替换 1、标量是指不可分割的量,如java中基本数据类型和reference类型,相对的一个数据可以继续分解,称为聚合量; 2、如果把一个对象拆散,将其成员变量恢复到基本类型来访问就叫做标量替换; 3、如果逃逸分析发现一个对象不会被外部访问,并且该对象可以被拆散,那么经过优化之后,并不直接生成该对象,而是在栈上创建若干个成员变量; 通过-XX:+EliminateAllocations可以开启标量替换, -XX:+PrintEliminateAllocations查看标量替换情况。 栈上分配 故名思议就是在栈上分配对象,其实目前Hotspot并没有实现真正意义上的栈上分配,实际上是标量替换。 ...... 链接:https://www.jianshu.com/p/20bd2e9b1f03
2021-01-21 - 乘风 👍(4) 💬(1)
看了此篇后有一些疑惑: 1.为什么对象存入到堆中就无法追踪其代码位置? 当基于全局的优化确定对象的作用域限定在方法内部,其引用不会发生逃逸,这样的对象虽然存在堆中但其引用作用域固定,不会发生方法逃逸。 2.逃逸分析的判断依据是对象是否存入到堆中,而后文又讲到HotSpot并没有采用栈上分配,那不是意味着对象是一定分配在堆中吗?
2019-07-18 - 李二木 👍(2) 💬(1)
本章介绍逃逸分析的优化作用,那么它有什么不足的地方吗?
2018-09-12 - Scott 👍(2) 💬(1)
你好,我翻了一下R大关于escape analysis的一篇知乎回答,里面提到C2可以对不逸出当前线程的锁做消除,这个过程是怎样的?
2018-09-12 - xzy 👍(1) 💬(0)
最后的结果还是:所有对象都在堆里面
2020-11-12 - GaGi 👍(1) 💬(0)
对于如何判断对象是逃逸的,我的理解是这样: 1、对象如果在堆中,其他线程是可以获取到这个对象的引用,这时如果很多地方引用到这个对象,那么就会导致即时编译器无法追踪所有使用该对象的代码位置; 2、关于第二点,文中说的比较清晰,就是对象如果是作为调用者调用一个未知方法/作为参数传入未知方法,这时就可以认为是逃逸的; 对于上面这两点,应该是连带的;也就是说先满足对象是在堆中存储,并且对象有涉及到未知代码中就认为是逃逸的;不知道理解正不正确,如果理解不正确,麻烦老师纠正下
2020-04-17 - 9700 👍(1) 💬(3)
为啥hashCode方法不能内联,22节介绍的native方法,只要被标注了intrinsic,都会被直接内联的啊。
2019-06-28 - 倔强 👍(1) 💬(0)
老师讲的非常好,对jvm的了解更加深入了一些
2018-09-12 - ZoQ-tans 👍(1) 💬(0)
老师讲的很有帮助,陈列原理,结合事例,有理有据,逻辑清晰,层次鲜明
2018-09-12 - Demon.Lee 👍(0) 💬(0)
“由于该对象没有被实际分配,因此和栈上分配一样,它同样可以减轻垃圾回收的压力。与栈上分配相比,它对字段的内存连续性不做要求,而且,这些字段甚至可以直接在寄存器中维护,无须浪费任何内存空间。” “与栈上分配相比” 这句话是否是“与堆上分配相比”,编辑小姐姐瞧一瞧 😄
2024-04-21 - walkingonair 👍(0) 💬(0)
标量替换(Scalar Replacement)是一种编译器优化技术,它可以将某些对象拆分成多个独立的基本类型变量,从而提高程序的执行效率。由于基本类型变量不需要进行内存管理和垃圾回收等操作,因此可以大幅度降低程序的内存开销和运行时开销。 在 Java 中,当创建一个对象时,它通常会被分配在堆空间中,并由 GC 进行管理和回收。但如果编译器能够确切地知道对象的属性数量、类型以及访问方式等信息,那么就可以将该对象拆分成多个独立的基本类型变量,从而提高程序的执行效率。 具体来说,标量替换的过程如下: 1、编译器首先会对程序进行静态分析,找出其中满足条件的对象。 2、对于满足条件的对象,编译器会将其属性值拆分成多个独立的基本类型变量,并将这些变量分别存储在栈上或寄存器中。 3、当需要使用该对象的属性值时,编译器会将这些基本类型变量合并为一个对象,并返回对象的引用。 4、当对象不再被使用时,它所占用的内存区域会由 GC 自动回收,无需进行额外的内存管理操作。
2023-06-26 - walkingonair 👍(0) 💬(0)
栈上分配(Stack Allocation)是一种编译器优化技术,它可以将某些对象直接分配在栈上,从而避免了对堆空间的额外开销。由于栈上分配不需要进行内存管理和垃圾回收等操作,因此可以大幅度提高程序的执行效率。 在 Java 中,当创建一个对象时,它通常会被分配在堆空间中,并由 GC 进行管理和回收。但如果编译器能够确切地知道对象的生命周期、访问方式以及是否发生逃逸等信息,那么就可以将该对象直接分配在栈上,避免了对堆空间的额外开销。 具体来说,栈上分配的过程如下: 1、编译器首先会对程序进行静态分析,找出其中局部变量且不会被逃逸的对象。 2、对于满足条件的对象,编译器会在方法栈帧中为其分配一段连续的内存区域,并将对象的属性值直接存储在这个内存区域中。 3、当对象不再被使用时,它所占用的内存区域会随着方法的返回而自动释放,无需进行垃圾回收等操作。
2023-06-26 - cv0cv0 👍(0) 💬(0)
Graal 是作者写的吗?
2022-03-09