.NET垃圾回收器是否执行代码的预测分析?

31

好的,我意识到这个问题可能看起来很奇怪,但我刚刚注意到了一些让我感到困惑的事情...看看这段代码:

static void TestGC()
{
        object o1 = new Object();
        object o2 = new Object();
        WeakReference w1 = new WeakReference(o1);
        WeakReference w2 = new WeakReference(o2);

        GC.Collect();

        Console.WriteLine("o1 is alive: {0}", w1.IsAlive);
        Console.WriteLine("o2 is alive: {0}", w2.IsAlive);
}

由于在垃圾收集发生时,o1o2仍然在作用域内,我本应该期望以下输出:

o1 is alive: True
o2 is alive: True

但是相反的,这是我得到的结果:

o1 is alive: False
o2 is alive: False

注意:只有在编译Release模式并在调试器外运行代码时才会发生此情况。

我猜测GC检测到o1o2在超出作用域之前不会再次使用,因此提前收集它们。为了验证这个假设,在TestGC方法的末尾添加了以下行:

string s = o2.ToString();

我得到了以下输出:

o1 is alive: False
o2 is alive: True

所以在这种情况下,o2 没有被回收。

有人可以解释一下发生了什么吗?这与JIT优化有关吗?到底发生了什么?


5
无论答案如何,这都很酷。 - jdmichal
4个回答

23
垃圾收集器依赖于JIT编译器提供的信息,告诉它哪些代码地址范围内的变量和"things"仍在使用中。因此,在您的代码中,由于不再使用对象变量,GC可以自由地收集它们。WeakReference无法防止这种情况发生,实际上,这就是WR的全部意义,允许您保留对对象的引用,同时不会阻止其被收集。关于WeakReference对象的案例在MSDN上的一行描述中得到了很好的总结:“表示弱引用,它引用一个对象,同时仍然允许垃圾回收器回收该对象。” WeakReference对象不会被垃圾收集,因此您可以安全地使用它们,但是它们所引用的对象只剩下WR引用,因此可以自由收集。在通过调试器执行代码时,变量在作用域内人为地延长到其作用域结束,通常是它们声明的块的末尾(如方法),以便您可以在断点处检查它们。这里有一些微妙的事情需要发现。请考虑以下代码:
using System;

namespace ConsoleApplication20
{
    public class Test
    {
        public int Value;

        ~Test()
        {
            Console.Out.WriteLine("Test collected");
        }

        public void Execute()
        {
            Console.Out.WriteLine("The value of Value: " + Value);

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

            Console.Out.WriteLine("Leaving Test.Execute");
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            Test t = new Test { Value = 15 };
            t.Execute();
        }
    }
}

在 Release 模式下,没有调试器附加时,输出如下:
Value 的值:15
已收集测试
离开 Test.Execute
原因是尽管您仍在执行与 Test 对象相关联的方法,但在要求 GC 执行其操作时,不需要任何 Test 实例引用(没有对 this 或 Value 的引用),也没有调用任何实例方法剩余可执行,所以对象可以安全地进行回收。
如果您不知道这一点,可能会产生一些严重的副作用。
请考虑以下类:
public class ClassThatHoldsUnmanagedResource : IDisposable
{
    private IntPtr _HandleToSomethingUnmanaged;

    public ClassThatHoldsUnmanagedResource()
    {
        _HandleToSomethingUnmanaged = (... open file, whatever);
    }

    ~ClassThatHoldsUnmanagedResource()
    {
        Dispose(false);
    }

    public void Dispose()
    {
        Dispose(true);
    }

    protected virtual void Dispose(bool disposing)
    {
        (release unmanaged resource here);
        ... rest of dispose
    }

