5.3 垃圾回收
本节将介绍以下内容:
.NET垃圾回收机制
非托管资源的清理
5.3.1 引言
.NET自动内存管理将开发人员从内存错误的泥潭中解放出来,这一切都归功于垃圾回收(GC,Garbage Collection)机制。
通过对对象创建全过程的讲述,我们理解了CLR执行对象内存分配的基本面貌。一个分配了内存空间和完成初始化的对象实例,就是一个CLR世界中的新生命体,其生命周期大概可以概括为:对象在系统中进行一定的操作和应用,到一定阶段它将不被系统中任何对象引用或操作,则表示该对象不会再被使用。因此,对象符合了可以销毁的条件,而CLR可能不会马上执行销毁操作,而是在适当的时间执行该对象的内存销毁。一旦被执行销毁,对象及其成员将不可在运行时使用,最后由垃圾收集器释放其内存资源,完成一个对象由生而灭的全过程。
由此可见,在.NET中自动内存管理是由垃圾回收器来执行的,GC自动完成对托管堆的全权管理,然而一股脑将所有事情交给GC,并非万全保障。基于性能与安全的考虑,很有必要对GC的工作机理、执行过程,以及对非托管资源的清理做一个讨论。
5.3.2 垃圾回收
顾名思义,垃圾回收就是清理内存中的垃圾,因此了解垃圾回收机制就应从以下几个方面着手:
l 什么样的对象被GC认为是垃圾呢?
l 如何回收?
l 何时回收?
l 回收之后,又执行哪些操作?
清楚地回答上述几个问题,也就基本了解.NET的垃圾回收机制。下面本节就逐一揭开这几个问题的答案。
l 什么样的对象被GC认为是垃圾呢?
简单地说,一个对象成为“垃圾”就表示该对象不被任何其他对象所引用。因此,GC必须采用一定的算法在托管堆中遍历所有对象,最终形成一个可达对象图,而不可达的对象将成为被释放的垃圾对象等待收集。
l 如何回收?
每个应用程序有一组根(指针),根指向托管堆中的存储位置,由JIT编译器和CLR运行时维护根指针列表,主要包括全局变量、静态变量、局部变量和寄存器指针等。下面以一个简单的示例来说明,GC执行垃圾收集的具体过程。
class A
{
private B objB;
public A(B o)
{
objB = o;
}
~A()
{
Console.WriteLine("Destory A.");
}
}
class B
{
private C objC;
public B(C o)
{
objC = o;
}
~B()
{
Console.WriteLine("Destory B.");
}
}
class C
{
~C()
{
Console.WriteLine("Destory C.");
}
}
public class Test_GCRun
{
public static void Main()
{
A a = new A(new B(new C()));
//强制执行垃圾回收
GC.Collect(0);
GC.WaitForPendingFinalizers();
}
}
在上述执行中,当创建类型A的对象a时,在托管堆中将新建类型B的实例(假设表示为objB)和类型C的实例(假设表示为objC),并且这几个对象之间保存着一定的联系。而局部变量a则相当于一个应用程序的根,假设其在托管堆中对应的实例表示为objA,则当前的引用关系可以表示为图5-6。

