当将null赋值给引用变量时的GC行为

19

我试图理解垃圾回收的行为,发现了一些有趣的东西,但是我无法理解。

请看代码和输出:

public class GCTest {
    private static int i=0;

    @Override
    protected void finalize() throws Throwable {
        i++; //counting garbage collected objects
    }

    public static void main(String[] args) {        
        GCTest holdLastObject; //If I assign null here then no of eligible objects are 9 otherwise 10.

        for (int i = 0; i < 10; i++) {            
             holdLastObject=new GCTest();             
        }

        System.gc(); //requesting GC

        //sleeping for a while to run after GC.
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // final output 
        System.out.println("`Total no of object garbage collected=`"+i);          
    }
}

如果我将holdLastObject赋值为null,则上面的示例中会得到垃圾回收的对象总数=9。如果不这样做,我会得到10

有人能解释一下吗?我无法找到正确的原因。


你应该将测试提取到一个单独的函数中,并在循环中执行。JIT 最终会优化掉各种东西,例如通过逃逸分析。单次运行不足以观察所有效果。 - the8472
3个回答

11

检查字节码可以帮助我们找到答案。

正如Jon Skeet所提到的,当你将null赋值给局部变量时,这是一次明确的赋值,而且javac必须在main方法中创建一个局部变量,正如字节码所证明的那样:

