垃圾收集器的出现意味着,通常不需要担心不再需要的对象,只要让这些对象的所有引用都超出作用域,并允许垃圾收集器在需要时释放内存即可。但是,垃圾收集器不知道如何释放非托管的资源(例如,文件句柄、网络连接和数据库连接)。托管类在封装对非托管资源的直接或间接引用时,需要制定专门的规则,确保非托管的资源在收集类的一个实例时释放。
在定义一个类时,可以使用两种机制来自动释放非托管的资源。这些机制常常放在一起实现,因为每种机制都为问题提供了略为不同的解决方法。这两种机制是:
- 声明一个析构函数(或终结器),作为类的一个成员
- 在类中实现System.IDisposable接口
下面依次讨论这两种机制,然后介绍如何同时实现它们,以获得最佳的效果。
1. 析构函数或终结器
前面介绍了构造函数可以指定必须在创建类的实例时进行的某些操作。相反,在垃圾收集器销毁对象之前,也可以调用析构函数。由于执行这个操作,因此析构函数初看起来是放置释放非托管资源、执行一般清理操作的代码的最佳地方。但是,事情并不是如此简单。
注意:
在讨论C#中的析构函数时,在底层的.NET体系结构中,这些函数称为终结器(finalizer)。在C#中定义析构函数时,编译器发送给程序集的实际上是Finalizer()方法。它不会影响源代码,但如果需要查看生成的IL代码,就应知道这个事实。
C++开发人员应很熟悉析构函数的语法。它看起来类似于一个方法,与包含的类同名,但有一个前缀波形符(~)。它没有返回类型,没有访问修饰符。下面是一个例子:
~MyClass()
{
//Finalizer implementation
}
C#编译器在编译析构函数时,它会隐式地把析构函数的代码编译为等价于重写Finalizer()方法的代码,从而确保执行父类的Finalizer()方法。下面列出的C#代码等价于编译器为~MyClass()析构函数生成的IL:
protected override void Finalize()
{
try
{
//Finalizer implementation
}
catch
{
base.Finalize();
}
}
如上所示,在~MyClass()析构函数中实现的代码封装在Finalize()方法的一个try块中。对父类的Finalize()方法的调用放在finally块中,确保该调用的执行。
有经验的C++开发人员大量使用了析构函数,有时不仅用于清理资源,还提供调试信息或执行其他任务。C#析构函数要比C++析构函数的使用少得多。与C++析构函数相比,C#析构函数的问题是它们的不确定性。在销毁C++对象时,其析构函数会立即执行。但由于使用C#时垃圾收集器的工作方式,无法确定C#对象的析构函数何时执行。所以,不能在析构函数中放置需要在某一时刻运行的代码,也不应寄希望于析构函数会以特定顺序对不同的实例调用。如果对象占用了宝贵而重要的资源,应尽快释放这些资源,此时就不能等待垃圾收集器来释放了。
另一个问题是C#析构函数的实现会延迟对象最终从内存中删除的时间。没有析构函数的对象会在垃圾收集器的一次处理中从内存中删除,但有析构函数的对象需要两次处理才能销毁:第一次调用析构函数时,没有删除对象,第二次调用才真正删除对象。另外,运行库使用一个线程来执行所有对象的Finalize()方法。如果频繁使用析构函数,而且使用它们执行长时间的清理任务,对性能的影响就会非常显著。
2. IDisposable接口
在C#中,推荐使用System.IDisposable接口替代析构函数。IDisposable接口定义了一种模式(具有语言级的支持),该模式为释放非托管的资源提供了确定的机制,并避免产生析构函数固有的与垃圾收集器相关的问题。IDisposable接口声明了一个Dispose()方法,它不带参数,返回void。MyClass类的Dispose()方法的实现代码如下:
class MyClass:IDisposable
{
public void Dispose()
{
//implementation
}
}
Dispose()方法的实现代码显示地释放由对象直接使用的所有非托管资源,并在所有也实现IDisposable接口的封装对象上调用Dispose()方法。这样,Dispose()方法为何时释放非托管资源提供了精确的控制。
假定有一个ResourceGobbler类,它需要使用某些外部资源,且实现IDisposable接口。如果要实例化这个类的实例,使用它,然后释放它,就可以使用下面的代码:
var theInstance = new ResourceGobbler();
//do your processing
theInstance.Dispose();
但是,如果在处理过程中出现异常,这段代码就没有释放theInstance使用的资源,所以应使用try块,编写下面的代码:
ResourceGobbler theInstance = null;
try
{
theInstance = new ResourceGobbler();
//do your processing
}
catch
{
theInstance?.Dispose();
}
3. using语句
使用try/finally,即使在处理过程中出现了异常,也可以确保总是在theInstance上调用Dispose()方法,总是释放theInstance使用的任意资源。但是,如果总是要重复这样的结构,代码就很容易被混淆。C#提供了一种语法,可以确保在实现IDisposable接口的对象的引用超出作用域时,在该对象上自动调用Dispose()方法。该语法使用了using关键字来完成此工作——该关键字在完全不同的环境下,它与名称空间没有关系。下面的代码生成与try块等价的IL代码:
using(var theInstance = new ResourceGobbler())
{
//do your processing
}
using语句的后面是一对圆括号,其中是引用变量的声明和实例化,该语句使变量的作用域限定在随后的语句块中。另外,在变量超出作用域时,即使出现异常,也会自动调用其Dispose()方法。
注意:
using关键字在C#中有多个用法。using声明用于导入名称空间。using语句处理实现IDisposable接口的对象,并在作用域的末尾调用Dispose方法。
注意:
.NET Framework中的几个类有Close和Dispose方法。如果常常要关闭资源(如文件和数据库),就实现Close和Dispose方法。此时Close()方法只是调用Dispose()方法。这种方法在类的使用上比较清晰,还支持using语句。新类只实现了Dispose方法,因为我们已经习惯了它。
4. 实现IDisposable接口和析构函数
前面的章节讨论了自定义类所使用的释放非托管资源的两种方式:
- 利用运行库强制执行的析构函数,但析构函数的执行是不确定的,而且,由于垃圾收集器的工作方式,它会给运行库增加不可接受的系统开销。
- IDisposable接口提供了一种机制,该机制允许类的用户控制释放资源的时间,但需要确保调用Dispose()方法。
如果创建了终结器,就应该实现IDisposable接口。假定大多数程序员都能正确调用Dispose()方法,同时把实现析构函数作为一种安全机制,以防没有调用Dispose()方法。下面是一个双重实现的例子:
public class ResourceHolder : IDisposable
{
private bool _isDisposed = false;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if(!_isDisposed)
{
if(disposing)
{
//Cleanup managed objects by calling their Dispose() methods.
}
//Cleanup unmanaed objects.
}
_isDisposed = true;
}
~ResourceHolder()
{
Dispose(false);
}
public void SomeMethod()
{
//Ensure object not already disposed before execution of any method
if(_isDisposed)
{
throw new ObjectDisposedException("ResourceHolder");
}
//method inplementation...
}
}
从上述代码可以看出,Dispose()方法有第二个protected重载方法,它带一个布尔参数,这是真正完成清理工作的方法。Dispose(bool)方法由析构函数和IDisposable.Dispose()方法调用。这种方式的重点是确保所有的清理代码都放在一个地方。
传递给Dispose(bool)方法的参数表示Dispose(bool)方法是由析构函数调用,还是由IDisposable.Dispose()方法调用——Dispose(bool)方法不应从代码的其他地方调用,其原因是:
- 如果使用者调用IDisposable.Dispose()方法,该使用者就指定清理所有与该对象相关的资源,包括托管和非托管的资源。
- 如果调用了析构函数,原则上所有的资源仍需要清理。但是在这种情况下,析构函数必须由垃圾收集器调用,而且用户不应试图访问其他托管的对象,因为我们不再能确定它们的状态了。在这种情况下,最好清理已知的非托管资源,希望任何引用的托管对象还有析构函数,这些析构函数执行自己的清理过程。
_isDisposed成员变量表示对象是否已被清理,并确保不试图多次清理成员变量。它还允许在执行实例方法之前测试对象是否已被清理,如SomeMethod()方法所示。这个简单的方法不是线程安全的,需要调用者确保在同一时刻只有一个线程调用方法。要求使用者进行同步是一个合理的假定,在整个.NET类库中(例如,在Collection类中)反复使用了这个假定。
最后,IDisposable.Dispose()方法包含一个对System.GC.SuppressFinalize()方法的调用。GC类表示垃圾收集器,SuppressFinalize()方法则告诉垃圾收集器有一个类不再需要调用其析构函数了。因为Dispose()方法已经完成了所有需要的清理工作,所以析构函数不需要做任何工作。调用SuppressFinalize()方法就意味着垃圾收集器认为这个对象根本没有析构函数。
5. IDisposable和终结器的规则
学习了终结器和IDisposable接口后,就已经了解了Dispose模式和使用这些构造的规则。因为释放资源是托管代码的一个重要方面,下面总结如下规则:
- 如果类定义了实现IDisposable的成员,该类也应该实现IDisposable。
- 实现IDisposable并不意味着也应该实现一个终结器。终结器会带来额外的开销,因为它需要创建一个对象,释放该对象的内存,需要GC的额外处理。只在需要时才应该实现终结器,例如,发布本机资源。要释放本机资源,就需要终结器。
- 如果实现了终结器,也应该实现IDisposable接口。这样,本机资源可以早些释放,而不仅是在GC找出被占用的资源时,才释放资源。
- 在终结器的实现代码中,不能访问已终结的对象了。终结器的执行顺序是没有保证的。
- 如果所使用的一个对象实现了IDisposable接口,就在不再需要对象时调用Dispose()方法。如果在方法中使用这个对象,using语句比较方便。如果对象是类的一个成员,就让类也实现IDisposable。