【问题标题】:Understanding garbage collection in .NET了解 .NET 中的垃圾收集
【发布时间】:2013-06-16 05:09:50
【问题描述】:

考虑下面的代码:

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();
    }
}

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

【问题讨论】:

标签: c# .net garbage-collection


【解决方案1】:

您在这里被绊倒并得出非常错误的结论,因为您使用的是调试器。您需要按照在用户机器上运行的方式运行代码。首先使用 Build + Configuration manager 切换到 Release build,将左上角的“Active solution configuration”组合更改为“Release”。接下来,进入Tools + Options,Debugging,General并取消勾选“Suppress JIT optimization”选项。

现在再次运行您的程序并修改源代码。注意额外的大括号根本没有效果。并注意将变量设置为 null 没有任何区别。它总是打印“1”。它现在可以按照您希望和预期的方式运行。

剩下的任务就是解释为什么在运行 Debug 构建时它的工作方式如此不同。这需要解释垃圾收集器如何发现局部变量,以及存在调试器时对局部变量有何影响。

首先,在将方法的 IL 编译为机器代码时,抖动执行 两个 重要职责。第一个在调试器中非常明显,可以通过Debug + Windows + Disassembly窗口看到机器代码。然而,第二个职责是完全看不见的。它还生成一个表,描述如何使用方法体内的局部变量。该表对每个方法参数和具有两个地址的局部变量都有一个条目。变量将首先存储对象引用的地址。以及不再使用该变量的机器代码指令的地址。以及该变量是否存储在堆栈帧或 cpu 寄存器中。

这张表对于垃圾收集器来说是必不可少的,它需要知道在执行收集时到哪里查找对象引用。当引用是 GC 堆上对象的一部分时,这很容易做到。当对象引用存储在 CPU 寄存器中时,绝对不容易做到。表格说明了在哪里查看。

表中“不再使用”的地址非常重要。它使垃圾收集器非常高效。它可以收集对象引用,即使它在方法内部使用并且该方法尚未完成执行。这很常见,例如,您的 Main() 方法只会在程序终止之前停止执行。显然,您不希望该 Main() 方法中使用的任何对象引用在程序期间存在,这将构成泄漏。抖动可以使用该表来发现这样的局部变量不再有用,这取决于程序在调用之前在 Main() 方法中的进度。

与该表相关的一个几乎神奇的方法是 GC.KeepAlive()。这是一个非常特殊的方法,它根本不生成任何代码。它的唯一职责是修改该表。它延长局部变量的生命周期,防止它存储的引用被垃圾收集。您需要使用它的唯一时间是阻止 GC 过度收集引用,这可能发生在将引用传递给非托管代码的互操作场景中。垃圾收集器看不到此类代码正在使用此类引用,因为它不是由抖动编译的,因此没有说明在哪里查找引用的表格。将委托对象传递给像 EnumWindows() 这样的非托管函数是需要使用 GC.KeepAlive() 时的样板示例。

因此,您可以从示例 sn-p 中看出,在发布版本中运行它后,局部变量可以在方法完成执行之前提前收集。更强大的是,如果该方法不再引用 this,则该对象可以在其方法之一运行时被收集。这样做有一个问题,调试这种方法非常尴尬。因为您可以将变量放在 Watch 窗口中或对其进行检查。如果发生 GC,它会在您调试时消失。这将是非常不愉快的,所以抖动意识到有一个调试器附加。然后它修改表格并改变“最后使用”的地址。并将其从正常值更改为方法中最后一条指令的地址。只要方法没有返回,它就会使变量保持活动状态。这使您可以继续观察它,直到方法返回。

现在这也解释了您之前看到的内容以及您提出这个问题的原因。它打印“0”,因为 GC.Collect 调用无法收集引用。该表表明该变量正在使用过去 GC.Collect() 调用,一直到方法结束。必须通过附加调试器来说明这一点通过运行调试构建。

现在将变量设置为 null 确实有效果,因为 GC 将检查变量并且将不再看到引用。但是请确保您不会落入许多 C# 程序员所陷入的陷阱,实际上编写该代码是没有意义的。当您在 Release 构建中运行代码时,无论该语句是否存在都没有区别。事实上,抖动优化器将删除该语句,因为它没有任何效果。所以千万不要写那样的代码,即使它看起来有效果。


关于这个主题的最后一点说明,这就是让编写小程序来使用 Office 应用程序做某事的程序员陷入困境的原因。调试器通常会让他们走上错误的道路,他们希望 Office 程序按需退出。适当的方法是调用 GC.Collect()。但是当他们调试他们的应用程序时,他们会发现它不起作用,通过调用 Marshal.ReleaseComObject() 将他们引导到永远不会到达的地方。手动内存管理,它很少能正常工作,因为它们很容易忽略不可见的接口引用。 GC.Collect() 确实有效,只是在调试应用时无效。