// access flags 0x9
public static main([Ljava/lang/String;)V
  TRYCATCHBLOCK L0 L1 L2 java/lang/InterruptedException
 L3
  LINENUMBER 12 L3
  ACONST_NULL
  ASTORE 1
在这种情况下,局部变量将保留最后赋值的值,并且只有在超出范围时才可以进行垃圾回收。由于它是在main中定义的,因此仅当程序终止时才会超出作用域,在打印i时,它没有被收集。
如果您给它赋值,由于它从未在循环外使用过,javac将其优化为for循环作用域中的局部变量,当然可以在程序终止之前收集。
对于这种情况,检查字节码表明,LINENUMBER 12的整个块都不见了,从而证明了这个理论的正确性。
注意:
据我所知,这种行为不是由Java标准定义的,可能会因javac实现而异。我观察到的版本是:
mureinik@computer ~/src/untracked $ javac -version
javac 1.8.0_31
mureinik@computer ~/src/untracked $ java -version
openjdk version "1.8.0_31"
OpenJDK Runtime Environment (build 1.8.0_31-b13)
OpenJDK 64-Bit Server VM (build 25.31-b07, mixed mode)

9
我怀疑这是由于明确的赋值问题。
如果在循环之前给holdLastObject分配一个值,它将在整个方法中(从声明点开始)被确定分配 - 因此即使您在循环后不访问它,GC也会理解您可能编写访问它的代码,因此不会终止最后一个实例。
由于在循环之前没有为变量分配值,除了循环内部,它并未被确定分配 - 因此我怀疑GC将其视为在循环中声明 - 它知道循环后没有代码可以从该变量读取(因为它没有被确定分配),因此它知道可以终止并收集最后一个实例。
只是为了澄清我的意思,如果您添加:
System.out.println(holdLastObject);

System.gc()这一行之前,你会发现第一个例子(没有赋值语句的情况)无法编译。

我怀疑这是虚拟机的细节问题 - 我希望如果垃圾回收器能够证明没有任何代码实际上要从局部变量读取数据,那么它可以合法地收回最终的实例(即使目前并没有实现这种方式)。

编辑:与TheLostMind的答案相反,我认为编译器将此信息提供给JVM。使用javap -verbose GCTest,我发现在没有赋值的情况下出现了这个错误:

  StackMapTable: number_of_entries = 4
    frame_type = 253 /* append */
      offset_delta = 2
      locals = [ top, int ]
    frame_type = 249 /* chop */
      offset_delta = 19
    frame_type = 75 /* same_locals_1_stack_item */
      stack = [ class java/lang/InterruptedException ]
    frame_type = 4 /* same */

还有这个分配的问题:

  StackMapTable: number_of_entries = 4
    frame_type = 253 /* append */
      offset_delta = 4
      locals = [ class GCTest, int ]
    frame_type = 250 /* chop */
      offset_delta = 19
    frame_type = 75 /* same_locals_1_stack_item */
      stack = [ class java/lang/InterruptedException ]
    frame_type = 4 /* same */

请注意第一个条目中的locals部分的区别。有趣的是,在没有初始赋值的情况下,class GCTest 条目不会出现 任何地方...


带有null --> 0: aconst_null 1: astore_1不带null --> 0: iconst_0 1: istore_2 - TheLostMind
@TheLostMind:是的,但在循环中这样做和在循环外部这样做有什么区别,例如就字节码而言? - Jon Skeet
什么都没有...我检查了...没有其他的区别。:P - TheLostMind
1
@TheLostMind:只是在许多情况下,这种区别并不重要。对我来说,大部分都是一个大黑盒子。(同样,JIT的边界在哪里?)我并不在意 - 而其他信息,比如JVM是否推断这些信息,或者它是否使用字节码中的内容,才是更具体的区别。无论如何,我认为我们应该在这一点上停止聊天... - Jon Skeet
1
@Amitd:在运行发布模式构建且不在调试器下时,.NET JIT 可能会非常积极 - 它会注意到当没有更多访问本地变量时并将其忽略为 GC 根。它甚至可以在实例方法仍在该对象中运行时收集对象,如果它可以证明没有代码路径可以读取字段... - Jon Skeet
显示剩余5条评论

6
我并未发现两种情况的字节码有任何重大差异(因此不值得在此处发布字节码)。因此,我认为这是由JIT / JVM优化引起的假设
解释:
-1 情况:
public static void main(String[] args) {
  GCTest holdLastObject; //If I assign null here then no of eligible objects are 9 otherwise 10.
     for (int i = 0; i < 10; i++) {
         holdLastObject=new GCTest();
    }
    //System.out.println(holdLastObject); You can't do this here. holdLastObject might not have been initialized.
     System.gc(); //requesting GC
}

请注意,您没有将holdLastObject初始化为null。因此,在循环外部无法访问它(会产生编译时错误)。这意味着*jvm认为该字段在后面的部分中未被使用。Eclipse会提示您相关信息。因此,*在循环内部创建和销毁了10个对象。

情况-2:

 public static void main(String[] args) {
      GCTest holdLastObject=null; //If I assign null here then no of eligible objects are 9 otherwise 10.
         for (int i = 0; i < 10; i++) {
             holdLastObject=new GCTest();
        }
        //System.out.println(holdLastObject); You can't do this here. holdLastObject might not have been initialized.
         System.gc(); //requesting GC
    }

在这种情况下,由于该字段被初始化为null,它是在循环外创建的,因此一个null引用被推送到它在本地变量表中的位置。因此,JVM理解该字段可以从外部访问,因此它不会销毁最后一个实例,而是使其保持活动状态,因为它仍然是可访问/可读的。因此,除非您显式地将最后一个引用的值设置为null,否则它存在且可达。因此,将有9个实例准备进行垃圾回收。

很奇怪的是JVM没有意识到变量在循环后根本没有被使用,尽管它可以做很多事情,但它真的无法发现循环后没有读取该字段吗? - Jon Skeet
@JonSkeet - 好的。在第二种情况下,发生这种情况的可能性是存在的。我没有检查测试时是否启用了逃逸分析。必须通过逃逸分析来查看结果。 - TheLostMind
@TheLostMind:不在字节码中-它肯定可以分析并确定没有可能读取该字段的路径。我刚刚使用了javap -verbose,发现还有另一个差异,在StackMapTable中。仍在调查它的含义。 - Jon Skeet
@JonSkeet - 你用的是哪个版本的Java?这很重要 :P。在这里查看:http://stackoverflow.com/questions/27739474/bug-in-local-variable-table-construction-when-using-javap-v - TheLostMind

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接