Java对象都是堆上分配?看完Java中对象逃逸分析就知道答案了

随着JIT编译期的发展与逃逸分析技术逐渐成熟,所有的对象都分配到堆上也渐渐变得不是一定的。在编译期间JIT会对代码做很多优化,其中有一部分优化是减少内存堆分配压力,这里有一种重要的技术叫逃逸分析。逃逸分析是一种可以有效减少J ava 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

1、方法逃逸和未逃逸

逃逸分析是分析指针动态范围的方法,当对象在方法中分配后,其指针有可能被返回,此时就会被其他方法或者线程所引用到,我们称这种现象为指针 (引用)的逃逸。逃逸分析在jdk1.7之后是默认开启的,控制逃逸分析的参数是:-XX:+DoEscapeAnalysis

(1)方法逃逸

一个对象被创建(new)出来之后,如果是作为参数传递到外部了,那么这个对象被外部所调用,这就是方法逃逸。如下所示的代码:

public static Dog  getDog(){ 
  Dog dog = new Dog(); 
  dog.setName("small dog"); 
  dog.setAge(2); 
  return dog; 
}

上面的代码中局部变量dog可能会被外部调用,这个就是方法的逃逸。

(2)未逃逸

与方法逃逸相对的就是方法未逃逸了,如下就是典型的未逃逸:

public static void getDog(){ 
  Dog dog = new Dog(); 
  dog.setName("small dog"); 
  dog.setAge(2); 
}

此时的局部变量dog不会被外部引用,所以不会出现方法逃逸。

public static String getDogStr(){ 
  Dog dog = new Dog(); 
  dog.setName("small dog"); 
  dog.setAge(2); 
  return dog.toString(); 
}

此时方法的返回值是一个字符串而不是一个对象的引用,所以这个局部变量dog也不会被外部锁引用,就不是方法逃逸。

2、线程逃逸

public static StringBuffer craeteStringBuffer(String s1, String s2) { 
    StringBuffer sb = new StringBuffer(); 
    sb.append(s1); 
    sb.append(s2); 
    return sb; 
}

sb是一个方法内部变量,在上述代码中直接将其返回出去,这个StringBuffer有可能被其他方法所改变,这样它的作用域就不只是在方法内部,也有可能被外部线程访问到。

典型的如类变量或可以在其他线程中访问的实例变量,可能别其他的而线程访问到,我们称之为线程逃逸。

方法逃逸和线程逃逸他们逃逸强度不一样,如下图所示:

3、逃逸分析意义

(1)对象在栈上分配

如果确定一个对象不会逃逸到线程之外,那么可以考虑将这个对象在栈上分配,对象占用的内存随着栈帧弹出而销毁,这样可以减轻垃圾回收的压力。如以下图所示的:

Person对象在栈上分配了,此时栈帧3对应的方法调用结束后,就会弹出栈,那么Person对象占用的空间也就随之释放。

(2)同步锁消除

线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,那么这个变量的读写肯定就不会有竞争,对这个变量添加的同步锁也就可以安全地消除掉,如下代码:

public void setDog(){ 
  Object lock = new Object(); 
  synchronized(lock){ 
      system.out.println("龙虾编程"); 
    } 
}

上面的代码中对lock对象进行加锁,但是lock对象只在setDog()方法内部并且对象也不会被其他线程调用,所以在JIT编译阶段就会被优化这段代码,如下所示:

public void setDog(){ 
  Object lock = new Object(); 
  system.out.println("龙虾编程"); 
}

在synchronized中,如果JIT经过逃逸分析之后发现代码并没有线程安全问题,就会做锁消除。

(3)标量替换

int a;

这个变量a是基本数据类型,它是不可拆分,我们称之为标量。

public class Dog { 
  private int age; 
  private String name; 
  private Origin orign; 
}

相对的,我们还可以分解的对象数据叫做聚合量,Java中的对象就是聚合量(如上的Dog类对象),因为它可以分解成其他标量(age、name)和聚合量(orign)。

在JIT 阶段如果经过逃逸分析发现一个对象不会被外界访问的话,那么经过JIT优化就会把这个对象拆解成包含若干个成员变量来代替,我们称这个过程为标量替换。如下所示的标量替换过程的代码:

public static void main(String args[]){ 
  getDog(); 
} 
public static void getDog(){ 
  SmallDog smallDog = new SmallDog(1, "smallDog"); 
  system.out.println("name: " + smallDog.getName() + ", age: " + smallDog.getAge()); 
} 
public class SmallDog { 
  private String name; 
  private int age; 

  public SmallDog(int age, String name){ 
    this.age = age; 
    this.name = name; 
  } 
}

方法getDog()经过逃逸分析发现它中的smallDog对象不会被方法部访问并且这个对象可以被拆散,那么可以不创建对象,直接创建若干个成员变量代替,这样可以让对象的成员变量在栈上分配和读写。优化之后的代码如下所示:

public static void main(String args[]){ 
  getDog(); 
} 
public static void getDog(){ 
  //对象的变量替换 
  String name = "smallDog"; 
  iint age = 1; 

  system.out.println("name: " + name  + ", age: " + age); 
} 
public class SmallDog { 
  private String name; 
  private int age; 

  public SmallDog(int age, String name){ 
    this.age = age; 
    this.name = name; 
  } 
}

标量替换可以很好的减少堆内存的占用,因为不需要创建对象,就不再需要分配堆内存了。标量替换默认是没有开启的,如果需要开启标量替换就添加参数-XX:+EliminateAllocations

总结:

(1)逃逸分析可以带来一定程度上的性能优化,但是逃逸分析自身也需要经过一系列的复杂处理,所以需要消耗一定的时间。

(2)不是所有的对象都会在堆上分配空间,也有可能在栈上分配。当JVM通过逃逸分析发现一个对象只在一个方法中使用并且不会逃逸出这个方法,那么它可能会选择在栈上分配这个对象。如果一个对象可以被拆解为一些基本类型或引用类型的字段,并且这些字段都只在一个方法中使用,那么JVM可能会选择进行标量替换,将这个对象拆解并在栈上分配空间。

(3)加锁不一定会生效,因为JIT根据逃逸分析会将锁消除。

(4)逃逸分析分为不逃逸、方法逃逸、线程逃逸,逃逸的强度也是越来越强。

2