C#中的非托管资源释放机制详解|Finalizer与Dispose模式
目录
- 1 前言
- 1.1 托管资源(Managed Resources)
- 1.2 非托管资源(Unmanaged Resources)
- 1.3 混合型资源
- 1.4 非托管资源释放的必要性
- 2 释放非托管资源
- 2.1 Finalizer(终结器、析构函数)
- 2.1.1 定义
- 2.1.2 工作原理与问题
- 2.1.2.1 工作原理
- 不确定性
- 对内存删除时间的延迟影响
- 2.1.2.4 注意事项
- 2.2 IDisposable接口
- 2.2.1 实现IDisposable接口
- 示例
- 2.2.2 使用`using`语句
- 2.2.3 手动调用`Dispose()`方法
- 2.3 双重实现
- 2.4 线程安全的处理
- 添加同步锁对象,用 lock 保护资源释放逻辑:
- 补充(根据具体场景选择):
- 2.5 IDisposable和Finalizer的规则
- 3 总结
- 3.1 资源释放的基本原则
- 3.2 实践建议
- 4 参考资料
1 前言
在软件开发的中,资源的有效管理始终是保障程序健壮性与性能的核心课题。
资源分为:托管资源、非托管资源、混合型资源
1.1 托管资源(Managed Resources)
托管资源,一般由垃圾回收器(Garbage Collector, GC)的自动化内存管理,开发人员通常不需要担心不再需要的对象,只要让这些对象的所有引用都超出作用域,并允许垃圾收集器在需要时释放内存即可。例如:
- 字符串(
string
)、 - 数组(
Array
) - 集合类型(
List<T>
、Dictionary<TKey, TValue>
)、 - 自定义类实例(不包含非托管资源)
- 线程池中的线程(部分托管)
1.2 非托管资源(Unmanaged Resources)
不受CLR管理的资源,通常是通过调用外部库(如C++编写的库)或操作系统提供的接口来获取的资源。例如:
- 文件句柄
- 数据库连接
- 网络连接
- 窗口句柄
- 未托管的内存等
1.3 混合型资源
托管对象包装非托管资源,目的是为了方便在.NET环境中使用非托管资源,同时利用托管资源的管理机制。
示例
FileStream
(包装文件句柄)SqlConnection
(包装数据库连接)Bitmap
(包装图像资源)
1.4 非托管资源释放的必要性
垃圾收集器不知道如何释放非托管的资源。这些资源需要手动释放,否则可能会导致资源泄露、程序崩溃或系统性能下降等问题。
- 资源泄露:未释放的资源会一直占用系统资源,导致系统资源耗尽。
- 文件句柄:当程序打开一个文件进行读写操作时,操作系统会分配一个文件句柄。如果文件句柄不被及时释放,可能会导致文件被锁定,其他程序无法访问该文件,或者系统文件句柄资源耗尽。
- 程序崩溃:某些资源(如文件句柄)有限,如果被占用过多,可能会导致程序无法正常运行
- GDI对象(画笔、设备上下文等):在Windows操作系统中,如画笔(
HBRUSH
)、画笔(HPEN
)、设备上下文(HDC
)等GDI对象,它们由操作系统分配,需要手动释放,否则可能导致系统资源耗尽,影响图形界面的显示效果,甚至导致程序崩溃。
- GDI对象(画笔、设备上下文等):在Windows操作系统中,如画笔(
- 性能下降:未释放的资源会占用系统资源,影响系统性能。
- 网络套接字:如Socket连接,用于网络通信。如果不及时关闭Socket连接,可能会导致网络端口被占用,无法释放,影响网络通信的性能和稳定性。
- 数据库连接:连接到数据库时,会占用数据库服务器的连接资源。如果不手动关闭数据库连接,可能会导致数据库服务器的连接数达到上限,影响其他程序对数据库的访问。
在定义一个类时,可以使用两种机制来自动释放非托管的资源。使用托管对象包装非托管资源,目的是为了方便在.NET环境中使用非托管资源,同时利用托管资源的管理机制。
- 声明一个析构函数(或终结器),作为类的一个成员
- 实现System.IDisposable接口
这些机制常常放在一起实现,因为每种机制都为问题提供了略为不同的解决方法。
2 释放非托管资源
2.1 Finalizer(终结器、析构函数)
我们都知道构造函数可以指定必须在创建类的实例时进行的某些操作。
相反,在垃圾收集器销毁对象之前,也可以调用析构函数。
2.1.1 定义
-
定义方式和构造函数也很类似,一个没有返回值类型,签名和类名一致,没有入参的方法,不过多了一个前缀波形符(~)
class MyClass { ~MyClass() { // Finalizer implementation } }
-
C#编译器在编译析构函数时,它会隐式地把析构函数的代码编译为等价于重写
Finalize()
方法的代码,从而确保执行父类的Finalize()
方法。下面列出的C#代码等价于编译器为~MyClass()
析构函数生成的IL:protected override void Finalize() { try { // Finalizer implementation } finally { base.Finalize();//执行父类的Finalize()方法 } }
-
Finalizer特点:
-
非自动生成:必须显式定义
-
仅用于类:无法在结构中定义终结器。
-
唯一性:每个类只能有一个无参Finalizer
-
终结器不使用修饰符或参数。
-
不能继承或重载终结器。
-
执行不确定性:不能手动调用终结器,只能由GC自动触发,GC决定回收时机
-
在大多数情况下,通过使用System.Runtime.InteropServices.SafeHandle或派生类包装任何非托管句柄,可以免去编写终结器的过程。
这句话来源于Microsoft Learn,我的理解是SafeHandle实现了IDisposable接口+终结器的双重实现方式(下文有具体说明)。可查看源码safehandle.cs。
-
2.1.2 工作原理与问题
2.1.2.1 工作原理
垃圾回收器(GC)的工作流程如下:
-
GC 定期检查托管堆中的对象,找出不再被任何引用的对象,标记为不可达后,
-
如果对象没有 Finalizer 方法,垃圾收集器在标记为不可达后,因为对象没有额外的清理任务需要在销毁前执行,所以垃圾收集器可以迅速且高效地回收其占用的内存。
-
如果对象有 Finalizer 方法,GC 会将这些对象放入“终结队列”(Finalization Queue)。
终结队列:
- 作用:存储所有需要调用 Finalizer 方法的对象。
- 终结线程:一个独立的线程,负责从终结队列中取出对象并调用它们的 Finalizer 方法。
-
一个独立的终结线程(Finalizer Thread)会从终结队列中取出对象并调用它们的 Finalizer 方法。
-
在 Finalizer 方法执行完成后,对象才会被真正回收。
不确定性
C++开发者常利用析构函数不仅进行资源清理,还用于调试信息传递和其他任务。相比之下,C#析构函数的使用频率远不及C++。
- C++析构函数:对象销毁时,析构函数立即执行。
- C#析构函数:受垃圾收集器机制影响,析构函数的执行时间不确定。
对内存删除时间的延迟影响
C#析构函数的实现会导致对象从内存中最终删除的时间被延迟。具体来说:
- 无析构函数对象:垃圾收集器在一次处理中即可直接从内存中删除。
- 有析构函数对象:需要两次处理才能销毁。第一次处理时调用析构函数(或
Finalize()
方法),但并不立即删除对象;第二次处理时才真正从内存中删除对象。
此外,运行库使用一个单独的线程来执行所有对象的Finalize()
方法。如果析构函数被频繁使用,并且执行长时间的清理任务,将会对应用程序的性能产生显著影响。
2.1.2.4 注意事项
- 保持简单:仅做必要清理,避免复杂逻辑/阻塞
- 主动释放和及时释放: 若对象占用关键资源,应主动管理释放,而非依赖垃圾收集器。
- 不依赖执行顺序:多个Finalizer调用顺序不可控
- 禁止显式调用:只能由GC触发
- 异常处理:必须内部捕获所有异常
- 结合IDisposable:最佳实践方案
2.2 IDisposable接口
2.2.1 实现IDisposable接口
在 C#中,推荐使用 System.IDisposable
接口替代析构函数。
IDisposable
接口定义了一种模式(具有语言级的支持),该模式为释放非托管的资源提供了确定的机制,并避免产生析构函数固有的与垃圾收集器相关的问题。
-
IDisposable
接口声明了一个Dispose()
方法,它不带参数,返回void
。如下所示:class MyClass: IDisposable { public void Dispose() { // implementation } }
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
}
finally
{
theInstance?.Dispose();
}
2.2.2 使用using
语句
using
语句是.NET中用于确保资源在使用后被正确释放的语法糖。它会自动调用对象的Dispose()
方法,从而释放资源。
using (UnmanagedResource resource = new UnmanagedResource())
{
// 使用资源
}
// 资源在 using 块结束后自动释放
关键点说明:
using
语句的作用范围是using
块内部。一旦离开using
块,对象的Dispose()
方法会被自动调用。using
语句适用于实现了IDisposable
接口的对象。
2.2.3 手动调用Dispose()
方法
在某些情况下,可能需要手动调用Dispose()
方法来释放资源。例如,当资源不再需要时,可以立即释放,而不是等待using
块结束或对象被垃圾回收。
UnmanagedResource resource = new UnmanagedResource();
try
{
// 使用资源
}
finally
{
resource.Dispose(); // 手动释放资源
}
关键点说明:
- 在
finally
块中调用Dispose()
方法,可以确保即使在发生异常的情况下,资源也能被正确释放。
2.3 双重实现
前面讨论了自定义类所使用的释放非托管资源的两种方式:
- Finalizer:利用运行库强制执行的析构函数,但析构函数的执行是不确定的。而且,由于垃圾收集器的工作方式,它会给运行库增加不可接受的系统开销。
- IDisposable 接口:提供了一种机制,该机制允许类的用户控制释放资源的时间,但需要确保调用
Dispose()
方法。
如果创建了终结器,就应该实现 IDisposable
接口。同时把实现析构函数作为一种安全机制,以防没有调用 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 unmanaged objects
_isDisposed = true;
}
}
~ResourceHolder()
{
Dispose(false);
}
}
- 关键点说明:
Dispose(bool disposing)
方法:虚方法,真正完成清理工作的方法disposing
参数,用于区分是否需要释放托管资源- 为
true
true需要传参为时,表示可以释放托管资源;Dispose()
方法调用Dispose(bool disposing)
时,传参为true,此时明确进行释放。 - 为
false
时,表示只释放非托管资源。在析构函数中调用Dispose(false)
,以确保非托管资源在对象被垃圾回收时被释放。
- 为
Dispose()
方法:用于释放资源。-
调用
Dispose(true)
:释放托管资源和非托管资源 -
调用
GC.SuppressFinalize(this)
:明确告诉垃圾回收器资源已经清理完毕,以防止垃圾回收器调用析构函数,同时符合设计规范。在显式调用
Dispose()
方法后,资源已经被清理了,再调用析构函数可能会导致以下问题:- 重复释放资源:可能导致异常或未定义行为。
- 性能开销:垃圾回收器调用析构函数是不必要的。
- 死锁或资源竞争:析构函数可能会访问已被释放的资源。
-
- Finalizer
- 调用
Dispose(false)
:只释放非托管资源,确保非托管资源在对象被垃圾回收时被释放。
- 调用
2.4 线程安全的处理
如果资源可能被多个线程访问,需要确保资源释放的线程安全性。可以通过锁(如lock
)来避免并发问题。
添加同步锁对象,用 lock 保护资源释放逻辑:
使用专用对象作为同步锁是最佳实践,可以避免与其他锁的意外冲突。
多线程同时调用时,只有一个线程能执行实际清理
private readonly object _disposeLock = new object();
lock (_disposeLock)
{
if (!_isDisposed)
{
// 资源释放代码...
_isDisposed = true;
}
}
修改后:
public class ResourceHolder : IDisposable
{
private bool _isDisposed = false;
private readonly object _disposeLock = new object(); // 用于同步的锁对象
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
lock (_disposeLock) // 确保线程安全的互斥访问
{
if (!_isDisposed)
{
if (disposing)
{
// Cleanup managed objects by calling their
// Dispose() methods.
}
// Cleanup unmanaged objects
_isDisposed = true; // 在锁内确保原子性修改
}
}
}
~ResourceHolder()
{
Dispose(false);
}
}
补充(根据具体场景选择):
-
如果清理操作非常轻量,可以用更轻量的
Interlocked
代替锁:private int _isDisposed = 0; // 0 = false, 1 = true protected virtual void Dispose(bool disposing) { if (Interlocked.Exchange(ref _isDisposed, 1) == 0) { // 清理代码... } }
-
如果类中有其他需要线程安全的方法,建议:
public void SomeMethod() { lock (_disposeLock) { if (_isDisposed) throw new ObjectDisposedException(...); // 正常逻辑... } }
-
若需要支持并发调用且性能敏感,可考虑双重检查锁(Double-Check Locking)模式。
2.5 IDisposable和Finalizer的规则
-
如果类定义了实现 IDisposable 的成员,该类也应该实现 IDisposable。
例如,类中包含了一个
IDisposable
类型的对象作为字段,那么这个类本身也应该实现IDisposable
接口。-
如果类中包含
IDisposable
类型的成员,那么这些成员在类的生命周期内可能会占用资源。为了确保这些资源能够被正确释放,类本身需要实现IDisposable
接口,并在Dispose
方法中调用成员的Dispose
方法,以释放其占用的资源。 -
示例:
public class MyClass : IDisposable { private SomeDisposableResource _resource; public MyClass() { _resource = new SomeDisposableResource(); } public void Dispose() { // 释放资源 _resource.Dispose(); } }
在这个例子中,
MyClass
包含了一个实现了IDisposable
的成员_resource
,因此MyClass
也需要实现IDisposable
接口,并在Dispose
方法中调用_resource.Dispose()
,以确保_resource
所占用的资源能够被正确释放。
-
-
实现 IDisposable 并不意味着也应该实现一个终结器。
终结器会带来额外的开销,因为它需要创建一个对象,释放该对象的内存,需要 GC 的额外处理。
只在需要时才应该实现终结器,例如,发布本机资源。要释放本机资源,就需要终结器。
-
如果实现了终结器,也应该实现 IDisposable 接口。
这样,本机资源可以早些释放,而不仅是在 GC 找出被占用的资源时,才释放资源。
-
在终结器的实现代码中,不能访问已终结的对象了。
终结器的执行顺序是没有保证的。
5.如果所使用的一个对象实现了 IDisposable 接口,就需要在不再使用需对象时调用Dispose 方法。如果在方法中使用这个对象,using 语句比较方便。如果对象是类的一个成员,就让类也实现 Disposable。
3 总结
3.1 资源释放的基本原则
- 及时释放:资源(尤其是非托管资源)应尽可能早地释放,避免长时间占用系统资源,从而提高资源的利用率和系统的性能。
- 确保释放:无论代码执行过程中是否发生异常,资源都必须被释放。这可以通过使用
try-finally
块或using
语句来保证。 - 避免重复释放:资源一旦被释放,就不应再次尝试释放,否则可能导致程序崩溃或不可预测的行为。需要在代码中进行适当的检查和控制。
- 明确责任归属:明确资源的创建者和释放者,避免出现资源无人释放或重复释放的情况。通常,谁创建了资源,谁就应该负责释放它。
3.2 实践建议
- 实现
IDisposable
****接口:在包含非托管资源的类中实现IDisposable
接口,并在Dispose
方法中编写释放非托管资源的代码。 - 使用
using
****语句:在需要使用非托管资源的代码块中使用using
语句,以确保资源在代码块结束时被自动释放。 - 避免使用析构函数:由于析构函数的执行时机不确定且可能影响性能,因此通常不建议使用析构函数来释放非托管资源。如果确实需要使用析构函数作为最后的保障措施,也应在
Dispose
方法中调用GC.SuppressFinalize(this)
来阻止垃圾回收器调用析构函数。 - 双重实现机制:在实现
IDisposable
接口的类中,可以同时提供析构函数作为最后的保障措施。但请注意,析构函数中应只调用Dispose(false)
,以避免重复释放资源。 - 线程安全:如果资源可能被多个线程访问,需要确保资源释放的线程安全性。可以通过锁(如
lock
)来避免并发问题。
4 参考资料
- 《C#高级编程(第11版) C 7 .NET Core 2.0》 ([美]克里斯琴·内格尔,[美] Christian Nagel,Christian Nagel)
- 终结器 - C# | Microsoft Learn
- safehandle.cs
- IDisposable 接口 (System) | Microsoft Learn
- SafeHandle 类 (System.Runtime.InteropServices) | Microsoft Learn