图5-6 垃圾收集执行前的托管堆
垃圾收集器正是通过根指针列表来获得托管堆中的对象图,其中定义了应用程序根引用的托管堆中的对象,当垃圾收集器启动时,它假设所有对象都是可回收的垃圾,并开始遍历所有的根,将根引用的对象标记为可达对象添加到可达对象图中,在遍历过程中,如果根引用的对象还引用着其他对象,则该对象也被添加到可达对象图中,依次类推,垃圾收集器通过根列表的递归遍历,将能找到所有可达对象,并形成一个可达对象图。同时那些不可达对象则被认为是可回收对象,垃圾收集器接着运行垃圾收集进程来释放垃圾对象的内存空间。通常,将这种收集算法称为:标记和清除收集算法。
在上例中,a可以看出是应用程序的一个根,它在托管堆中对应的对象objA就是一个可达对象,而对象objA依次关联的objB、objC都是可达对象,被添加到可达对象图中。当Main方法运行结束时,a不再被引用,则其不再是一个根,此时通过GC.Collect强制启动垃圾收集器,a对应的objA,以及相关联的objB和objC将成为不可达对象,我们从执行结果中可以看出类型A、B、C的析构方法被分别调用,由此可以分析垃圾回收执行了对objA、objB、objC实例的内存回收。
l 何时回收?
垃圾收集器周期性的执行内存清理工作,一般在以下情况出现时垃圾收集器将会启动:
(1)内存不足溢出时,更确切地应该说是第0代对象充满时。
(2)调用GC.Collect方法强制执行垃圾回收。
(3)Windows报告内存不足时,CLR将强制执行垃圾回收。
(4)CLR卸载AppDomain时,GC将对所有代龄的对象执行垃圾回收。
(5)其他情况,例如物理内存不足,超出短期存活代的内存段门限,运行主机拒绝分配内存等等。
作为开发人员,我们无需实现任何代码来管理应用程序中各个对象的生命周期,CLR知道何时去执行垃圾收集工作来满足应用程序的内存需求。当上述情况发生时,GC将着手进行内存清理,当内存释放之前GC会首先检查终止化链表中是否有记录来决定在释放内存之前执行非托管资源的清理工作,然后才执行内存释放。
同时,微软强烈建议不要通过GC.Collect方法来强制执行垃圾收集,因为那会妨碍GC本身的工作方式,通过Collect会使对象代龄不断提升,扰乱应用程序的内存使用。只有在明确知道有大量对象停止引用时,才考虑使用GC.Collect方法来调用收集器。
l 回收之后,又执行哪些操作?
GC在垃圾回收之后,堆上将出现多个被收集对象的“空洞”,为避免托管堆的内存碎片,会重新分配内存,压缩托管堆,此时GC可以看出是一个紧缩收集器,其具体操作为:GC找到一块较大的连续区域,然后将未被回收的对象转移到这块连续区域,同时还要对这些对象重定位,修改应用程序的根以及发生引用的对象指针,来更新复制后的对象位置。因此,势必影响GC回收的系统性能,而CLR垃圾收集器使用了Generation的概念来提升性能,还有其他一些优化策略,如并发收集、大对象策略等,来减少垃圾收集对性能的影响。例如,上例中执行后的托管堆的内存状况可以表示为图5-7。

图5-7 垃圾收集执行后的托管堆
CLR提供了两种收集器:工作站垃圾收集器(Workstation GC,包含在mscorwks.dll)和服务器垃圾收集器(Server GC,包含在mscorsvr.dll),分别为不同的处理机而设计,默认情况为工作站收集器。工作站收集器主要应用于单处理器系统,工作站收集器尽可能地通过减少垃圾回收过程中程序的暂停次数来提高性能;服务器收集器,专为具有多处理器的服务器系统而设计,采用并行算法,每个CPU都具有一个GC线程。在CLR加载到进程时,可以通过CorBindToRuntimeEx()函数来选择执行哪种收集器,选择合适的收集器也是有效、高效管理的关键。
关于代龄(Generation)
接下来对文中多次提到的代龄概念做以解释,来理解GC在性能优化方面的策略机制。
垃圾收集器将托管堆中的对象分为三代,分别为:0、1和2。在CLR初始化时,会选择为三代设置不同的阙值容量,一般分配为:第0代大约256KB,第1代2MB,第2代10MB,可表示为如图5-8所示。显然,容量越大效率越低,而GC收集器会自动调节其阙值容量来提升执行效率,第0代对象的回收效率肯定是最高的。
![]()
图5-8 代龄的阙值容量
在CLR初始化后,首先被添加到托管堆中的对象都被定为第0代,如图5-9所示。当有垃圾回收执行时,未被回收的对象代龄将提升一级,变成第1代对象,而后新建的对象仍为第0代对象。也就是说,代龄越小,表示对象越新,通常情况下其生命周期也最短,因此垃圾收集器总是首先收集第0代的不可达对象内存。
随着对象的不断创建,垃圾收集再次启动时则只会检查0代对象,并回收0代垃圾对象。而1代对象由于未达到预定的1代容量阙值,则不会进行垃圾回收操作,从而有效的提高了垃圾收集的效率,这就是代龄机制在垃圾回收中的性能优化作用。

图5-9 初次执行垃圾回收
那么,垃圾收集器在什么情况下,才执行对第1代对象的收集呢?答案是仅当第0代对象释放的内存不足以创建新的对象,同时1代对象的体积也超出了容量阙值时,垃圾收集器将同时对0代和1代对象进行垃圾回收。回收之后,未被回收的1代对象升级为2代对象,未被回收的0代对象升级为1代对象,而后新建的对象仍为第0代对象,如图5-10所示。垃圾收集正是对上述过程的不断重复,利用分代机制提高执行效率。

