理解.NET中的垃圾回收

200

请考虑以下代码:

public class Class1
{
    public static int c;
    ~Class1()
    {
        c++;
    }
}

public class Class2
{
    public static void Main()
    {
        {
            var c1=new Class1();
            //c1=null; // If this line is not commented out, at the Console.WriteLine call, it prints 1.
        }
        GC.Collect();
        GC.WaitForPendingFinalizers();
        Console.WriteLine(Class1.c); // prints 0
        Console.Read();
    }
}

现在,即使在调用GC.Collect()时,主方法中的变量c1超出了范围并且没有被任何其他对象进一步引用,为什么它在那里没有被终结?


9
GC不会在对象超出范围时立即释放它们,而是在必要时才这样做。您可以在此处阅读有关GC的所有内容:http://msdn.microsoft.com/en-US/library/vstudio/0xy59wtx.aspx - user1908061
@user1908061(嘘,你的链接坏了。) - Dragomok
一些文章:GC | GC | GC | GC | GC | GC - user12031933
2个回答

396

您在这里被绊倒,因为您正在使用调试器并且得出了非常错误的结论。您需要以用户机器上运行程序的方式来运行代码。首先使用“生成+配置管理器”切换到发布版本,将左上角的“活动解决方案配置”组合框更改为“Release”。 接下来,进入“工具+选项”,选择“调试”,然后取消选中“抑制JIT优化”选项。

现在再次运行程序并调整源代码。注意,额外的括号根本没有任何影响。请注意,将变量设置为null不会有任何区别。它始终会打印“1”。它现在以您所希望的方式工作。

这留给您解释为什么在运行Debug版本时它会表现得如此不同。这需要解释垃圾收集器如何发现局部变量以及调试器的存在如何影响它。

首先,JIT编译器在将方法的IL编译为机器码时执行了两个重要的任务。 第一个任务在调试器中非常明显,您可以在“调试+窗口+反汇编”窗口中查看机器码。但第二个任务完全不可见。它还生成一个描述方法体内使用的局部变量的表格。该表格每个方法参数和局部变量都有两个地址。变量将首先存储对象引用的地址,以及不再使用该变量的机器码指令的地址。还说明该变量是存储在堆栈帧还是CPU寄存器中。

这个表格对于垃圾收集器至关重要,它需要知道在执行收集时在哪里查找对象引用。当引用是GC堆上对象的一部分时很容易做到。但如果对象引用存储在CPU寄存器中,则肯定不容易做到。该表格说明了要查找的位置。

表格中的"不再使用"地址非常重要。它使得垃圾回收器非常高效。即使对象引用在方法内部被使用且该方法尚未完成执行,垃圾回收器也可以收集它。这是非常常见的情况,例如您的Main()方法只有在程序终止之前才停止执行。显然,您不希望该Main()方法中使用的任何对象引用在整个程序运行期间都存在,否则会导致泄漏。Jitter可以使用该表格来发现这样一个局部变量已经不再有用了,具体取决于程序在进行到Main()方法内的哪个位置之前发生调用。

与该表格有关的一个近乎神奇的方法是GC.KeepAlive()。它是一个非常特别的方法,它根本不生成任何代码。它唯一的职责就是修改该表格。它延长了局部变量的生命周期,防止所存储的引用被垃圾回收器清除掉。你需要使用它的唯一原因是防止垃圾回收器过于热心地收集一个引用,在互操作场景中这种情况可能会发生,其中一个引用被传递给非托管代码。由于非托管代码没有被jitter编译,因此没有该表格来指示在哪里查找引用,垃圾回收器无法看到这样的引用被该代码使用。将委托对象传递给像EnumWindows()这样的非托管函数是需要使用GC.KeepAlive()的模板示例。

所以,运行Release构建后,可以从示例片段中看出,局部变量确实可以在方法执行完成之前被提前收集。更加强大的是,如果该方法不再引用“this”,则对象在其方法运行时可能被收集。这存在一个问题,即这样的方法非常难以调试。因为您可能会将变量放在监视窗口或检查它。如果发生垃圾回收,则变量将在您调试时消失。这将非常令人不愉快,因此JIT编译器知道有调试器连接。它然后修改表并改变“最后使用”的地址。并将其从其正常值更改为方法中最后一条指令的地址。只要该方法未返回,就会使变量保持活动状态。这使您可以在方法返回之前继续观察它。

现在,这也解释了您先前看到的内容以及为什么会提出这个问题。它打印“0”,因为GC.Collect调用无法收集该引用。表格显示该变量在GC.Collect()调用之后使用,一直到方法的结束。通过调试器连接和运行Debug构建来强制执行这个结果。

将变量设置为null现在确实有影响,因为GC将检查该变量,并且将不再看到引用。但是请确保您不会陷入许多C#程序员陷入的陷阱,即实际编写该代码是没有意义的。无论在Release构建中是否存在该语句,都不会有任何区别。实际上,JIT优化器将删除该语句,因为它根本没有任何影响。因此,请确保不要编写这样的代码,即使它似乎具有影响。


