vlambda博客
学习文章列表

编译器优化-逃逸分析

Java对象实例和数组元素都是在堆上分配内存的吗?

答:不一定,满足特定条件的时候,它们可以在(虚拟机)栈上分配内存。



这和我们平时理解的可能有些不同,虚拟机栈一般是用来存储基本数据类型、


这是因为Java JIT(just-in-time)编译器进行的两项优化,分别称作逃逸分析(escape analysis)和标量替换(scalar replacement);

在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么绝对了。


中文维基上对逃逸分析的描述为:

在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法,分析在程序的哪些地方可以访问到指针。当一个变量或对象在子程序中被分配时,一个指向变量的指针可能逃逸到其他执行的线程中去,或者是返回调用者子程序。


逃逸分析确定指针可以存储的所有地方,以及确定能否保证指针的生命周期只在当前进程或线程中。

简单来说:JVM中的逃逸分析可以通过分析对象引用的使用范围(即动态作用域),来决定是否要在堆上分配内存,也可以作一些其他方面的优化。

static StringBuilder getStringBuilder1(String a, String b) {    StringBuilder builder = new StringBuilder(a);    builder.append(b);    return builder; // builder通过方法返回值逃逸到外部} static String getStringBuilder2(String a, String b) {    StringBuilder builder = new StringBuilder(a);    builder.append(b);    return builder.toString(); // builder范围维持在方法内部,未逃逸}

以JDK 1.8为例,可以通过设置JVM参数-XX:+DoEscapeAnalysis、-XX:-DoEscapeAnalysis来开启或关闭逃逸分析(默认当然是开启的)。


下面是一个没有逃逸分析的例子:

public class EscapeAnalysisTest {  public static void main(String[] args) throws Exception {    long start = System.currentTimeMillis();    for (int i = 0; i < 5000000; i++) {      allocate();    }    System.out.println((System.currentTimeMillis() - start) + " ms");    Thread.sleep(600000);  }   static void allocate() {    MyObject myObject = new MyObject(20192019.0);  }   static class MyObject {    int a;    double b;     MyObject(int a, double b) {      this.a = a;      this.b = b;    }  }}


然后通过开启和关闭DoEscapeAnalysis开关观察不同。

关闭逃逸分析

~ java -XX:-DoEscapeAnalysis EscapeAnalysisTest76 ms~ jmap -histo 26031 num #instances #bytes class name----------------------------------------------   1: 5000000      120000000  me.lmagics.EscapeAnalysisTest$MyObject   2: 636       12026792  [I   3: 3097        1524856  [B   4: 5088         759960  [C   5: 3067          73608  java.lang.String   6: 623          71016  java.lang.Class   7: 727          43248  [Ljava.lang.Object;   8: 532          17024  java.io.File   9: 225          14400  java.net.URL  10: 334          13360  java.lang.ref.Finalizer# ......

开启逃逸分析

~ java -XX:+DoEscapeAnalysis EscapeAnalysisTest4 ms~ jmap -histo 26655 num #instances #bytes class name----------------------------------------------   1: 592       11273384  [I   2: 90871        2180904  me.lmagics.EscapeAnalysisTest$MyObject   3: 3097        1524856  [B   4: 5088         759952  [C   5: 3067          73608  java.lang.String   6: 623          71016  java.lang.Class   7: 727          43248  [Ljava.lang.Object;   8: 532          17024  java.io.File   9: 225          14400  java.net.URL  10: 334          13360  java.lang.ref.Finalizer

可见,关闭逃逸分析之后,堆上有5000000个MyObject实例,而开启逃逸分析之后,就只剩下90871个实例了,不管是实例数还是内存占用都只有原来的2%不到。

另外,如果把堆内存限制得小一点(比如加上-Xms10m -Xmx10m),并且打印GC日志(-XX:+PrintGCDetails)的话,关闭逃逸分析还会造成频繁的GC,开启逃逸分析就没有这种情况。这说明逃逸分析确实降低了堆内存的压力。


所谓标量,就是指JVM中无法再细分的数据,比如int、long、reference等。相对地,能够再细分的数据叫做聚合量。

仍然考虑上面的例子,MyObject就是一个聚合量,因为它由两个标量a、b组成。通过逃逸分析,JVM会发现myObject没有逃逸出allocate()方法的作用域,标量替换过程就会将myObject直接拆解成a和b,也就是变成了

static void allocate() {    int a = 2019;    double b = 2019.0;}

可见,对象的分配完全被消灭了,而int、double都是基本数据类型,直接在栈上分配就可以了。所以,在对象不逃逸出作用域并且能够分解为纯标量表示时,对象就可以在栈上分配。

JVM提供了参数-XX:+EliminateAllocations来开启标量替换,默认仍然是开启的。显然,如果把它关掉的话,就相当于禁止了栈上内存分配,只有逃逸分析是无法发挥作用的


在Debug版JVM中,还可以通过参数-XX:+PrintEliminateAllocations来查看标量替换的具体情况。

除了标量替换之外,通过逃逸分析还能实现同步消除

举个例子

private void someMethod() {    Object lockObject = new Object();    synchronized (lockObject) {      System.out.println(lockObject.hashCode());    }}

lockObject这个锁对象的生命期只在someMethod()方法中,并不存在多线程访问的问题,所以synchronized块并无意义,会被优化掉

private void someMethod() {    Object lockObject = new Object();    System.out.println(lockObject.hashCode());}