由于.NET拥有垃圾回收机制,为什么我们还需要finalizers/destructors/dispose-pattern?

70

如果我理解正确,.net运行时将始终为我清理垃圾。因此,如果我创建新对象并在代码中停止引用它们,运行时将清理这些对象并释放它们占用的内存。

既然如此,为什么有些对象需要有析构函数或dispose方法呢?当它们不再被引用时,运行时不会自动清理吗?

12个回答

96
终结器是必需的,以确保稀缺资源(如文件句柄、套接字、内核对象等)被释放回系统。由于终结器总是在对象生命周期结束时运行,因此它是释放这些句柄的指定位置。
“Dispose”模式用于提供资源的确定性销毁。由于.NET运行时垃圾收集器是非确定性的(这意味着您永远无法确定运行时何时收集旧对象并调用它们的终结器),因此需要一种方法来确保系统资源的确定性释放。因此,当您正确实现“Dispose”模式时,您可以提供资源的确定性释放,在消费者粗心大意且未处理对象的情况下,终结器将清理对象。
一个简单的例子说明为什么需要“Dispose”可能是一个快速而肮脏的日志方法:
public void Log(string line)
{
    var sw = new StreamWriter(File.Open(
        "LogFile.log", FileMode.OpenOrCreate, FileAccess.Write, FileShare.None));

    sw.WriteLine(line);

    // Since we don't close the stream the FileStream finalizer will do that for 
    // us but we don't know when that will be and until then the file is locked.
}

在上面的例子中,文件将保持锁定状态,直到垃圾回收器调用StreamWriter对象的终结器。这会带来问题,因为在此期间,可能会再次调用该方法来写日志,但这次将失败,因为文件仍然被锁定。
正确的方法是在使用完对象后进行处理:
public void Log(string line)
{
    using (var sw = new StreamWriter(File.Open(
        "LogFile.log", FileMode.OpenOrCreate, FileAccess.Write, FileShare.None))) {

        sw.WriteLine(line);
    }

    // Since we use the using block (which conveniently calls Dispose() for us)
    // the file well be closed at this point.
}

顺便说一句,技术上来说,finalizers 和 destructors 意思相同;我更倾向于称 C# 的析构函数为 "finalizers",因为否则它们很容易与 C++ 的析构函数混淆,而 C++ 的析构函数与 C# 不同,是确定性的。


4
我认为这是最好的答案。其中最重要的部分,也是我们使用一次性语法的原因,是为了确保稀缺资源得到确定性释放。非常棒的文章。 - Rob
1
好的回答,尽管终结器不会在对象生命周期结束时自动运行。否则我们就不需要可处理模式了。当GC确定需要运行它们时(谁知道何时),它们会被调用。 - Ricardo Villamil
1
仅供记录。无法保证 Finalizers 能够运行。它们由专用线程按顺序执行,因此如果一个 Finalizer 进入死锁状态,就不会运行其他 Finalizer(并且内存将泄漏)。显然,Finalizer 不应该阻塞,但我只是指出存在一些注意事项。 - Brian Rasmussen
这可能就是为什么有传言称该框架可能会开始使用线程池来执行终结器的原因。 - Yona
1
Eric Lippert最近在博客中谈到了析构函数/终结器之间的区别。http://blogs.msdn.com/ericlippert/archive/2010/01/21/what-s-the-difference-between-a-destructor-and-a-finalizer.aspx - Ian
显示剩余3条评论

21
之前的回答很好,但让我再次强调这里的重点。特别是,您说:
“如果我理解正确,.NET运行时将始终在我之后进行清理。”
这只有部分正确。实际上,.NET仅为一种特定资源 - 主内存提供自动管理。1)其他所有资源都需要手动清理。
奇怪的是,在几乎所有有关程序资源的讨论中,主内存都被赋予了特殊地位。当然,这有很好的原因 - 主内存经常是最稀缺的资源。但值得记住的是,还有其他类型的资源需要进行管理。
1)通常尝试的解决方案是将其他资源的生命周期与代码中的内存位置或标识符的生命周期耦合 - 因此出现了终结器。

1
你可以通过提及这是错误的解决方案来改进那个注释!可替代和不可替代的商品必须以不同的方式处理。 - Daniel Earwicker
Earwicker:我同意你的观点。然而,由于我不知道任何实现可行替代方案的语言,我真的不知道什么会更好。特别是因为每个资源都绑定到一个标识符,而该标识符的生命周期与其内存相同。 - Konrad Rudolph
C# 的 using 关键字是一个可行的替代方案:当执行离开代码块时,就该释放资源了。对于非同质化资源来说,这比将它们的生命周期与类似已释放内存的同质化资源绑定在一起要好得多。 - Daniel Earwicker
@Earwicker:这就是我不再同意的地方。using有其优点和缺点,但我不确定前者是否超过后者。当然,这取决于应用程序,但在我编写的几乎每个程序中,非托管资源管理都是至关重要的一部分,而C++在这方面让我的生活更加轻松。 - Konrad Rudolph
你可能想看一下C++/CLI,了解析构函数如何完美映射到IDisposable接口上。我认为C++/CLI的支持更全面,因为它会自动传播Dispose调用到成员对象、继承对象等,而C#的using关键字只是复制了C++中处理栈上对象的方式。 - Daniel Earwicker
@DanielEarwicker:在C++/CLI中有没有方便的方法可以拥有一个自动清理的IDisposable字段,该字段是通过构造函数而不是字段初始化程序初始化的? 可以通过将IDisposable类型封装在struct DisposableHolder<T>:IDisposable where T:class类型中来实现它,但那似乎很棘手。 - supercat

