数组中的对象未被垃圾回收

7

我正在测试一个使用弱引用确保对象能够被垃圾回收的类,但我发现即使列表不再被引用,List<>中的对象也永远不会被回收。这也适用于简单的数组。下面的代码片段展示了一个简单的失败测试。

class TestDestructor
{
    public static bool DestructorCalled;

    ~TestDestructor()
    {
        DestructorCalled = true;
    }
}

[Test]
public void TestGarbageCollection()
{
    TestDestructor testDestructor = new TestDestructor();

    var array = new object[] { testDestructor };
    array = null;

    testDestructor = null;

    GC.Collect();
    GC.WaitForPendingFinalizers();

    Assert.IsTrue(TestDestructor.DestructorCalled);
}

如果省略数组的初始化,测试就会通过。

为什么数组中的对象没有被垃圾回收?


2
只是猜测,数组是否真的被创建了?也许它已经被优化掉了?在销毁对象之前,您能否对其进行一些操作以确保它存在? - VoidStar
1
你也应该尝试在析构函数内部放置一个 Console.WriteLine。 - Tudor
这与代数有关吗?在垃圾回收测试中,即使数组被取消引用,testDestructor仍然作为数组的一个元素存在,因此在本周期内不会被回收。如果进行两次回收会发生什么? - Bob Vale
2
@Jon:从简要检查IL代码来看,在发布模式下,C#编译器似乎完全省略了数组分配。 - Ani
@Ani:确实很有趣。不过请注意,我得到的结果是一样的 :) - Jon Skeet
显示剩余3条评论
5个回答

4

编辑:好的,我在这方面有些进展。可能涉及到三个二进制开关(至少):

  • 代码是否经过优化;即命令行中的 /o+/o- 标志。看起来没有任何区别。
  • 代码是否在调试器中运行。看起来也没有任何区别。
  • 生成的调试信息级别,即命令行标志 /debug+/debug-/debug:full/debug:pdbonly。只有 /debug+/debug:full 会导致其失败。

此外:

  • 如果将 Main 代码与 TestDestructor 代码分开,则可以确定是 Main 代码的编译模式造成了差异。
  • 据我所知,/debug:pdbonly 生成的 IL 与 /debug:full 在方法本身内部生成的 IL 相同,因此可能是一个清单问题...

编辑:好吧,现在情况真的很奇怪。如果我反汇编 “错误” 版本,然后重新汇编它,它就能正常工作:

ildasm /out:broken.il Program.exe
ilasm broken.il

ilasm有三个不同的调试设置: /DEBUG, /DEBUG=OPT/DEBUG=IMPL。使用前两个会失败,使用最后一个则可以。最后一个被描述为启用即时编译优化,因此这可能是造成差异的原因......尽管在我看来,它应该仍然能够以任何一种方式收集对象。


这可能是由于DestructorCalled中的内存模型问题。它不是易失性的,因此无法保证来自终结器线程的写入将被测试线程“看到”。

在这种情况下,确实会调用终结器。在使变量易失性后,这个独立的等效示例(对我来说更简单)当然会打印True。当然,这并不是证明:没有volatile,代码不能保证失败; 它只是不保证工作。您能否在将其设置为易失性变量后使测试失败?

using System;

class TestDestructor
{
    public static volatile bool DestructorCalled;

    ~TestDestructor()
    {
        DestructorCalled = true;
    }
}

class Test
{
    static void Main()
    {
        TestDestructor testDestructor = new TestDestructor();

        var array = new object[] { testDestructor };
        array = null;

        testDestructor = null;

        GC.Collect();
        GC.WaitForPendingFinalizers();
        Console.WriteLine(TestDestructor.DestructorCalled);
    }
}

编辑:我刚刚发现在使用Visual Studio构建时,这个操作失败了,但是在命令行中运行没有问题。现在我正在研究IL(Intermediate Language)。


