Java是否可以栈上分配对象内存? 为什么?

5,388 阅读6分钟

  在说java的对象分配内存所在位置前,我们先来看看C++的对象分配是怎样的。 C++实例化对象的方式有两种:

  • 直接定义对象,对象被分配在方法栈的本地变量栈上,生命周期与方法栈一致,方法退出时对象被自动销毁。
  • 通过new关键字在堆上分配对象,对象要用户手动销毁。
#include <iostream>
using namespace std;

class ClassA {
private:
    int arg;
public:
     ClassA(int a): arg(a) {
         cout << "ClassA(" << arg << ")" << endl;
    }

    ~ClassA(){
         cout << "~ClassA(" << arg << ")" << endl;
    }
};

int main() {
    ClassA ca1(1); //直接定义对象
    ClassA* ca2 = new ClassA(2); //使用new关键字
    return 0;
}

输出结果:

ClassA(1)
ClassA(2)
~ClassA(1)

  直接定义对象的方式会将对象内存分配在栈上,因此main函数退出后会执行ClassA的虚构函数,该对象被回收。而使用new实例化的对象内存分配在堆上,对象在main函数退出后不会执行虚构函数。
  C++中,内存可以被分配到栈上或者堆内存中。
  那么java是否也是这样呢,如果java在必要的时候也是把对象分配到栈上,从而自动销毁对象,那必然能减少一些垃圾回收的开销(java的垃圾回收需要进行标记整理等一系列耗时操作),同时也能提高执行效率(栈上存储的数据有很大的概率会被虚拟机分配至物理机器的高速寄存器中存储)。虽然,这些细节都是针对JVM而言的,对于开发者而言似乎不太需要关心。
  然而,我还是很好奇。

写一段不怎么靠谱的代码来观察Java的输出结果:

public class ClassA{
     public int arg;
     public ClassA(int arg) {
         this.arg = arg;
     }

     @Override
     protected void finalize() throws Throwable {
         System.out.println("对象即将被销毁: " + this + "; arg = " + arg);
         super.finalize();
     }
 }
 
 
 public class TestCase1 {
     public static ClassA getClassA(int arg) {
         ClassA a = new ClassA(arg);
         System.out.println("getA() 方法内:" + a);
         return a;
     }
 
     public static void foo() {
         ClassA a = new ClassA(2);
         System.out.println("foo() 方法内:" + a);
     }
 
 
     public static void main(String[] args) {
         ClassA classA = getClassA(1);
         System.out.println("main() 方法内:" + classA);
 
         foo();
     }
 
 }

输出结果:

getA() 方法内:com.rhythm7.A@29453f44
main() 方法内:com.rhythm7.A@29453f44
foo() 方法内:com.rhythm7.A@5cad8086

  执行完getA()方法后,getA()方法内实例化的classA对象实例a被返回并赋值给main方法内的classA。 接着执行foo()方法,方法内部实例化一个classA对象,但只是输出其HashCode,没有返回其对象。
  结果是两个对象都没有执行finalize()方法。
  如果我们强制使用System.gc()来通知系统进行垃圾回收,结果如何?

public static void main(String[] args) {
    A a = getA(1);
    System.out.println("main() 方法内:" + a);
    foo();
    System.gc();
}

输出结果

getA() 方法内:com.rhythm7.A@29453f44
main() 方法内:com.rhythm7.A@29453f44
foo() 方法内:com.rhythm7.A@5cad8086
对象即将被销毁: com.rhythm7.A@5cad8086; arg = 2

  这说明,需要通知垃圾回收器进行进行垃圾回收才能回收方法foo()内实例化的对象。 所以,可以肯定foo()内实例化的对象不会跟随foo()方法的出栈而销毁,也就是foo()方法内实例化的局部对象不会是分配在栈上的。

查阅相关资料,发现JVM的确存在一个 “逃逸分析” 的概念。
内容大概如下:
  逃逸分析是目前Java虚拟机中比较前沿的优化技术,它并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。
逃逸分析的主要作用就是分析对象作用域。
  当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种行为就叫做 方法逃逸。甚至该对象还可能被外部线程访问到,例如赋值被类变量或可以在其他线程中访问的实例变量,称为 线程逃逸
  通过逃逸分析技术可以判断一个对象不会逃逸到方法或者线程之外。根据这一特点,就可以让这个对象在栈上分配内存,对象所占用的内存空间就可以随帧栈出栈而销毁。在一般应用中,不会逃逸的局部对象所占比例很大,如果能使用栈上分配,那么大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力就会小很多。
  除此之外,逃逸分析的作用还包括 标量替换同步消除 ;
   标量替换 指:若一个对象被证明不会被外部访问,并且这个对象可以被拆解成若干个基本类型的形式,那么当程序真正执行的时候可以不创建这个对象,而是采用直接创建它的若干个被这个方法所使用到的成员变量来代替,将对象拆分后,除了可以让对象的成员变量在栈上分配和读写之外,还可以为后续进一步的优化手段创造条件。
   同步消除 指:若一个变量被证明不会逃逸出线程,那么这个变量的读写就肯定不会出现竞争的情况,那么对这个变量实施的同步措施也就可以消除掉。
   说了逃逸分析的这些作用,那么Java虚拟机是否有对对象做逃逸分析呢?

  答案是否。

  关于逃逸分析的论文在1999年就已经发表,但直到Sun JDK 1.6才实现了逃逸分析,而且直到现在这项优化尚未足够成熟,仍有很大的改进余地。不成熟的原因主要是不能保证逃逸分析的性能收益必定高于它的消耗。因为逃逸分析本身就是一个高耗时的过程,假如分析的结果是没有几个不逃逸的对象,那么这个分析所花费时候比优化所减少的时间更长,这是得不偿失的。
  所以目前虚拟机只能采用不那么准确,但时间压力相对较小的算法来完成逃逸分析。还有一点是,基于逃逸分析的一些优化手段,如上面提到的“栈上分配”,由于HotSpot虚拟机目前的实现方式导致栈上分配实现起来比较复杂,因此在HotSpot中暂时还没有做这项优化
事实上,在java虚拟机中,有一句话是这么写的:

The heap is the runtime data area from which memory for all class instances and arrays is allocated。
堆是所有的对象实例以及数组分配内存的运行时数据区域。

  所以,忘掉Java栈上分配对象内存的想法吧,至少在目前的HotSpot中是不存在的。也就是说Java的对象分配只在堆上。

PS: 如果有需要,并且确认对程序运行有益,用户可以使用参数-XX:+DoEscapeAnalysis来手动开启逃逸分析,开启之后可以通过参数-XX:+PrintEscapeAnalysis来查看分析结果。