C# 的垃圾回收器(GC)负责自动管理托管内存,但并非所有资源都能被它直接处理。那些封装了非托管资源的类,比如文件句柄、数据库连接、GDI 对象等,需要在对象被回收前执行额外的清理工作。终结器(Finalizer)就是为这种场景设计的机制。当一个类定义了终结器,GC 在回收该对象时会先把它交给终结器线程去执行清理,然后才能真正释放内存。这个过程看似简单,但内部涉及多个队列和线程协作,理解其中的关系对于写出稳定高效的程序至关重要。

终结器的存在会改变对象的回收路径。没有终结器的普通对象,一旦被 GC 标记为不可达,就会在当次回收中直接释放内存。而带有终结器的对象,即使被判定为垃圾,也不能立即回收,必须等待终结器线程执行完终结方法,并且对象在终结方法中可能重新变得可达(比如把自身引用赋给了某个静态字段),这让 GC 的处理逻辑变得更加复杂。
Finalizer 线程与 GC 的基本协作流程
在 CLR 启动时,会创建一个专门的线程用于执行终结器,这个线程通常被称为 Finalizer 线程。它的职责只有一个:遍历 freachable 队列,取出其中的对象并调用它们的终结方法。但对象要进入 freachable 队列,必须经过 GC 的一轮特殊标记。
当 GC 开始回收时,它会扫描堆上的所有对象。对于没有终结器的对象,如果不可达就直接回收;对于定义了终结器的对象,GC 不会回收它,而是把该对象的引用放入终结队列(Finalization Queue)。终结队列是 GC 内部维护的一个数据结构,专门存放所有需要终结的对象。经过这次回收后,那些不可达的、带终结器的对象并没有被释放,而是留在了堆上,等待终结器线程处理。
GC 完成本轮回收后,终结器线程会被唤醒。它从终结队列中取出对象,把这些对象的引用转移到 freachable 队列(F-Reachable Queue)中。freachable 是“finalization reachable”的缩写,意思是“可终结可达”。这个队列中的对象虽然在逻辑上已经是垃圾,但因为有终结器线程的引用,它们暂时不会被 GC 认为不可达。换句话说,freachable 队列充当了一个“安全区”,确保对象在终结器执行期间不会被提前回收。
随后,终结器线程依次从 freachable 队列中取出对象,执行该对象的终结方法。终结方法执行完毕后,对象从 freachable 队列中移除。此时,对象不再被任何根引用或内部队列引用,它才真正成为不可达对象。等到下一次 GC 运行时,该对象才会被回收内存。
public class NativeResource
{
private IntPtr nativeHandle;
public NativeResource()
{
nativeHandle = AllocateNativeMemory(1024);
}
~NativeResource()
{
if (nativeHandle != IntPtr.Zero)
{
ReleaseNativeMemory(nativeHandle);
nativeHandle = IntPtr.Zero;
}
}
private static IntPtr AllocateNativeMemory(int size)
{
return IntPtr.Zero; // 示意代码
}
private static void ReleaseNativeMemory(IntPtr handle)
{
// 释放非托管内存
}
}
上面的代码展示了一个简单的终结器示例。对象被回收前会先执行终结器,释放通过 P/Invoke 分配的非托管内存。但需要注意的是,终结器执行的时间点完全由 GC 和终结器线程调度决定,程序员无法精确控制。
终结队列与 freachable 队列的详细过程
终结队列和 freachable 队列是理解 Finalizer 线程工作的核心。终结队列在对象创建时就可能被填充——只要对象类型定义了终结器,CLR 在通过 new 关键字分配该对象时,就会把对象的引用自动加入终结队列。这一点和很多人的直觉不同:终结队列并不是等到对象变成垃圾时才添加,而是在对象诞生时就已经登记在案。
当 GC 发生并判定一个带终结器的对象不可达时,它不会删除该对象在终结队列中的条目,而是等待终结器线程来处理。终结器线程被唤醒后,会从终结队列中取出对象,把对象引用放入 freachable 队列。注意这里有一个关键细节:对象从终结队列移动到 freachable 队列的过程,相当于对象暂时摆脱了“待终结”状态,但还没有被实际终结。只有 freachable 队列中的对象被终结器线程逐个执行终结方法后,才会被真正释放。
如果终结器线程因为某些原因没有及时处理 freachable 队列,这些对象就会一直留在堆上,占用的内存无法回收。更糟的是,这些对象可能引用了大量的托管资源,导致这些资源也无法被回收,形成事实上的内存泄漏。这也是为什么终结器被滥用时,程序的内存占用会持续升高。
另外,freachable 队列中的对象在终结方法执行期间,如果终结器内部把对象引用重新赋给了全局变量或静态字段,那么这个对象就“复活”了。GC 在下一次回收时会重新标记该对象为可达,终结队列中的条目也会重新被激活。这种复活行为虽然理论可行,但在实际开发中非常危险,容易造成难以追踪的状态混乱。
Finalizer 线程的执行细节与性能影响
Finalizer 线程是一个普通的后台线程,优先级和普通线程相同,但它只有一个实例,并且不会为每个对象创建新的线程。这意味着所有需要终结的对象都必须排队等待这一个线程来执行。如果某个终结器执行时间过长,比如在终结器中进行了网络调用、文件写入或复杂的同步操作,后面的对象终结就会被严重拖延。
更严重的是,Finalizer 线程的中断或异常会直接影响后续对象的清理。虽然 CLR 会尽量保证终结器线程不会因为单个终结器的异常而崩溃,但如果终结器内部抛出未捕获的异常,该异常会被 CLR 吞掉,终结过程继续。然而,如果终结器内部发生死锁或者调用了一些导致线程挂起的 API,整个终结流程就会停滞,内存回收被完全阻塞。
从 GC 的角度看,终结器的存在会额外增加一轮回收的开销。普通对象在一次 GC 中就能完成回收,而带终结器的对象至少需要两轮:第一轮标记不可达并放入终结队列,第二轮等待终结器执行完毕后才能真正释放。如果对象数量很多,GC 的频率和停顿时间都会显著增加。
为了减轻这种负担,微软在 .NET 中提供了 IDisposable 接口和 Dispose 模式。通过显式调用 Dispose 方法,可以提前释放非托管资源,并在方法内部调用 GC.SuppressFinalize(this) 阻止终结器执行。这样对象就不必进入终结队列,回收路径恢复到普通对象的状态,大大提高性能。
如何正确使用终结器与 IDisposable 模式
终结器只应该作为非托管资源的兜底清理方案,而不应作为主要清理手段。任何封装了非托管资源的类,都应该实现 IDisposable 接口,并在 Dispose 方法中完成资源释放。同时,为了应对调用者忘记调用 Dispose 的情况,类还可以提供终结器作为后备,但终结器必须调用与 Dispose 相同的清理逻辑。
一个标准的实现模式如下:在类中定义一个 protected virtual void Dispose(bool disposing) 方法,根据参数判断当前是用户主动释放还是终结器调用。当 disposing 为 true 时,说明是用户在调用 Dispose,此时可以释放托管资源和非托管资源;当 disposing 为 false 时,说明终结器正在执行,此时只能释放非托管资源,因为托管资源可能已经被 GC 回收或处于不确定状态。
public class SafeResourceHolder : IDisposable
{
private bool disposed = false;
private IntPtr nativeHandle;
public SafeResourceHolder()
{
nativeHandle = AllocateNativeMemory(2048);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposed)
return;
if (disposing)
{
// 释放托管资源
}
if (nativeHandle != IntPtr.Zero)
{
ReleaseNativeMemory(nativeHandle);
nativeHandle = IntPtr.Zero;
}
disposed = true;
}
~SafeResourceHolder()
{
Dispose(false);
}
private static IntPtr AllocateNativeMemory(int size)
{
return IntPtr.Zero;
}
private static void ReleaseNativeMemory(IntPtr handle)
{
}
}
上面的代码通过 GC.SuppressFinalize(this) 告诉 GC 该对象不再需要终结,从而避免进入终结队列。这样即使调用者忘记调用 Dispose,终结器也会作为最后防线释放非托管资源,但不会影响正常情况下的回收效率。
总的来说,Finalizer 线程是 GC 回收流程中的一个关键角色,它负责执行那些带终结器对象的清理代码,但它的存在本身就会增加内存回收的复杂性。理解终结队列、freachable 队列以及 Finalizer 线程的协作机制,能够帮助开发者避免常见的内存泄漏和性能陷阱,写出更健壮的 C# 程序。

