了解.NET中的垃圾回收


170

考虑下面的代码:

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()调用时没有被其他任何对象进一步引用,但为什么还没有在其中最终确定呢?


8
当实例超出范围时,GC不会立即释放它们。认为必要时会这样做。您可以在此处阅读有关GC的所有信息:msdn.microsoft.com/zh-CN/library/vstudio/0xy59wtx.aspx
user1908061 2013年

@ user1908061(Pssst。您的链接已断开。)
Dragomok

Answers:


352

由于正在使用调试器,您在这里绊倒了,得出了非常错误的结论。您需要以在用户计算机上运行的方式运行代码。首先使用Build + Configuration Manager切换到Release build,将左上角的“活动解决方案配置”组合更改为“发布”。接下来,进入“工具+选项”,“调试”,“常规”,然后取消选中“抑制JIT优化”选项。

现在,再次运行您的程序并修改源代码。请注意,多余的括号完全没有作用。并注意将变量设置为null不会有任何区别。它将始终打印“ 1”。现在,它可以按照您希望和期望的方式工作。

剩下的工作是解释为什么在运行Debug版本时它为何如此不同。这需要说明垃圾收集器如何发现局部变量,以及如何通过提供调试器来影响局部变量。

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

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

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

与该表相关的一种几乎不可思议的方法是GC.KeepAlive()。这是一种非常特殊的方法,它根本不会生成任何代码。它的唯一职责是修改该表。它延伸局部变量的生存期,防止它存储的引用被垃圾回收。唯一需要使用它的方法是停止GC过度收集参考,这在将参考传递给非托管代码的互操作方案中可能会发生。垃圾收集器无法看到此类代码正在使用的此类引用,因为它不是由抖动编译的,因此没有说明在哪里查找引用的表。将委托对象传递给非托管函数(例如EnumWindows())是何时需要使用GC.KeepAlive()的典型示例。

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

现在,这也说明了您之前看到的内容以及为什么提出该问题。因为GC.Collect调用无法收集引用,所以它显示“ 0”。该表显示该变量正在使用中过去的GC.Collect的()调用,直到方法结束所有的方式。通过附加调试器运行Debug构建来强制如此说。

现在将变量设置为null确实有效,因为GC将检查该变量并且不再看到引用。但是请确保您没有陷入许多C#程序员陷入的陷阱,实际上编写该代码是没有意义的。无论在运行Release版本中的代码时是否存在该语句都没有关系。实际上,抖动优化器将删除该语句,因为它根本不起作用。因此,即使看起来有效果,也请不要编写这样的代码。


关于此主题的最后一点说明,这就是使程序员遇到麻烦的问题,他们编写小型程序来使用Office应用程序执行某些操作。调试器通常将它们放在错误的路径上,他们希望Office程序按需退出。适当的方法是调用GC.Collect()。但是他们会在调试应用程序时发现它不起作用,并通过调用Marshal.ReleaseComObject()使其进入永无止境。手动内存管理,它很少能正常工作,因为它们很容易忽略不可见的接口引用。GC.Collect()实际上有效,只是在调试应用程序时不起作用。


1
另请参阅我的汉斯为我很好回答的问题。stackoverflow.com/questions/15561025/...
戴夫不然

1
@HansPassant我刚刚找到了这个很棒的解释,它也在这里回答了部分问题:stackoverflow.com/questions/30529379/…关于GC和线程同步。我仍然有一个问题:我想知道GC是否实际上压缩并更新了寄存器中使用的地址(暂停时存储在内存中),还是只是跳过了它们?在挂起线程之后(在恢复之前),更新寄存器的过程在我看来就像是被操作系统阻止的严重安全线程。
atlaste 2015年

间接地,是的。线程被挂起,GC更新了CPU寄存器的后备存储。一旦线程恢复运行,它现在将使用更新的寄存器值。
汉斯·帕桑

1
@HansPassant,如果您为此处描述的CLR垃圾收集器的一些非显而易见的细节添加引用,我将不胜感激。
denfromufa

似乎在配置方面很重要,重要的一点是启用了“优化代码”(<Optimize>true</Optimize>中的.csproj)。这是“发布”配置中的默认设置。但是,如果使用自定义配置,则需要知道此设置很重要。
Zero3,19年

34

[只是想进一步添加有关Finalization的内部信息]

因此,创建一个对象,并在收集该对象Finalize时应调用该对象的方法。但是,除了这个非常简单的假设之外,最终确定还有更多。

简短概念::

  1. 对象未实现Finalize方法,那里的内存会立即回收,除非,当然,
    应用程序代码无法再访问​​它们

  2. 对象实施Finalize方法,概念/实施Application RootsFinalization QueueFreacheable Queue谈到他们可以被回收之前。

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

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

当应用程序创建新对象时,新运算符将从堆中分配内存。如果对象的类型包含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。

垃圾收集器扫描完成队列,以查找指向这些对象的指针。当找到一个指针时,该指针将从终结队列中删除,并附加到可访问队列(“ F可访问”)中。

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

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

2.) 但是,对象E,I和J占用的内存无法回收,因为它们的内存Finalize方法尚未被调用。 调用Finalize方法是通过易到达队列完成的

3.) 仍然可以通过上方黄色框内的箭头所示的应用代码来访问A,C,D,F,因此在任何情况下均不会收集它们

在此处输入图片说明

有一个专用的运行时线程专用于调用Finalize方法。当可访问队列为空时(通常是这种情况),该线程进入睡眠状态。但是,当出现条目时,该线程将唤醒,从队列中删除每个条目,并调用每个对象的Finalize方法。垃圾收集器压缩可回收内存,特殊的运行时线程清空易碎队列,执行每个对象的Finalize方法。所以这终于是当您的Finalize方法被执行时

下次调用垃圾收集器(第二个收集)时,它会看到最终对象是真正的垃圾,因为应用程序的根不指向该对象,并且 易碎队列不再指向该对象(它也是EMPTY),因此只需从堆中回收对象(E,I,J)的内存即可。请参见下图,并将其与上图进行比较

在此处输入图片说明

这里要了解的重要一点是,需要两个GC来回收由 需要终结处理对象。实际上,甚至需要两个以上的集合,因为这些对象可能会升级为较老的一代

注:: freachable队列被认为是根就像全局和静态变量是根。因此,如果对象在可访问队列中,则该对象可访问且不是垃圾。

最后,请记住,调试应用程序是一回事,垃圾收集是另一回事,并且工作方式有所不同。到目前为止,您还不能仅通过调试应用程序来感受垃圾回收,如果您希望调查“内存从这里开始”的话,那么进一步

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.