    public void Test()
    {
        IntPtr local = _HandleToSomethingUnmanaged;

        // DANGER!

        ... access resource through local here
    }

在这一点上,如果Test在获取非托管句柄的副本后不使用任何实例数据,那会怎样呢?如果GC现在在我写下“DANGER”的地方运行,你看到这是怎么回事了吗?当GC运行时,它将执行finalizer,这将从仍在执行的Test中拔出对非托管资源的访问权限。
非托管资源通常通过IntPtr或类似方式访问,对于垃圾收集器来说是不透明的,并且在判断对象的生命周期时不考虑这些资源。
换句话说,我们在本地变量中保留句柄的引用对于GC来说毫无意义,它只会注意到没有实例引用剩余,因此认为对象是安全可收集的。
当然,这假设没有外部引用仍被认为是“活着”的对象。例如,如果上述类是从以下方法中使用的:
public void DoSomething()
{
    ClassThatHoldsUnmanagedResource = new ClassThatHoldsUnmanagedResource();
    ClassThatHoldsUnmanagedResource.Test();
}

那么你也会遇到完全相同的问题。

(当然,你可能不应该像这样使用它,因为它实现了IDisposable接口,你应该使用using块或手动调用Dispose方法。)

编写上述方法的正确方式是确保在我们仍需要对象时,GC不会将其收集:

public void Test()
{
    IntPtr local = _HandleToSomethingUnmanaged;

    ... access resource through local here

    GC.KeepAlive(this); // won't be collected before this has executed
}

@Lasse,+1,非常有指导性的答案。我会等一会儿再接受它,以防出现额外的信息;)。@jdmichal,确实,显然它们被忽略了,因为我在GC.Collect之后使用了WRs。 - Thomas Levesque
弱引用的存在是为了让您保留对对象的引用,同时不会阻止它被回收。因此,在进行GC时,该引用将被忽略。 WR的整个重点在于确保可以回收可能被回收的对象,但如果它们创建成本如此之高,以至于您宁愿避免它们被回收,请改用普通引用。然而,我不知道是否有任何特殊处理弱引用的情况,也就是说,它们被回收的机会是否与普通对象不同。 - Lasse V. Karlsen
为什么引用WeakReference对象的Console.WriteLine行不能延长它们的生命周期? - Overhed
1
Lasse:弱引用并不是为了缓存而设计的;它们是为了在一个对象需要能够操作一些其他(未知)对象可能“感兴趣”的对象,但是操作对象本身并没有真正的“兴趣”时使用的。例如,一个对象可能将某些东西视为只写数据汇,另一个对象可能将其视为数据源。如果写入者仅在实际写入时持有弱引用,则如果所有读取器都消失,该对象可以被丢弃。 - supercat
“我们在本地变量中保留句柄的引用对于GC来说是没有意义的” - 但我们并没有保留引用。IntPtr是一个结构体;我们正在创建一个副本。实际的内部句柄是非托管的;GC对此一无所知。如果句柄是引用类型,它将在本地访问之后才被终结(我测试过了)。与其调用GC.KeepAlive(this),我会根据http://msdn.microsoft.com/en-us/library/system.gc.keepalive.aspx调用GC.KeepAlive(_HandleToSomethingUnmanaged)。这个方法可行。 - TrueWill
显示剩余6条评论

12
垃圾收集器从JIT编译器获取生命周期提示。 因此,它知道在GC.Collect()调用时,没有可能的引用到局部变量,因此可以回收它们。 请查看GC.KeepAlive()
当调试器附加时,JIT优化被禁用,并且生命周期提示被延长到方法的结尾。 这使得调试变得更简单。
我写了一个关于这个问题更详细的答案,你可以在这里找到。

1
这本质上是与Lasse相同的答案,但有一个明显的区别:你说生命周期提示由JIT提供,而Lasse说它在汇编中...我应该相信谁?;)。无论如何+1,因为它似乎足够接近真正的解释。 - Thomas Levesque
3
他关于JIT提供这个信息是正确的。我会编辑我的回答。 - Lasse V. Karlsen

0

我不是C#专家,但我认为这是因为在生产中,您的代码被优化了。

static void TestGC()
{
        WeakReference w1 = new WeakReference(new Object());
        WeakReference w2 = new WeakReference(new Object());

        GC.Collect();

        Console.WriteLine("o1 is alive: {0}", w1.IsAlive);
        Console.WriteLine("o2 is alive: {0}", w2.IsAlive);
}

没有o1,o2绑定剩余。

编辑:这是一个名为常量折叠的编译器优化。可以使用JIT或不使用JIT进行操作。

如果您有一种方法可以禁用JIT但保持发布优化,则会出现相同的行为。

人们应该注意以下说明:

注意:仅当代码以发布模式编译并在调试器外运行时才会发生此情况

这是关键。

PS:我还假设您了解WeakReference的含义。


但是即使你把名称删除了,'w1'和'w2'仍然引用着某些东西。关键在于编译器可以证明弱引用对象将不再被使用时,它们就被回收了。 - Ben Voigt
请注意我说的话。我不是在谈论名称,而是在谈论绑定。你错了,w1和w2并没有被收集,但是w1和w2所指向的对象被收集了。如果w1和w2被收集了,w2.IsAlive会抛出一个NullPointerException,但事实并非如此。 - mathk

0
有人能解释一下发生了什么吗?
你对虚拟机垃圾回收资源的心理模型过于简单化。特别是,你认为变量和作用域与垃圾回收有关是错误的。
这与JIT优化有关吗?
是的。
具体发生了什么?
JIT没有浪费时间保留不必要的引用,因此当跟踪收集器启动时,它无法找到引用,因此收集了对象。
请注意,其他答案已经说明JIT将此信息传达给GC,而事实上JIT实际上没有将这些引用传达给GC,因为这样做没有意义。

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