为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。不过,这样的设计除了节省栈帧空间以外,还会伴随有少量额外的副作用,例如在某些情况下变量槽的复用会直接影响到系统的垃圾收集行为。
public static void main(String[] args)() {
byte[] placeholder = new byte[64 * 1024 * 1024];
System.gc();
}
代码很简单,向内存填充了64MB的数据,然后通知虚拟机进行垃圾收集。我们在虚拟机运行参数中加上“-verbose:gc”来看看垃圾收集的过程,发现在System.gc()运行后并没有回收掉这64MB的内存
[GC 66846K->65824K(125632K), 0.0032678 secs]
[Full GC 65824K->65746K(125632K), 0.0064131 secs]
代码没有回收掉placeholder所占的内存是能说得过去,因为在执行System.gc()时,变量placeholder还处于作用域之内,虚拟机自然不敢回收掉placeholder的内存。那我们把代码修改一下
public static void main(String[] args)() {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();
}
加入了花括号之后,placeholder的作用域被限制在花括号以内,从代码逻辑上讲,在执行
System.gc()的时候,placeholder已经不可能再被访问了,但执行这段程序,会发现运行结果如下,还是有64MB的内存没有被回收掉,这又是为什么呢?
[GC 66846K->65888K(125632K), 0.0009397 secs]
[Full GC 65888K->65746K(125632K), 0.0051574 secs]
在解释为什么之前,我们先对这段代码进行第二次修改,在调用System.gc()之前加入一行“int
a=0;”
public static void main(String[] args)() {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 0;
System.gc();
}
这个修改看起来很莫名其妙,但运行一下程序,却发现这次内存真的被正确回收了:
[GC 66401K->65778K(125632K), 0.0035471 secs]
[Full GC 65778K->218K(125632K), 0.0140596 secs]
placeholder能否被回收的根本原因就是:局部变量表中的变量槽是否还存有关于placeholder数组对象的引用。第一次修改中,代码虽然已经离开了placeholder的作用域,但在此之后,再没有发生过任何对局部变量表的读写操作,placeholder原本所占用的变量槽还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。这种关联没有被及时打断,绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存但实际上已经不会再使用的变量,手动将其设置为null值(用来代替那句int a=0,把变量对应的局部变量槽清空)便不见得是一个绝对无意义的操作,这种操作可以作为一种在极特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到即时编译器的编译条件)下的“奇技”来使用。Java语言的一本非常著名的书籍《Practical Java》中将把“不使用的对象应手动赋值为null”作为一条推荐的编码规则,但是并没有解释具体原因。
示例说明了赋null操作在某些极端情况下确实是有用的,但是不应当对赋null值操作有什么特别的依赖,更没有必要把它当作一个普遍的编码规则来推广。原因有两点,从编码角度讲,以恰当的变量作用域来控制变量回收时间才是最优雅的解决方法,如int a = 0那样的场景除了做实验外几乎毫无用处。更关键的是,从执行角度来讲,使用赋null操作来优化内存回收是建立在对字节码执行引擎概念模型的理解之上的。当虚拟机使用解释器执行时,通常与概念模型还会比较接近,但经过即时编译器施加了各种编译优化措施以后,两者的差异就会非常大,只保证程序执行的结果与概念一致。在实际情况中,即时编译才是虚拟机执行代码的主要方式,赋null值的操作在经过即时编译优化后几乎是一定会被当作无效操作消除掉的,这时候将变量设置为null就是毫无意义的行为。字节码被即时编译为本地代码后,对GC Roots的枚举也与解释执行时期有显著差别,以前面的例子来看,经过第一次修改的代码在经过即时编译后,System.gc()执行时就可以正确地回收内存,根本无须写成int a = 0的样子。