关于不再使用的Java对象手工指null是否有意义

在《Practical Java》中,有这样一条实践:“一旦不再需要object references,就将它设为null”

我开始职业生涯今年已经是第7年了。在6年中,网上也好,同事之间也好,关于Java的方法中,局部变量在使用完之后是否需要手工指null,存在大量争论。

有人说手工指null 没有意义,也有人说可以“暗示”JVM尽快垃圾回收。


直到我研究《深入理解Java虚拟机——JVM高级特性与最佳实践》这本书之后,这个疑问终于有了最终解释。


学习Java第一课时,一般的师父简单和学生讲:当一个对象不再被任何变量引用时,它被GC。

聪明一点的学生会提问,JVM怎么判断一个对象是否存活。

水平高一点的师父简单讲一下“可达性分析算法”的内容:这个算法的基本思路就是通过一系列的成为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为“引用链”,当一个对象到GC Roots没有任何引用链相连的话,则证明此对象是不可用的。用图论的话来说,就是从GC Roots到这个对象不可达。


一般来说,解释到此已经足够了,然而,JVM是怎么判断对象是否正在被引用呢?

我今天就是要来讲讲其中的细节。首先来看看代码。

public class GCTest {

	public static void main(String[] args) {
		{
			byte[] waste = new byte[6 * 1024 * 1024];
		}
		System.gc();
	}
}
程序清单1

让我们在执行时,加上以下参数:

-verbose:gc -Xmx22m -Xms22m

GC结果为:

[GC 6708K->6432K(22528K), 0.0021090 secs]
[Full GC 6432K->6369K(22528K), 0.0116380 secs]

如果诸位能明白我能表达什么的话,这是一件非常奇怪的事情。

从代码逻辑上讲,在System.gc()之前,waste对象已经不能再被访问了,理应被GC,但是这是为什么呢?

然而,这不是最奇怪的,请诸位且看一下代码:

public class GCTest {

	public static void main(String[] args) {
		{
			byte[] waste = new byte[6 * 1024 * 1024];
		}
		int a = 1;
		System.gc();
	}
}

程序清单2


GC结果为:

[GC 6575K->272K(22528K), 0.0030020 secs]
[Full GC 272K->225K(22528K), 0.0228170 secs]

神奇事情发生了,看似无关的“int a = 1”指令,对waste对象的回收产生了影响。

当我第一次看到这个结果的时候,真是毛骨悚然。


要解释这一切,先让我来解释一下class文件的局部变量表。

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,其中存放的数据的类型是编译期可知的各种基本数据类型、对象引用(reference)和 returnAddress 类型(它指向了一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,即在 Java 程序被编译成 Class 文件时,就确定了所需分配的最大局部变量表的容量。当进入一个方法时,这个方法需要在栈中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

局部变量表的容量以变量槽(Slot)为最小单位。在虚拟机规范中并没有明确指明一个 Slot 应占用的内存空间大小(允许其随着处理器、操作系统或虚拟机的不同而发生变化),一个 Slot 可以存放一个32位以内的数据类型:boolean、byte、char、short、int、float、reference 和 returnAddresss。reference 是对象的引用类型,returnAddress 是为字节指令服务的,它执行了一条字节码指令的地址。对于 64 位的数据类型(long和double),虚拟机会以高位在前的方式为其分配两个连续的 Slot 空间。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从 0 开始到局部变量表最大的 Slot 数量,对于 32 位数据类型的变量,索引 n 代表第 n 个 Slot,对于 64 位的,索引 n 代表第 n 和第 n+1 两个 Slot。

在方法执行时,虚拟机是使用局部变量表来完成参数值到参数变量列表的传递过程的,如果是实例方法(非static),则局部变量表中的第 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数。其余参数则按照参数表的顺序来排列,占用从1开始的局部变量 Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的 Slot。

局部变量表中的 Slot 是可重用的,方法体中定义的变量,作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那么这个变量对应的 Slot 就可以交给其他变量使用。这样的设计不仅仅是为了节省空间,在某些情况下 Slot 的复用会直接影响到系统的而垃圾收集行为。


真正重要的段落,我已经用粗体字标出,程序清单2中的奇特现象,是由局部变量表中slot的重用引起的。请大家对比两段程序清单的字节码分析后的结果:

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=2, args_size=1
         0: ldc           #16                 // int 6291456
         2: newarray       byte
         4: astore_1
         5: invokestatic  #17                 // Method java/lang/System.gc:()V
         8: return
      LineNumberTable:
        line 5: 0
        line 7: 5
        line 8: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=2, args_size=1
         0: ldc           #16                 // int 6291456
         2: newarray       byte
         4: astore_1
         5: iconst_1
         6: istore_1
         7: invokestatic  #17                 // Method java/lang/System.gc:()V
        10: return
      LineNumberTable:
        line 5: 0
        line 7: 5
        line 8: 7
        line 9: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  args   [Ljava/lang/String;
            7       4     1     a   I

大家会发现,尽管前后两此分析后,LocalVariableTable 是不同的,但是locals的数量都等于2,也就是说a变量和waste变量复用了同一个slot。

在程序清单1和2中,waste能否被回收的根本原因是:局部变量表中的slot是否还存在有关于waste数组对象的引用。程序清单1中,代码虽然已经离开了waste的作用域,但在此之后,没有任何对局部变量表的读写操作,waste原本所占用的slot还没有被其他的变量所复用,所以作为GC Roots一部分的局部变量表仍然保持这对他的关联。这种关联没有被及时打断,在绝大部分情况下情况下都是轻微的。但是如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面有定义了占用大量内存、实际上已经不会再使用的对象,手动将其设置为null便不见得是一个绝对无意义的操作,这种操作可以作为一种在极其特殊情形下(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到JIT的编译条件)下的“奇技”来使用。


让我们看看手工指null的效果:

public class GCTest {

	public static void main(String[] args) {
		{
			byte[] waste = new byte[6 * 1024 * 1024];
			waste = null;
		}
		System.gc();
	}
}

GC的结果不出所料:

[GC 6575K->272K(22528K), 0.0366480 secs]
[Full GC 272K->225K(22528K), 0.0151030 secs]


当然,我的结论本非如此。


以上的实例说明赋null值的操作在某些情况下确实是有意义的。但是我以及《深入理解Java虚拟机》的作者都认为不应当对赋null值得操作有过多的依赖,更没有必要把它当作一个普遍的编码规则来推广。原因有两点:

  • 从编码角度来说,以恰当的变量作用域来控制变量回收的时间才是最优雅的解决方法,以上的场景并不多见。
  • 更关键的是,从执行角度讲,使用赋null值的操作来优化内存回收是建立在对字节码执行引擎概念模型的理解之上的。在虚拟机使用解释器执行时,通常与概念模型还比较接近,但经过JIT编译器后,才是虚拟机执行代码的主要方式,赋null值的操作在经过JIT编译优化后就会被消除掉,这时候将变量设置为null是没有意义的。

最后看个示例,我们虚拟机参数调整为如下所示:

-verbose:gc -Xmx22m -Xms22m -Xcomp


-Xcomp参数表示,JVM在第一次使用时会把所有的字节码编译成本地代码,从而带来最大程度的优化。

此时,GC的日志可以看出手工对变量赋null便不再有意义。

[GC 6841K->6488K(22528K), 0.0018050 secs]
[Full GC 6488K->253K(22528K), 0.0114480 secs]



  • 5
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值