3
我认为SO应该在投反对票时强制要求留下评论。 - Cheng Chen
1
在发布模式下,即使没有使用volatile,它也会打印true,所以你的理论似乎是错误的。(顺便说一句,我不是那个点踩的人 :) ) - Stilgar
@Stilgar:它可能会打印True,但不能保证。这就是重点-这就是我说“没有保证”的原因。稍微编辑一下以澄清一下。 - Jon Skeet
1
添加volatile对我没有影响。它仍然在调试模式下失败,在发布模式下通过。 - Paul Haley
@PaulHaley:好的,谢谢确认。我回答中提到的独立应用对你有什么作用?哦,你正在使用哪个版本的.NET,以及在什么架构上?是否有任何有趣的GC开关(如并行GC等)? - Jon Skeet
显示剩余9条评论

2

另外一点修改:如果数组在Main()方法作用域中定义,结果将始终为false;但如果在Class-Test作用域中定义,则结果将为true。也许这不是件坏事。

class TestDestructor
{
    public TestDestructor()
    {
        testList = new List<string>();
    }

    public static volatile bool DestructorCalled;

    ~TestDestructor()
    {
        DestructorCalled = true;
    }

    public string xy = "test";

    public List<string> testList;

}

class Test
{
    private static object[] myArray;

    static void Main()
    {
        NewMethod();            
        myArray = null;

        GC.Collect();
        GC.WaitForPendingFinalizers();
        Console.WriteLine(TestDestructor.DestructorCalled);
        Console.In.ReadToEnd();
    }

    private static void NewMethod()
    {
        TestDestructor testDestructor = new TestDestructor() { xy = "foo" };
        testDestructor.testList.Add("bar");
        myArray = new object[] { testDestructor };
        Console.WriteLine(myArray.Length);
    }
}

调试模式会将变量和对象保留更长时间(直到方法结束),以便可以使用调试器进行检查。 - Stilgar
如果我运行Jon Skeet的程序,它在Debug模式下失败,在Release模式下成功。再次使用VS 2010。 - Jobo
的确很奇怪但却是真的。这就引出了一个问题 - 我该如何在调试模式下让测试通过,因为那是我们通常运行测试的方式? - Paul Haley
我的更新答案返回true。但现在我不确定这是否澄清了一切。如果从Main()方法引用数组或TestDestructor实例,即使“nullified”,它仍然返回false。 - Jobo
1
好的,数组现在已经超出了范围,所以这并不令人惊讶。 - Stilgar
显示剩余4条评论

1

正如Ani在评论中指出的那样,整个数组在发布模式下被优化掉了,因此我们应该将代码更改为以下形式:

class TestDestructor
{
    public static bool DestructorCalled;
    ~TestDestructor()
    {
        DestructorCalled = true;
    }
}

class Test
{
    static void Main()
    {
        TestDestructor testDestructor = new TestDestructor();

        var array = new object[] { testDestructor };
        Console.WriteLine(array[0].ToString());
        array = null;

        testDestructor = null;

        GC.Collect();
        GC.WaitForPendingFinalizers();
        Console.WriteLine(TestDestructor.DestructorCalled);
    }
} 

对我来说它可以工作(没有使用volatile),并且总是打印True。有人能否确认在发布模式下未调用终结器,否则我们可以假设它与调试模式有关。


0

如果我没记错的话,这是因为当对象加载到数组中时,它实际上被复制并与其初始创建分离。然后,当您销毁数组和原始对象时,复制到数组中的对象仍然存在。

垃圾回收应该最终会完成其工作,但我知道您正在尝试强制清除资源。我建议先清空数组(删除对象),然后再销毁它,看看是否可以清除所有内容。


请注意,assert针对的是静态成员,因此任何两个垃圾回收中的一个都应该触发它。上面的代码应该可以运行。 - Tudor

0

这就是文档所说的:

实现Finalize方法或析构函数可能会对性能产生负面影响,应避免不必要地使用它们。使用Finalize方法回收对象所占用的内存需要至少两次垃圾回收。[...] 未来的垃圾回收将确定已完成终结的对象确实是垃圾,因为它们不再被标记为准备完成终结的对象列表中的条目所引用。在此未来的垃圾回收中,对象的内存实际上被回收。

尝试使用一种处理机制而非Finalize方法,以查看会发生什么。


它是相关的,因为假设需要几个垃圾收集器来运行终结器,代码应该调用GC.Collect两次。我的实验表明,这并没有起到帮助作用。 - Stilgar

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