垃圾回收GC:.Net自动内存管理 上(三)终结器
前言
.Net下的GC完全解决了开发者跟踪内存使用以及控制释放内存的窘态。然而,你或午想要理解GC是怎么工作的。此系列文章中将会解释内存资源是怎么被合理分配及管理的,并包含非常详细的内在算法描述。同时,还将讨论GC的内存清理流程及什么时清理,怎么样强制清理。
终结器
GC提供了另外一个能够给你带来好处的功能:终结器。在一个资源被回收后,终结器允许一个优雅的清理操作。使用终结器,当GC释放资源所占的内存时,它们可以进行适当的自我清理。
简而言之:当GC检测到一个对象是垃圾时,GC会调用它的Finalize()方法(如果存在的话),并且回收对象所占内存。看下面的代码:
public class BaseObj { public BaseObj() { } protected override void Finalize() { // 实现资源清理的代码 // 比如,关闭文件或网络连接 Console.WriteLine("In Finalize."); } }
现在你可以创建一个此对象的实例:
BaseObj bo = new BaseObj();
在将来如果GC检测到这个对象变成了垃圾,GC会去查看它是否实现了Finalize方法;如果是,就会调用此方法,最后回收此对象所占内存。
许多习惯C++的开发者会迅速把析构器destructor和Finalize方法联系起来。然而,值得注意的是:它们完全不是一回事,当你试图理解终结器时你最好忘记一切有关destructor的操作。托管对象从来没有destructor周期。
当设计一个类型时,最好避免使用Finalize方法。下面列出几个原因:
- 可被终结(Finalize)的对象会被提升到GC的更老一代中,这会增大内存压力并阻止对象内存回收即使GC认为此对象为垃圾对象。另外,所有与此对象有直接或间接关系的对象也会被提升。GC中的代以及代的提升在后续文章中会介绍。
- 可被终结(Finalize)的对象需要更长时间去分配。
- 强制GC执行终结(Finalize)方法会明显降低性能。因此,如果有10000个对象实现了Finalize方法,GC必须执行其10000次终结方法,很伤性能。
- 实现终结器的对象可能引用了没有终结器的对象,导致了那些没有终结器的对象的生命周期的延长。实际上,你可能会想把一个类型分成两个不同的类型:没有引用任何其它对象的带终结器的轻量级类型和引用了其它对象而不带终结器的类型。
- 你无法控制终结器方法的执行时间。因此,它可能会占有一定的资源不释放直到GC的下次回收。
- 当一个程序终止时,一些对象始终可以被访问到并且不会执行其终结器。比如,后台线程使用的对象或者程序终止(或程序域卸载)过程中创建的对象。另外,默认地,为了程序能够终止迅速,当一个程序终止时不会调用无法被访问到的对象的终结器。当然,所有操作系统资源都会被回收利用,但是在托管堆中的对象是不能够被恰当清理的。如果你想改变这个默认行为,你可以调用System.GC的RequestFinalizeOnShutdown方法。不过,你一定要小心地使用此方法,因为调用此方法意味着你的这个类型正在控制整个应用程序的策略。
- 程序运行时无法保证终节器的执行顺序。比如,一个对象包含一个指针指向一个内部对象,GC检到这两个对象都是垃圾。更进一步说,内部对象的终结器先被调用。现在,外部对象的终结器能够访问到内部对象并且调用其方法,但是内部对象已经被终结了。此时,结果是无法预知的。由于这个原因,强烈推荐终结器不要访问任何内部成员对象。
如果你决定让你的类型实现终结器,必须确保代码可以尽可能快的执行完毕。这样可以避免可能会阻止终结器执行的动作,包括线程同步操作。另一方面,如果你在终结器内抛出了异常,系统会认为终结器已返回(执行完毕),然后继续执行其它对象的结终器。
当然编译器为构造器生成代码时,编译器会自动地插入一个对基类构造器的调用。同样地,当一个C++编译器为析构器生成代码时,编译器会自动地插入一个对基类析构器的调用。然而,就像之前所说,终结器和析构器是不同的。编译器对于终结器没有特殊处理,因此编译器不会自动生成代码去调用基类的终结器。如果你想实现这个过程,你必须明确地在你的终结器里调用基类终结器:
public class BaseObj {
public BaseObj() {
}
protected override void Finalize() {
Console.WriteLine("In Finalize.");
base.Finalize(); // 调用基类终结器
}
}
在衍生类型终结器中你会经常在最后一句代码调用基类的终结器。这样可以保持基类对象尽可能存在的更久。因为这种调用基类终结器比较常见,C#中有一个简单的语法:
class MyObject {
~MyObject() {
//其它代码
}
}
causes the compiler to generate this code: class MyObject {
protected override void Finalize() {
//其它代码
base.Finalize();
}
}
这和C++析构器有些像,但是记住C#不支持析构器。
终结器内部
表面上,终结器看起来直接了当:你创建一个带终结器的对象,当它被回收时,终结器被调用。实际上,有更多的操作你看不到。
当一个应用程序创建一个新的对象,new操作符在堆中给它分配内存。如果有终结器,一个指向此对象的指针会被放入终结器队列。终结器队列是一个由GC控制的内部数据结构。队列中的每一项指向一个对象,而此对象在内存回收前会调用终结器。
下图中堆中存放着几个对象。一些对象可以被程序根访问到,一些不能。当对象C,E,F,I和J被创建,系统检测到这些对象实现了终结器,同时在终结器队列里添加了指向这些对象的指针。
带有很多对象的托管堆(对堆与栈疑惑的可以参考:深入浅出图解C#堆与栈):
Finalization Queue:终结器队列;
当GC回收内存时,对象B,E,G,H,I和J被认为是垃圾。GC扫描终结器队列看是否存在指针指向这些对象。如果存在,在终结器队列中移除此指针并把它移动到终结器可达队列。终结器可达队列是另外一个由GC控制的内部数据结构。终结器可达队列中的每一个指针代表一个已经执行过终结器的对象。
在GC回收内存后,托管堆变成了下图。你可以看到对象B,G和H所占的内存已经被回收利用因为它们没有终结器。然而,对象E,I和J所占的内存没有被回收因为它们的终结器还没有被执行。
GC回收之后的托管堆:
Finalization
Queue:终结器队列;Freachable Queue: 终结器可达队列
有一个特殊运行时线程专门用于调用终结器。通常情况下,当终结器可达队列是空队列时,这个线程进入休眠。但是,一旦终结器可达队列出现新的项,此线程苏醒,移除终结器可达队列中所有项并调用它们的终结器。由于这个机制的存在,你不可在终结器中执行任何基于此线程的编码。比如,不要在终节器中访问线程本地存储器。
终结器队列和终结器可达队列的交互是很有趣的。Freachable中的f代表着Finalization终结器,reachable意思是对象可以被访问。终结器可达队列被看作类似于全局变量和静态变量一样的根。因此,GC会认定终结器可达队列中所有对象都不是垃圾。
总而言之,当一个带有终结器的对象不能够被访问到,GC认为它是垃圾。然后,GC会移动此对象在终结器队列中的指针到终结器可达队列中,这时此对象将不再是垃圾,同时它所占的内存也不会被回收。到此为止,GC已经完成了一次垃圾扫描识别过程。GC压缩可释放内存,那个特殊运行时线程进行清理终结器可达队列并执行每一个对象的终结器。
GC进行二次垃圾回收时,带有终结器的垃圾对象就变成真正的垃圾,因为程序根不指向它,终结器可达队列也不指向它。这时这个对象所占的内存才会被回收。这里我要指出的点就是带有终结器的对象,GC需要对它们进行两次的垃圾回收才能回收它们所占的内存。实际上,GC有时需要执行两次以上的垃圾回收因为对象可能会被提升到更老的一代中。下图显示了GC二次回收后的托管堆。
GC二次回收后的托管堆:
总结
终结器与GC的关系,我们需要掌握并理解。这有助于更深层次的理解垃圾回收GC机制。当然,还有比终结器更多的内容,下一节我们将介绍《复活与强制回收》。