图5-10 执行1代对象垃圾回收
通过GC.Collect方法可以指定对从第0代到指定代的对象进行回收,通过GC. MaxGeneration来获取框架版本支持的代龄的最大有效值。
规则小结
关于垃圾回收,对其有以下几点小结:
l CLR提供了一种分代式、标记清除型GC,利用标记清除算法来对不同代龄的对象进行垃圾收集和内存紧缩,保证了运算效率和执行优化。
l 一个对象没有被其他任何对象引用,则该对象被认为是可以回收的对象。
l 最好不要通过调用GC.Collect来强制执行垃圾收集。
l 垃圾对象并非立即被执行内存清理,GC可以在任何时候执行垃圾收集。
l 对“胖”对象考虑使用弱引用,以提高性能,详见5.4节“性能优化的多方探讨”。
5.3.3 非托管资源清理
对于大部分的类型来说,只存在内存资源的分配与回收问题,因此CLR的处理已经能够满足这种需求,然而还有部分的类型不可避免的涉及访问其他非托管资源。常见的非托管资源包括数据库链接、文件句柄、网络链接、互斥体、COM对象、套接字、位图和GDI+对象等。
GC全权负责了对托管堆的内存管理,而内存之外的资源,又该由谁打理?在.NET中,非托管资源的清理,主要有两种方式:Finalize方法和Dispose方法,这两种方法提供了在垃圾收集执行前进行资源清理的方法。Finalize方式,又称为终止化操作,其大致的原理为:通过对自定义类型实现一个Finalize方法来释放非托管资源,而终止化操作在对象的内存回收之前通过调用Finalize方法来释放资源;Dispose模式,指的是在类中实现IDisposable接口,该接口中的Dispose方法定义了显式释放由对象引用的所有非托管资源。因此,Dispose方法提供了更加精确的控制方式,在使用上更加的灵活。
1.终止化操作
对C++程序员来说,提起资源释放,会首先想到析构器。不过,在.NET世界里,没落的析构器已经被终结器取而代之,.NET在语法上选择了类似的实现策略,例如你可以有如下定义:
class GCApp: Object
{
~GCApp()
{
//执行资源清理
}
}
将上述代码编译为IL:
.method family hidebysig virtual instance void
Finalize() cil managed
{
// 代码大小 14 (0xe)
.maxstack 1
.try
{
IL_0000: nop
IL_0001: nop
IL_0002: leave.s IL_000c
} // end .try
finally
{
IL_0004: ldarg.0
IL_0005: call instance void [mscorlib]System.Object::Finalize()
IL_000a: nop
IL_000b: endfinally
} // end handler
IL_000c: nop
IL_000d: ret
} // end of method GCApp::Finalize
可见,编译器将~GCApp方法编译为托管模块元数据中一个Finalize方法,由于示例本身没有实现任何资源清理代码,上述Finalize方法只是简单调用了Object.Finalize方法。可以通过重写基类的Finalize方法实现资源清理操作,注意:自.NET 2.0起,C#编译器认为Finalize方法是一个特殊的方法,对其调用或重写必须使用析构函数语法来实现,不可以通过显式非覆写Finalize方法来实现。因此在自定义类型中重写Finalize方法将等效于:
protected override void Finalize()
{
try
{
//执行自定义资源清理操作
}
finally
{
base.Finalize();
}
}
由此可见,在继承链中所有实例将递归调用base.Finalize方法,也就是意味调用终结器释放资源时,将释放所有的资源,包括父类对象引用的资源。因此,在C#中,也无需调用或重写Object.Finalize方法,事实上显示的重写会引发编译时错误,只需实现虚构函数即可。
在具体操作上,终结器的工作原理是这样的:在Systm.Object中,Finalize方法被实现为一个受保护的虚方法,GC要求任何需要释放非托管资源的类型都要重写该方法,如果一个类型及其父类均未重写Systm.Object的Finalize方法,则GC认为该类型及其父类不需要执行终止化操作,当对象变成不可达对象时,将不会执行任何资源清理操作;而如果只有父类重写了Finalize方法,则父类会执行终止化操作。因此,对于在类中重写了Finalize的方法(在C#中实现析构函数),当GC启动时,对于判定为可回收的垃圾对象,GC会自动执行其Finalize方法来清理非托管资源。例如通常情况下,对于Window资源的释放,是通过调用Win32API的CloseHandle函数来实现关闭打开的对象句柄。
对于重写了Finalize方法的类型来说,可以通过GC. SuppressFinalize来免除终结。
对于Finalize方式来说,存在如下几个弊端,因此一般情况下在自定义类型中应避免重写Finalize方法,这些弊端主要包括:
l 终止化操作的时间无法控制,执行顺序也不能保证。因此,在资源清理上不够灵活,也可能由于执行顺序的不确定而访问已经执行了清理的对象。
l Finalize方法会极大地损伤性能,GC使用一个终止化队列的内部结构来跟踪具有Finalize方法的对象。当重写了Finalize方法的类型在创建时,要将其指针添加到该终止化队列中,由此对性能产生影响;另外,垃圾回收时调用Finalize方法将同时清理所有的资源,包括其父类对象的资源,也是影响性能的一个因素。
l 重写了Finalize方法的类型对象,其引用类型对象的代龄将被提升,从而带来内存压力。
l Finalize方法在某些情况下可能不被执行,例如可能某个终结器被无限期的阻止,则其他终结器得不到调用。因此,应该确保重写的Finalize方法尽快被执行。
基于以上原因,应该避免重写Finalize方法,而实现Dispose模式来完成对非托管资源的清理操作,具体实现见下文描述。
对于Finalize方法,有以下规则值得总结:
l 在C#中无法显示的重写Finalize方法,只能通过析构函数语法形式来实现。
l struct中不允许定义析构函数,只有class中才可以,并且只能有一个。
l Finalize方法不能被继承或重载。
l 析构函数不能加任何修饰符,不能带参数,也不能被显示调用,唯一的例外是在子类重写时,通过base调用父类Finalize方法,而且这种方式也被隐式封装在析构函数中。
l 执行垃圾回收之前系统会自动执行终止化操作。
l Finalize方法中,可以实现使得被清理对象复活的机制,不过这种操作相当危险,而且没有什么实际意义,仅作参考,不推荐使用:
public class ReLife
{
~ReLife()
{
//对象重新被一个根引用
Test_ReLife.Instance = this;
//重新将对象添加到终止化队列
GC.ReRegisterForFinalize(this);
}
public void ShowInfo()
{
Console.WriteLine("对象又复活了。");
}
}
public class Test_ReLife
{
public static ReLife Instance;
public static void Main()
{
Instance = new ReLife();
Instance = null;
GC.Collect();
GC.WaitForPendingFinalizers();
//对象又复活了
Instance.ShowInfo();
}
}
2.Dispose模式
另一种非托管资源的清理方式是Dispose模式,其原理是定义的类型必须实现System.IDisposable接口,该接口中定义了一个公有无参的Dispose方法,用户可以在该方法中实现对非托管资源的清理操作。在此,我们实现一个典型的Dispose模式:
class MyDispose : IDisposable
{
//定义一个访问外部资源的句柄
private IntPtr _handle;
//标记Dispose是否被调用
private bool disposed = false;
//实现IDisposable接口
public void Dispose()
{
Dispose(true);
//阻止GC调用Finalize方法
GC.SuppressFinalize(this);
}
//实现一个处理资源清理的具体方法
protected virtual void Dispose(bool disposing)
{
if (! disposed)
{
if (disposing)
{
//清理托管资源
}
//清理非托管资源
if (_handle != IntPtr.Zero)
{
//执行资源清理,在此为关闭对象句柄
CloseHandle(_handle);
_handle = IntPtr.Zero;
}
}
disposed = true;
}
public void Close()
{
//在内部调用Dispose来实现
Dispose();
}
}
在上述实现Dispose模式的典型操作中,有几点说明:
l Dispose方法中,应该使用GC. SuppressFinalize防止GC调用Finalize方法,因为显式调用Dispose显然是较佳选择。
l 公有Dispose方法不能实现为虚方法,以禁止在派生类中重写。
l 在该模式中,公有Dispose方法通过调用重载虚方法Dispose(bool disposing)方法来实现,具体的资源清理操作实现于虚方法中。两种策略的区别是:disposing参数为真时,Dispose方法由用户代码调用,可释放托管或者非托管资源;disposing参数为假时,Dispose方法由Finalize调用,并且只能释放非托管资源。
l disposed字段,保证了两次调用Dispose方法不会抛出异常,值得推荐。
l 派生类中实现Dispose模式,应该重写基类的受保护Dispose方法,并且通过base调用基类的Dispose方法,以确保释放继承链上所有对象的引用资源,在整个继承层次中传播Dispose模式。
protected override void Dispose(bool disposing)
{
if (!disposed)
{
try
{
//子类资源清理
//......
disposed = true;
}
finally
{
base.Dispose(disposing);
}
}
}
l 另外,基于编程习惯的考虑,一般在实现Dispose方法时,会附加实现一个Close方法来达到同样的资源清理目的,而Close内部其实也是通过调用Dispose来实现的。
3.最佳策略
最佳的资源清理策略,应该是同时实现Finalize方式和Dispose方式。一方面,Dispose方法可以克服Finalize方法在性能上的诸多弊端;另一方面,Finalize方法又能够确保没有显式调用Dispose方法时,也自行回收使用的所有资源。事实上,.NET框架类库的很多类型正是同时实现了这两种方式,例如FileStream等。因此,任何重写了Finalize方法的类型都应实现Dispose方法,来实现更加灵活的资源清理控制。
因此,我们模拟一个简化版的文件处理类FileDealer,其中涉及对文件句柄的访问,以此来说明在自定义类型中对非托管资源的清理操作,在此同时应用Finalize方法和Dispose方法来实现:
class FileDealer: IDisposable
{
//定义一个访问文件资源的Win32句柄
private IntPtr fileHandle;
//定义引用的托管资源
private ManagedRes managedRes;
//定义构造器,初始化托管资源和非托管资源
public FileDealer(IntPtr handle, ManagedRes res)
{
fileHandle = handle;
managedRes = res;
}
//实现终结器,定义Finalize
~FileDealer()
{
if(fileHandle != IntPtr.Zero)
{
Dispose(false);
}
}
//实现IDisposable接口
public void Dispose()
{
Dispose(true);
//阻止GC调用Finalize方法
GC.SuppressFinalize(this);
}
//实现一个处理资源清理的具体方法
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
//清理托管资源
managedRes.Dispose();
}
//执行资源清理,在此为关闭对象句柄
if (fileHandle != IntPtr.Zero)
{
CloseHandle(fileHandle);
fileHandle = IntPtr.Zero;
}
}
public void Close()
{
//在内部调用Dispose来实现
Dispose();
}
//实现对文件句柄的其他应用方法
public void Write() { }
public void Read() { }
//引入外部Win32API
[DllImport("Kernel32")]
private extern static Boolean CloseHandle(IntPtr handle);
}
注意,本例只是一个简单化的演示,并非专门的设计文件操作类型。在.NET框架中的FileStream类中,文件句柄被封装到一个SafeFileHandle的类中实现,该类间接继承于SafeHandle抽象类。其中SafeHandle类型是一个对操作系统句柄的包装类,实现了对本地资源的封装,因此对于大部分的资源访问应用来说,以SafeHandle的派生类作为操作系统资源的访问方式,是安全而可信的,例如FileStream中的SafeFileHandle类,就是对文件句柄的有效包装。
4.using语句
using语句简化了资源清理代码实现,并且能够确保Dispose方法得到调用,因此值得推荐。凡是实现了Dispose模式的类型,均可以using语句来定义其引用范围。关于using语句的详细描述,请参考6.3节“using的多重身份”,在此我们将演示引用using语句实现对上述FileDealer类的访问:
public static void Main()
{
using(FileDealer fd = new FileDealer(new IntPtr(), new ManagedRes()))
{
fd.Read();
}
}
上述执行,等效于实现了一个try/finally块,并将资源清理代码置于finally块中:
public static void Main()
{
FileDealer fd = null;
try
{
fd = new FileDealer(new IntPtr(), new ManagedRes());
fd.Read();
}
finally
{
if(fd != null)
fd.Dispose();
}
}
5.规则所在
对于Finalize方法和Dispose方法,有如下的规则,留作参考:
l 对于非托管资源的清理,Finalize由GC自行调用,而Dispose由开发者强制执行调用。
l 尽量避免使用Finalize方式来清理资源,必须实现Finalize时,也应一并实现Dispose方法,来提供显式调用的控制权限。
l 通过GC. SuppressFinalize可以免除终结。
l 垃圾回收时,执行终结器的准确时间是不确定的,除非显式的调用Dispose或者Close方法。
l 强烈建议不要重写Finalize方法,同时强烈建议在任何有非托管资源访问的类中同时实现终止化操作和Dispose模式。
l Finalize方法和Dispose方法,只能清理非托管资源,释放内存的工作仍由GC负责。
l 对象使用完毕应该立即释放其资源,最好显式调用Dispose方法来实现。
5.3.4 结论
.NET自动内存管理,是CLR提供的最为重要的基础服务之一。通过本节对垃圾回收和非托管资源的管理分析,可以基本了解CLR对系统资源管理回收方面的操作本质。对于开发人员来说,GC全权负责了对内存的管理、监控与回收,我们应将更多的努力关注于非托管资源的清理方式的理解和应用上,以提升系统资源管理的性能和安全。