【讨论】:

  • 另请参阅 Hans 为我很好地回答的问题。 stackoverflow.com/questions/15561025/…
  • @HansPassant 我刚刚找到了这个很棒的解释,它也在这里回答了我的部分问题:stackoverflow.com/questions/30529379/… 关于 GC 和线程同步。我仍然有一个问题:我想知道 GC 是否真的压缩和更新寄存器中使用的地址(挂起时存储在内存中),或者只是跳过它们?在暂停线程后(在恢复之前)更新寄存器的进程在我看来就像一个被操作系统阻止的严重安全线程。
  • 间接,是的。线程被挂起,GC 更新 CPU 寄存器的后备存储。一旦线程恢复运行,它现在使用更新的寄存器值。
  • @HansPassant,如果您为您在此处描述的 CLR 垃圾收集器的一些非显而易见的细节添加参考资料,我将不胜感激?
  • 似乎配置明智,重要的一点是启用了“优化代码”(.csproj 中的<Optimize>true</Optimize>)。这是“发布”配置中的默认设置。但如果使用自定义配置,则需要知道此设置很重要。
【解决方案2】:

[只是想进一步补充最终确定流程的内部]

因此,您创建了一个对象,并且在收集该对象时,应该调用该对象的 Finalize 方法。但除了这个非常简单的假设之外,还有更多的事情要做。

简短概念::

  1. 对象没有实现Finalize 方法,内存是 立即回收,当然,除非它们无法通过
    不再是应用程序代码

  2. 实现Finalize 方法的对象,概念/实现 来自Application RootsFinalization QueueFreacheable Queue 在它们被回收之前。

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

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

当应用程序创建一个新对象时,new 运算符从堆中分配内存。 如果对象的类型包含 Finalize 方法,则指向该对象的指针被放置在终结队列中

因此指向对象 C、E、F、I、J 被添加到终结队列中。

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

当发生 GC(第一次收集)时,对象 B、E、G、H、I 和 J 被确定为垃圾。 因为 A、C、D、F 仍然可以通过上面黄色框中的箭头所示的应用程序代码访问。

垃圾收集器扫描终结队列,寻找指向这些对象的指针。 找到指针后,将指针从终结队列中移除并附加到 freachable 队列中(“F-reachable”)。

freachable queue 是另一个由垃圾收集器控制的内部数据结构。 freachable queue 中的每个指针都标识了一个准备好调用其Finalize 方法的对象。

在集合(第一个集合)之后,托管堆看起来类似于下图。解释如下::
1.) 对象B、G、H占用的内存已被回收 立即,因为这些对象没有 finalize 方法 需要调用

2.) 但是,对象 E、I 和 J 占用的内存不能 回收是因为他们的Finalize 方法还没有被调用。 调用 Finalize 方法是由 freacheable queue 完成的。

3.) A、C、D、F 仍然可以通过描述的应用程序代码访问 上面黄色框中的箭头,所以它们不会被收集在任何地方 案例

有一个专门用于调用 Finalize 方法的特殊运行时线程。当 freachable 队列为空时(通常是这种情况),该线程休眠。但是当条目出现时,该线程唤醒,从队列中删除每个条目,并调用每个对象的 Finalize 方法。垃圾收集器压缩可回收内存,特殊运行时线程清空 freachable 队列,执行每个对象的 Finalize 方法。 所以最后是你的 Finalize 方法被执行的时候

下次调用垃圾收集器(第二次收集)时,它发现最终对象是真正的垃圾,因为应用程序的根不指向它,并且 freachable 队列 不再指向到它(它也是EMPTY),因此对象(E,I,J)的内存只是从堆中回收。见下图并与上图进行比较

这里要理解的重要一点是,需要两次 GC 来回收需要终结的对象使用的内存。实际上,甚至需要两个以上的集合,因为这些对象可能会被提升到老一代

注意:: freachable queue 被认为是一个根,就像全局变量和静态变量是根一样。因此,如果一个对象在 freachable 队列上,那么该对象是可访问的,不是垃圾。

最后一点,请记住调试应用程序是一回事,垃圾收集是另一回事,并且工作方式不同。到目前为止,您无法仅通过调试应用程序来感受垃圾收集,如果您想进一步调查 Memory get started here.

【讨论】:

    【解决方案3】:

    考虑以下代码并确定哪个解释最能代表当前状态 在 main() 方法结束之前引用 c1、c2 和 c3,假设垃圾收集尚未运行?在图中,每个框代表一个带有许多鸡蛋的鸡对象。

    【讨论】:

    • 正如目前所写,您的答案尚不清楚。请edit 添加其他详细信息,以帮助其他人了解这如何解决所提出的问题。你可以找到更多关于如何写好答案的信息in the help center
    猜你喜欢
    • 2015-12-19
    • 1970-01-01
    • 1970-01-01
    • 2014-11-19
    • 1970-01-01
    • 2021-03-25
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多