9
垃圾收集器只有在系统没有内存压力时才会运行,除非它确实需要释放一些内存。这意味着你永远无法确定GC何时运行。
现在,想象一下你是一个数据库连接。如果你让GC在你之后清理,那么你可能会连接到数据库比需要的时间更长,导致奇怪的负载情况。在这种情况下,你需要实现IDisposable,这样用户就可以调用Dispose()或使用using()来确保连接尽快关闭,而不必依赖可能会更晚运行的GC。
通常,任何使用非托管资源的类都会实现IDisposable。

2
垃圾回收器只有在系统没有内存压力的情况下才会运行,除非它确实需要释放一些内存。实际上,这个说法是不正确的。GC 在三种情况下运行(其中只有一种是确定性的):1)当请求内存分配并且该对象代的当前段大小已超过时,2)系统处于内存压力下(操作系统),3)AppDomain 正在卸载。 - Dave Black
1
通常,IDisposable被实现在任何使用非托管资源的类上。然而,这种说法并不准确。当一个类成员实现了IDisposable接口时,应该实现IDisposable模式。当你处理非托管资源时,也应始终实现IDisposable模式。 - Dave Black

5
  1. 垃圾收集器无法清理的东西
  2. 即使是它可以清理的东西,您也可以帮助它更早地清理

2
实际原因是因为.net垃圾回收器并没有被设计来回收“非托管资源”,因此这些资源的清理仍然掌握在开发人员手中。同时,当对象超出范围时,对象终结器不会自动调用。它们由GC在某个未确定的时间调用。当它们被调用时,GC不会立即运行它,而是等待下一轮调用它,增加了清理时间,这对于保存稀缺非托管资源(如文件或网络连接)的对象来说并不是一个好事情。
这时就需要使用可释放模式,开发人员可以在确定的时间(调用yourObject.Dispose()或using(...)语句时)手动释放稀缺资源。请注意,在dispose方法中应该调用GC.SuppressFinalize(this);告诉GC对象已经被手动释放,不应该被终结。
我建议您阅读K. Cwalina和B. Abrams的《框架设计指南》。它很好地解释了可释放模式。
祝好运!

2
简单的解释:
Dispose 专为确定性地处理非内存资源,特别是稀缺资源而设计。例如,窗口句柄或数据库连接。
Finalize 则专为非确定性地处理非内存资源而设计,通常作为如果 Dispose 没有被调用的备用选项。
实现 Finalize 方法的一些指南:
仅在需要终结的对象上实现 Finalize,因为 Finalize 方法会带来性能成本。
如果需要 Finalize 方法,请考虑实现 IDisposable,以允许您类型的用户避免调用 Finalize 方法的成本。
您的 Finalize 方法应该是 protected 而不是 public。
您的 Finalize 方法应释放类型所拥有的任何外部资源,但仅限于它自己拥有的资源。它不应引用任何其他资源。
CLR 不保证按照何种顺序调用 Finalize 方法。正如 Daniel 在他的评论中提到的那样,这意味着 Finalize 方法不应尽可能地访问任何成员引用类型,因为这些类型可能(或将来可能)具有自己的终止程序。
永远不要直接调用除类型基类型之外的任何类型的 Finalize 方法。
尽量避免在 Finalizer 方法中发生任何未处理的异常,因为这将终止进程(在2.0或更高版本中)。
避免在 Finalizer 方法中执行任何长时间运行的任务,因为这将阻止 Finalizer 线程并防止执行其他 Finalizer 方法。
实现 Dispose 方法的一些指南:
在封装需要明确释放的资源的类型上实现 dispose 设计模式。
即使基类型不需要,也要在一个或多个派生类型拥有资源的基类型上实现 dispose 设计模式。
在实例上调用 Dispose 后,通过调用 GC.SuppressFinalize 方法防止 Finalize 方法运行。唯一例外的规则是必须在 Finalize 中完成未涵盖的工作的罕见情况。
不要假设将调用 Dispose。如果未调用 Dispose,则应在 Finalize 方法中释放类型拥有的非托管资源。
从此类型的实例方法(除 Dispose 以外)抛出 ObjectDisposedException,当资源已经被处理时。此规则不适用于 Dispose 方法,因为应该可以多次调用而不抛出异常。
通过基类型的层次结构传播对 Dispose 的调用。Dispose 方法应释放此对象和由此对象拥有的任何对象所保存的所有资源。
考虑不允许在其 Dispose 方法被调用后使用对象。重新创建已被处理的对象是一种难以实现的模式。
允许对 Dispose 方法进行多次调用而不抛出异常。方法在第一次调用后不应执行任何操作。

1

垃圾回收器可以清理资源,只是你不知道何时。 - Sunny Milenov
2
GC(垃圾回收器)通常可以清理资源,但并非总是如此。例如,在System.DirectoryServices.SearchResultCollection的MSDN文档中:“由于实现限制,当SearchResultCollection类被垃圾回收时,它无法释放所有未受管控的资源。” - Joe

1
主要用于非托管代码和与非托管代码的交互。 "纯"托管代码不应该需要终结器。 另一方面,可处理对象只是一种方便的模式,可以在使用完后强制释放它。

0

有些对象可能需要清理低级别的项目。例如需要关闭的硬件等。


0

.NET垃圾回收器知道如何处理.NET运行时中的托管对象。但Dispose模式(IDisposable)主要用于应用程序正在使用的非托管对象。

换句话说,.NET运行时不一定知道如何处理所有类型的设备或句柄(关闭网络连接、文件句柄、图形设备等),因此使用IDisposable提供了一种方式来表明“让我实现自己的清理”在一个类型中。看到这个实现,垃圾回收器可以调用Dispose()并确保管理堆之外的事物被清理。


感谢您的澄清,将“在.NET堆栈/堆之外”更改为“托管堆”。 - Jeff Donnici

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接