.Net 垃圾回收机制整理
.Net CLR 的垃圾回收机制可以让开发者不必去追踪内存的使用,什么时候去回收内存。但是如果你想了解内存回收是如何工作的,本文会一步一步带你了解.net 的垃圾回收机制。 本文总结了Jeffrey Richter的Garbage Collection: Automatic Memory Management in the Microsoft .NET ,以及MSDN上的官方文档,整理如下。
一 NextObjPtr
当一个进程初始化的时候,runtime会保留一个连续的地址空间,对于.Net来说,这个练习的地址空间就是托管堆(Manged heap),托管堆会维护一个指针,我们叫它NextObjPtr,这个指针用来表示,下一个托管堆中的对象会在哪里生成,当一个对象的构造方法被调用后,new 运算符就会返回这个对象的地址。
Figure1 表示一个托管堆中有三个对象,A,B,和C。那么下一个生成的对象就会在NextObjPtr处。但是当运行时去new一个对象时,可能会出现托管堆的内存不足的情况,这样就需要进行垃圾回收,需要一种机制来保证托管堆的空间一直是充足的。
二 垃圾回收机制的算法
GC会检查是否有某些对象,不再被使用。如有有这样的对象,那就意味着这些对象需要被回收,(如果托管堆中没有空间可以使用,CLR会抛出OutOfMemoryException),但是GC是怎么知道某个对象是否不再被引用的呢?
每一个应用程序(application)都会引用一些列的根(Root),根标识了在托管堆上的地址。例如,所有全局的,以及static变量,线程栈上的参数,变量,寄存器包含一些托管对象的引用这些都被认为是应用程序root集合的一部分,这些root集合使用JIT 和CLR来维护的,并且运行GC访问。
当GC开始运行你的生活,它会首先认为托管堆中所有的对象都是垃圾,然后遍历root集合,建立一个可访问对象的列表。
Figure2显示了一系列的对象,A,C,D,F是直接被应用程序Root应用的,当遍历到D的时候,GC会发现H被D所使用,因此A,C,D,H都被加入到一个可访问对象的集合中,GC会递归遍历所有对象,找到所有的可访问对象。
这个集合完成了一部分之后,GC会检查下一个Root,然后递归遍历这个root所引用的对象,当GC发现某个对象已经被加到集合中,就不会沿着这条路继续遍历。这样主要基于两点考虑:不去重复的遍历某个对象可以提高性能,也可以避免无穷递归。
一旦所有的应用程序的root都被遍历过之后,GC就获得了一个可以被访问对象的列表。不在这个列表中的对象就应该考虑被回收。GC现在会一直遍历托管堆,寻找垃圾对象(垃圾对象占用的内存区现在被认为是可用的内存),把托管堆中的对象依次下移,填满垃圾对象占用的内存区,删掉托管堆上的内存空白区。同时GC需要修改Root集合里面的指向地址,修改NextObjPtr,GC需要保证他们指的地址是正确的。Figure3显示了GC回收之后的托管堆。
既然GC的功能如此强大,为什么ANSI C++里面没有实现这样的功能呢?原因是程序的Root集必须能识别所有的Root,并且能够找到Root对应的对象,C++可以把一个类型的指针强转为另外一个类型的,因此没有办法知道指针真正指向的是一个什么对象。在CLR中,托管堆知道一个对象的真正类型,因为托管对象的metadata记录了这些信息。
三 Finalization和析构函数
GC提供了另外一个你可能会利用到的功能:Finalization。Finalization可以优雅的实现在GC回收托管资源之后清理自己占用的其它资源。通过Finalization,在GC决定释放资源的时候,对文件资源,网络连接资源等进行自我清理。一个简单的例子:
class Car { ~Car() { // destructor // cleanup statements... Console.WriteLine("In Finalize."); } }
编译之后,析构函数会隐式的调用Finalize方法,析构函代码就会变成了下面的样子,你不能直接重写Finalize方法,只能通过实现析构函数语法来实现Finalize的功能
protected override void Finalize() { try { // Cleanup statements... Console.WriteLine("In Finalize."); } finally { base.Finalize(); } }
- 你可以这样创建一个新对象
Car car = new Car();
- 在某一个时间,当GC回收这个对象的时候,发现这个类实现了Finalize方法,就会调用它,因此"In Finalize"会在控制台上显示出来,这个对象占用的资源会被回收。当设计一个类的时候,尽量避免使用Finalize方法,有以下几点理由:
- Finalizable对象会被提升到旧的Generation中。这会提高内存的压力,并且组织GC对资源的释放。另外,所有直接或者间接的被Finalizable对象引用的资源也都会被提升Generation,会在后面的文章中介绍到。
- 您应该只实现 Finalize 方法来清理非托管资源。您不应该对托管对象实现 Finalize 方法,因为垃圾回收器会自动清理托管资源
- 强制GC调用Finalize方法会降低性能。记住,如果每个对象都是Finalizable,10000个对象就需要调用每个对象的Finalize方法。
- Finalizable对象可能会引用其他非Finalizable对象,没有必要的延长的被引用对象的生命周期。这种情况下,你可以考虑把这个类分成两个不同的类,一个轻量级的类实现Finalize方法,另外一个没有Finalize方法的类引用其他类。
- 你没有办法控制什么时候Finalize方法会被执行。对象可能会一直占用资源,直到GC下次运行。
- 当一个程序停止时,一些对象仍然是可访问的,因此他的Finalize方法可能还没有被调用。这种情况可能会发生在后台线程在使用某个对象,对象是在英语程序关闭的过程中创建的,或者appDomain正在卸载。默认情况下,应用程序正在关闭的过程中,不可访问的对象的的Finalize方法不会被调用,程序会很快的终止。当然,操作系统会回收资源,但是托管堆里的资源不会被优雅的释放。你可以通过调用GC.RequestFinalizeOnShutdown来改变这个默认行为。当然你要小心的使用这个方法,因为你在改变整个程序的设置。
- 运行时无法保证Finalize方法的调用顺序。例如,一个对象含有一个内部对象的引用。GC检测到两个对象都需要被回收。而且,内部对象的Finalize方法应该先被调用。现在,外部对象的Finalize方法是允许访问内部对象的方法的,并且调用了内部对象的方法,这就会导致不可预料的错误。因此,强烈建议Finalize方法不用引用任何内部成员的对象。
- 当你决定为一个类定义Finalize方法的时候,确保Finalize方法不会抛出异常,这个异常是无法被捕获的,会导致应用程序终止。
四 Finalization的内部实现
表面上看,finalization的实现是很干脆的。你创建一个对象,当对象被回收的时候,对象的Finalize方法被调用。实际上比这个要复杂。
当一个程序创建一个新的对象,new操作在托管堆上分配内存,如果这个类定义了Finalize方法,一个指向这个对象的指针会加到一个Finalization队列中,Finalization队列是由GC维护的内部的数据结构,队列中每一个成员都必须实现了Finalize方法,并且保证Finalize方法在资源回收之前被调用。
Figure5显示了一个堆包含了一些对象。这里面有些对象可以在应用程序root集合中访问,有些不能。当C,E,F,I 和 J被创建的时候,系统会检测实现了Finalize方法的对象,加到Finalize队列中。
当GC开始回收的时候,B,E,G,H 和 J决定要回收。GC会检测Finalization队列中是否存在这些对象的引用。如果存在,则把这个对象从Finalization队列中移除,加到Freachable队列中。这个Freachable队列是GC维护的另外一个数据结构。Freachable队列中表示准备调用这些对象的Finalize方法。
在回收之后,托管堆就像Figure6。你可以看到B,G,H占用的内存已经被回收了,因为这些对象没有实现Finalize方法。然而,被E,I,J占用的内存无法被回收,因为他们的Finalize方法还没有被调用。
有一个特别的运行时线程专门负责调用Finalize方法,当Freachable队列是空的时候(大多数情况都是这样),这个线程处于sleep状态。当里面有内容的时候,线程被唤醒,逐个把队列中的内容删除,并且调用每一个对象的Finalize方法。因此,Finalize方法中不要假设方法在某个线程中执行,也不要在Finalize方法中只有当前线程才能访问的成员。
这里简单说一下Freachable的命名,F当然就是finalization的意思,Freachable队列也被认为是Application Root集合的一部分,和全局的变量或者静态变量一样,因此在Freachable队列中的对象是可访问的,不属于垃圾对象。
简单的说,当一个对象是不可访问的时候,GC会认为这个对象是垃圾对象。然后GC会把对象从Finalization队列中移到Freachable队列中,这时这个对象就不再被认为是垃圾对象,因此他的资源也不会被回收。因此,曾经被GC认为是垃圾的对象会被重新归类成非垃圾对象。GC会重新安排可回收的资源并且等待特定的运行时线程清空Freachable队列,执行每一个对象的Finalize方法。
GC下一次运行的时候,所有的Finalized对象会被认为是真正的垃圾,因为没有任何Root指向Freachable队列了。现在这些垃圾对象占用的内存会被回收了。这里需要注意,实现了finalization的对象需要GC运行至少两次才能被回收。事实上,可能多余两次,因为这些对象可能被提升到更旧的Generation中。Figure7中显示了GC第二次回收之后堆栈的情况。
五 复活
Finalization的完整概念令人着迷的,但是我们还有更多需要说的。在之前的段落中你可能会注意到,当应用程序不能访问一个生存的对象时,我们会认为这个对象已经死亡。但是,如果这个对象需要Finalization,这个对象的又被认为活着的,直到他的Finalize方法被调用,之后他才是真正的死亡了。换句话说,需要Finalize对象的生命周期会经历一个从死亡,生存,真正死亡的过程。这种情况我们叫做resurrection,正如他的名字暗示的一样,这个对象经历了一个复活的过程。
public class BaseObj { protected override void Finalize() { Application.ObjHolder = this; } } class Application { static public Object ObjHolder; // Defaults to null }
在这个例子中,当一个对象的Finalize方法被执行的时候,Application类引用了当前对象,这个对象又复活了,但是实际上这个对象的Finalize方法已经被执行过了,这可能会导致无法预期的结果。因此记住,Finalize方法中引用的所有对象都会复活,在Finalize方法中当前对象被其他对象引用可能会导致不可预期的异常,因为这个对象已经被Finalize了。
事实上,当设计一个类的时候,复活的对象可能会超出你的控制。对象复活很少有漂亮的用法,因此我们应该尽量的去避免它。
当你确认要使用Finalize的时候,你可以通过一个bool变量来控制这个对象是否被Finalized,.Net FrameWork中很多类库给出了一个优雅的实现,实现的基本代码是这样的:
class ClassNeedFinalize : IDisposable { private bool isDispose; public void Dispose() { Dispose(true); System.GC.SuppressFinalize(this);//通知GC不再需要调用这个类的Finalize方法 } protected virtual void Dispose(bool disposing) { if (!isDispose) { if (disposing) { //释放托管资源 } //释放非托管资源 } isDispose = true; } protected override void Finalize() { Dispose(false); } public void SomeMethod() { if (isDispose) { throw new Exception(); } } }
六 垃圾回收的时机
当满足以下条件之一时将发生垃圾回收:
系统具有低的物理内存。
由托管堆上已分配的对象使用的内存超出了可接受的阈值。这意味着可接受的内存使用的阈值已超过托管堆。随着进程的运行,此阈值会不断地进行调整。
调用 GC.Collect 方法。几乎在所有情况下,您都不必调用此方法,因为垃圾回收器会持续运行。此方法主要用于特殊情况和测试。
参考文献:
Garbage Collection: Automatic Memory Management in the Microsoft .NET Jeffrey Richter