内容借鉴-C#8.0本质论
终结器
终结器 (finalizer) 允许程序员写代码来清理类的资源。与使用new
操作符显式调用构造函数不同,终结器不能从代码中显式调用。没有和new
对应的操作符 (比如像delete
这样的操作符)。相反,是垃圾回收器负责为对象实例调用终结器。因此,开发者不能在编译时确定终结器的执行时间。唯一确定的是终结器会在对象最后一次使用之后,并“通常”在应用程序正常关闭前的某个时间运行。这里“通常”一词是为了强调事实上终结器有可能不会被调用,尤其当程序被强行关闭时。例如,计算机关机,或者程序被调试器强行终止,终结器都很有可能不会被调用。
- 再次强调,终结器不能显式调用,只有垃圾回收器才能调用。
一、终结器的定义
终结器的声明与构造函数类似,但要求在类名之前添加一个~
字符。终结器不允许传递任何参数,所以不可重载。此外,终结器不能显式调用。调用终结器的只能是垃圾回收器。因此,为终结器添加访问修饰符没有意义 (也不支持) 。基类中的终结器作为对象终结调用的一部分被自动调用。
public class FinalizerTest
{
~FinalizerTest() { ... }
}
由于垃圾回收器负责所有内存管理,所以终结器不负责回收内存。相反,它们负责释放像数据库连接和文件句柄这样的资源这些资源需通过一次显式的行动来进行清理,而垃圾回收器不知道具体如何采取这些行动。
在设计程序时,应该避免实现没有必要的终结器。
二、终结器的示例
public class FinalizerTest
{
public FileStream? Stream { get; private set; }
public FileInfo? File { get; private set; }
public FinalizerTest() : this(Path.GetTempFileName()) { }
public FinalizerTest(string fileName)
{
File = new FileInfo(fileName);
Stream = new FileStream(File.FullName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
}
~FinalizerTest()
{
try
{
Close();
}
catch (Exception ex)
{
//异常处理
}
}
public void Close()
{
Stream?.Dispose();
try
{
File?.Delete();
}
catch (IOException exception)
{
Console.WriteLine(exception);
}
Stream = null;
File = null;
}
}
示例中,终结器的主要目的是调用File.Delete()
,将在构造函数中创建的临时文件删除。但是它首先调用了FileStream
对象的Dispose()
方法将该对象销毁。FileStream
类有自己的终结器,并具有与Dispose()
相同的效果,因此通常不是必须要调用其Dispose()
方法。但在这里我们需要确保该FileStream
对象的销毁先于File.Delete()
发生,否则Delete()
无法删除文件。反之,如果不通过实现终结器,主动销毁FileStream
对象,则不能预测它的终结器与FinalizerTest对象的终结器哪一个先被调用,导致不能预测文件能否被删除。
IDisposable
注意终结器在自己的线程中执行,这使它们的执行变得更不确定。这种不确定性使终结器中(调试器外)未处理的异常变得难以诊断,因为造成异常的情况是不确定的。从用户的角度看,未处理异常的引发时机显得相当随机,跟用户当时执行的任何操作都没有太大关系。有鉴于此,一定要在终结器中避免异常。为此需采取一些防卫性编程技术,比如空值检查等。实际上,通常应该在终结器中捕获所有可能发生的异常,并通过其他途径进行汇报,而不是放任这些异常逃逸,留给系统默认处理。更好的方式是将异常捕获并记录在日志文件中或者显示在用户界面的诊断信息区等。就这个原因,上面的示例代码中将File.Delete()
包围在try/catch
块中。
终结器的问题在于不支持确定性终结(也就是预知终结器在何时运行)。相反,终结器是对资源进行清理的备用机制。假如类的使用者忘记显式调用必要的清理代码就得依赖终结器清理资源。
因此,很有必要提供进行确定性终结的方法,来避免依赖终结器不确定的行为。
- 例如终结器的示例中,即使开发者忘记显式调用Close(),终结器也会调用它。虽然终结器运行得会晚一些 (相较于显式调用Close() ),但该方法肯定会得到调用。此时Close()就是确定性终结的方法。
由于确定性终结的重要性,基类库为这个使用模式包含了特殊接口,而且C#已将这个模式集成到语言中。IDisposable
接口用名为Dispose()
的方法定义了该模式的细节。
将上面的终结器中的示例进一步优化,实现IDisposable
接口,通过Dispose()
来提供确定性终结的方法。
using FinalizerTest ft = new FinalizerTest("D://test.txt");
public class FinalizerTest : IDisposable
{
public FileStream? Stream { get; private set; }
public FileInfo? File { get; private set; }
public FinalizerTest() : this(Path.GetTempFileName()) { }
public FinalizerTest(string fileName)
{
File = new FileInfo(fileName);
Stream = new FileStream(File.FullName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
}
~FinalizerTest()
{
Dispose(false);
}
public void Dispose()
{
Dispose(true);
// Unregister from the finalization queue
GC.SuppressFinalize(this);
}
public void Dispose(bool disposing)
{
if (disposing)
{
Stream?.Close();
}
try
{
File?.Delete();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
Stream = null;
File = null;
}
}
关于using
using
语句只是提供了try/finally
块的语法快捷方式,并且在finally
中会执行对应对象的Dispose()
方法。也就是说,使用using
的变量必须都实现了IDisposable
接口。
using
语句目前有两种使用方式,一种是使用using{}
,可以一次性生命多个变量,用逗号隔开;另一种是在C#8.0之后才出现的,直接在声明变量之前使用using
关键字,但每次只能声明一个变量。此外,用using
关键字声明的变量还有一个限制,就是这种变量不能再被赋值为其他值。
示例
using FinalizerTest ftA = new FinalizerTest(), ftB = new FinalizerTest()
{
//....
};
using FinalizerTest ftC = new FinalizerTest();
示例中有如下几个要点需要认真关注:
- 示例中,调用了
System.GC.SuppressFinalize()
,作用是从终结 (f-reachable) 队列中移除FinalizerTest类实例。这是因为所有清理都在Dispose()
方法中完成了,而不是等着终结器执行。如果不调用SuppressFinalize()
,对象的实例会包括到f-reachable队列中。该队列中的对象已差不多准备好了进行垃圾回收,只是它们还有终结方法没有运行。这种对象只有在其终结方法被调用之后,才能由“运行时”进行垃圾回收。但垃圾回收器本身不调用终结方法。相反,对这种对象的引用会添加到f-reachable队列中,并由一个额外的线程根据执行上下文,挑选合适的时间进行处理。这就造成了托管资源的垃圾回收时间的推迟,而许多这样的资源本应更早一些被清理。推迟是因为f-reachable队列是“引用”列表。所以,对象只有在它的终结方法得到调用,而且对象引用从f-reachable队列中删除之后,才会真正变成“垃圾”。需要注意的是,有终结器的对象如果不显式dispose
,其生存期会被延长,因为即使对它的所有显式引用都不存在了,f-reachable队列仍然包含对它的引用,使对象直生存,直至f-reachable队列处理完毕。正是由于这个原因,Dispose()
才调用System.GC.SuppressFinalize()
,告诉“运行时”不要将该对象添加到f-reachable队列而是允许垃圾回收器在对象没有任何引用(包括任何f-reachable引l用) 时清除对象。 Dispose()
调用了Dispose(bool disposing)方法,并传递实参true
。结果是为Stream
调用Dispose()
方法 (清理它的资源并阻止终结)。接着,临时文件在调用Dispose()
后立即删除。这个重要的调用避免了一定要等待终结队列处理完毕才能清理资源的限制。- 终结器现在不是调用Close()而是调用Dispose(bool disposing),并传递实参
false
。结果是即使文件被删除,Stream
也不会关闭 (disposed) 。原因是从终结器中调用Dispose(bool disposing)时,Stream
实例本身还在等待终结 (或者已经终结,系统会以任意顺序终结对象)所以,在执行终结器时,拥有托管资源的对象不应清理,那应该是终结队列的职责。
设计规范
在使用终结器和实现IDisposable
接口的过程中,有如下几点设计规范:
- 要只为使用了稀缺或昂贵资源的对象实现终结器方法,即使终结会推迟垃圾回收。
- 要为有终结器的类实现
IDisposable
接口以支持确定性终结。 - 只有当类包含必须释放的资源,而该资源自己又没有终结器的时候,才要在类中实现终结器。
- 要重构终结器方法来调用与
IDisposable
相同的代码,可能就是调用一下Dispose()
方法。 - 不要在终结器方法中抛出异常。
- 要从
Dispose()
中调用System.GCSuppressFinalize()
,以使垃圾回收更快地发生,并避免重复性的资源清理。 - 要保证
Dispose()
可以重入 (可被多次调用)。 - 要保持
Dispose()
的简单性,把重点放在终结所要求的资源清理上。 - 避免为自己拥有的、带终结器的对象调用
Dispose()
。相反,依赖终结队列清理实例。 - 避免在终结方法中引用未被终结的其他对象。
- 要在重写
Dispose()
时调用基类的实现。 - 考虑在调用
Dispose()
后将对象状态设为不可用。对象被dispose之后,调用除Dispose()
之外的方法应发ObjectDisposedException
异常(Dispose
方法则能多次调用)。 - 要为含有可dispose字段 (或属性)的类型实现
IDisposable
接口,并dispose这些字段引用的对象。 - 要在派生类的
Dispose()
中调用基类的Dispose()
。 - 若要提高终结器在程序结束之前被调用的可能性,考虑将终结器中的代码实现在事件处理器里并注册到
AppDomain.CurrentDomain.ProcessExitevent
中。 - 如果一个类同时注册了
AppDomain.CurrentDomain.ProcessExitevent
事件,又实现了Dispose()
,一定要在Dispose()
方法中解除注册的事件。