在这个话题最后需要注意的一点是,写小程序与Office应用程序交互的工程师们会出现麻烦。调试器通常会让他们走错路线,他们希望Office程序能够按需退出。正确的做法是通过调用GC.Collect()来实现。但是他们会发现,在调试应用程序时它不起作用,导致他们通过调用Marshal.ReleaseComObject()进入无穷无尽的状态。手动内存管理很少能正常工作,因为他们很容易忽略一个看不见的接口引用。GC.Collect()确实可以工作,只是在调试应用程序时不能正常工作。


1
请参阅汉斯为我很好地回答的问题。 https://dev59.com/V2Up5IYBdhLWcg3wCUKx - Dave Nay
1
@HansPassant我刚刚发现这个很棒的解释,也回答了我的问题的一部分:https://dev59.com/jl0a5IYBdhLWcg3wRGzp 关于GC和线程同步。我仍然有一个问题:我想知道GC是否实际上压缩和更新在寄存器中使用的地址(在挂起时存储在内存中),还是只是跳过它们?在暂停线程之后(在恢复之前)更新寄存器的进程对我来说感觉像是被操作系统阻止的严重安全威胁。 - atlaste
1
间接地,是的。线程被挂起时,垃圾回收器会更新CPU寄存器的后备存储区。一旦线程恢复运行,它将使用更新后的寄存器值。 - Hans Passant
1
@HansPassant,如果您能为您在这里描述的CLR垃圾回收器的一些非显而易见的细节添加参考资料,我将不胜感激。 - denfromufa
1
从配置的角度来看,一个重要的点是启用了“优化代码”(在.csproj中为<Optimize>true</Optimize>)。这是“发布”配置的默认设置。但是,如果使用自定义配置,则了解此设置很重要。 - Zero3

44

[只是想进一步添加有关终结过程的内部信息]

您创建了一个对象,当该对象被垃圾回收时,应调用该对象的Finalize方法。但终结不仅仅是这个非常简单的假设。

概念:

  1. 没有实现Finalize方法的对象:它们的内存立即被回收,除非它们已经不再被应用程序代码所引用。

  2. 实现了Finalize方法的对象:需要理解应用程序根终结队列Freachable队列等概念,因为它们参与到回收过程中。

  3. 如果任何对象无法被应用程序代码访问,则被视为垃圾。

假设:类/对象A、B、D、G、H不实现Finalize方法,而C、E、F、I、J则实现了Finalize方法。

当一个应用程序创建一个新对象时,new运算符从堆中分配内存。如果对象的类型包含一个Finalize方法,则指向该对象的指针将被放置在finalization队列中。因此,指向对象C、E、F、I、J的指针被添加到finalization队列中。 finalization队列是由垃圾收集器控制的内部数据结构。队列中的每个条目都指向一个对象,该对象在其内存可以被回收之前应调用其Finalize方法。
下图显示了一个包含多个对象的堆。其中一些对象可以从应用程序根访问,而另一些则不能。当创建对象C、E、F、I和J时,.NET框架检测到这些对象具有Finalize方法,并将指向这些对象的指针添加到finalization队列中。

enter image description here

当进行GC(第一次收集)时,对象B、E、G、H、I和J被确定为垃圾。A、C、D和F仍然可以从上面的黄色框中的应用程序代码箭头到达。
垃圾回收器扫描终结队列,查找指向这些对象的指针。当找到指针时,将该指针从终结队列中删除并附加到可达队列(“F-reachable”,即finalizer reachable)。可达队列是另一个由垃圾回收器控制的内部数据结构。可达队列中的每个指针都标识一个已准备好调用其Finalize方法的对象。
第一次GC后,托管堆看起来类似下图所示。以下是说明:
The memory occupied by objects B, G, and H has been immediately reclaimed because these objects do not have a finalize method that needs to be called. However, the memory occupied by objects E, I, and J cannot be reclaimed because their Finalize method has not yet been called. Calling the Finalize method is done through the freachable queue. Objects A, C, D, and F are still reachable by application code depicted as arrows from the yellow box above, so they will not be collected in any case.

enter image description here

有一个特殊的运行时线程专门用于调用Finalize方法。当可回收队列为空(通常情况下),该线程会进入睡眠状态。但是当条目出现时,该线程会唤醒,从队列中移除每个条目,并调用每个对象的Finalize方法。垃圾收集器压缩可回收内存,特殊的运行时线程清空可回收队列,执行每个对象的Finalize方法。所以这就是你的Finalize方法最终被执行的时候。
下一次垃圾收集器被调用时(第二次GC),它会看到已经完成了清理的对象是真正的垃圾,因为应用程序的根不再指向它,而可回收队列也不再指向它(它也是空的),因此可以从堆中回收对象E、I、J的内存。请参见下面的图表,并将其与上面的图表进行比较。

enter image description here

重要的是要明白,需要两个GC(Garbage Collection)来回收需要终结的对象所使用的内存。实际上,可能需要更多的垃圾回收操作,因为这些对象可能会晋升到较旧的代中。
注意:freachable队列被视为根,就像全局变量和静态变量一样是根。因此,如果一个对象在freachable队列中,则该对象是可达的,不是垃圾。
最后请记住,调试应用程序是一回事,垃圾回收是另一回事,并且工作方式不同。到目前为止,您无法仅通过调试应用程序来感知垃圾回收。如果您希望进一步研究内存,请从这里开始。

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