所有对象实例都分配在堆中吗?

1,381 阅读5分钟

引言

在《Java虚拟机规范》中,堆Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配”。其实这句话这样说也没有问题,但实际上,由于即时编译技术的进步,尤其是逃逸技术的发展,栈上内存、标量替换等优化手段使得对象的存储可以在堆中。

本文将通过该问题为引入点,重点介绍逃逸分析技术以及其对应的优化手段。

逃逸分析技术

逃逸分析技术是一直比较前沿的分析技术,他并不会直接优化代码,而是为其它代码优化手段提供依据。 逃逸分析可以将对象由高到底的不同逃逸程度分为三类:从不逃逸、方法逃逸、线程逃逸。

线程逃逸

如果一个线程内的对象有可能被其它线程访问到,那么这个对象就行线程逃逸的。

方法逃逸

如果一个对象在方法内部创建后,可能被其它方法所引用,例如通过参数传递给其它方法,又或者作为方法返回值返回给其它方法,这就叫方法逃逸

从不逃逸

如果一个对象在方法内部创建后,不可能被其它方法所引用,也不可能被其它线程所引用,那么它就是从不逃逸的。

优化手段

根据逃逸分析技术得出的对象分析情况,我们可以对不同逃逸程度的对象采取不同程度的优化。

栈上分配

首先我们来明确下堆和栈的区别。对于堆来说,它是所有线程所共享的,存在垃圾回收机制。而对于栈来说,每个线程都有自己的虚拟机栈,而线程每执行一个方法就会在栈中创建一个栈帧,方法调用结束相应的栈帧会被销毁,当线程结束运行时对应的栈也会被销毁,所以说栈是不需要垃圾回收的。

而正如我们开头说的,对象实例以及数组应当在堆上分配。并且由于堆对于所有线程是共享的,所以说只要线程持有某对象的引用,就可以使用该对象。而当一个对象不会再被使用的时候,就会通过垃圾回收机制进行回收,然后垃圾回收其实是要浪费很多资源的。而对于不会发生线程逃逸的对象来说,它是不需要线程共享使用的,那么将这个对象在栈上分配内存或许会更好一些,因为对于栈来说它占用的空间是可以随着栈帧出栈而销毁的,就不需要通过垃圾回收机制进行回收。

也就是说,对这种不会发生线程逃逸的对象,在栈空间上分配内存的情况,就叫栈上分配。而不会发生线程逃逸的对象占比是很高的,如果可以采用栈上分配的方式,那么垃圾回收系统的压力就会小很多。

标量替换

标量和聚合量

首先介绍下标量和聚合量:

  • 标量就是指那些已经无法再分分解为更小数据来表示的数据,比如Java虚拟机中的原始数据(int、double等)。
  • 如标量相反,如果一个数据可以被分解为更小的数据,难么它就是聚合量,比如对象就是一种典型的聚合量。

标量替换

那所谓的标量替换,就是在某些情况下,将聚合量拆分为多个标量,即将一个对象根据程序的访问情况,将其用到的成员变量恢复为原始类型来访问的过程。

引用周志明老师《深入理解JVM虚拟机》中的一段话:

假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可拆散,那么程序真正执行的时候将可能不去创建这个
对象,而改为直接创建它的诺干个被这个方法使用的成员变量来代替,将对象拆分后,除了可以让对象的成员变量在栈上(
栈上存储的数据,很大机会会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写外,还可以为后续进一步的优化创
造条件。

标量替换可以看作栈上分配的一种特殊情况,但是标量替换是不允许方法逃逸的,而栈上分配接收方法逃逸且不接受线程逃逸。

同步消除

如果你有了解过并发编程,那么应该清楚,当一个变量可以被多个线程同时访问时,那么就可能会存在线程安全问题。这时候我们往往会采取一些同步措施来预防安全问题。

但是,如果我们通过逃逸技术分析得到一个对象是不会发生线程逃逸的,又因为单核CPU同时只能执行一条指令,所以该对象一定是线程安全的,所以我们为该对象加的同步措施就可以安全的消除掉(同步措施往往会消耗很多资源),这就叫同步消除。

总结

本文主要介绍了逃逸分析技术以及其对应的优化。而实际上,逃逸分析的计算成本往往是非常高的,很多时候我们并不能保证逃逸分析带来的收益会高于其消耗,所以目前的虚拟机只能采用不那么准确、相对简单的算法进行逃逸分析。

另外,回到我们题目中的问题,因为有了栈上分配和标量替换的存在,使得并不是所有对象实例都会分配到堆中,更准确的说法应该是:几乎所有的对象实例都会分配到堆中。