垃圾回收与 IDisposable 模式
本教程共 100 篇 · 第 93 篇 · 更新于 2026-07-31 · 约 9 分钟阅读
93. 垃圾回收与 IDisposable 模式
本节目标:学完能说清托管内存为何不用手动释放,以及如何用 IDisposable 和 using 释放文件、连接等非托管资源。
很多新手从 C/C++ 转来,总担心「内存会不会漏」。在 C# 里,普通对象的内存由垃圾回收器(Garbage Collector,GC)托管,你基本不用管。但有一类资源例外,必须手动释放。
一、托管资源 vs 非托管资源
托管资源(managed resource)是指 .NET 自己分配、自己能收回的对象,比如普通的 List<string>、Person 实例。它们的内存由 GC 负责。
非托管资源(unmanaged resource)是指绕过 .NET 的「操作系统级把手」:打开的文件、数据库连接、网络套接字、图形句柄等。GC 看不见它们,不会替你关。不关掉,句柄就一直占着,最终耗尽系统资源。
Note关键区别:GC 能回收「内存」,但回收不了「操作系统句柄」。凡是实现了
IDisposable的类型,几乎都握着某种非托管资源,用完就该Dispose。
二、GC 是怎么工作的
GC 在后台自动运行,当内存压力到一定程度才触发。它分代(0/1/2 代)回收:新对象在 0 代,活得久的升到 1、2 代。绝大多数对象朝生夕死,所以 0 代回收最频繁、最快。
重点:GC 何时回收是不确定的。你不能假设「方法一结束对象就被销毁」。这也是为什么不能依赖「等 GC 来收」去关文件——那可能要等很久。
Tip如果你想主动帮 GC 一把,可以调
GC.Collect(),但日常几乎不需要。GC 比你想的更会算账,手动干预反而可能打乱它的节奏,让性能更差。
Note分代回收的核心假设是「大多数对象活不过第一轮」。0 代回收又快又频繁,1、2 代很少触发但代价更大。所以 GC 倾向于先扫「最可能有垃圾」的新生代,整体效率远高于「每次全扫一遍」。理解这点,你就明白为什么不该手动
GC.Collect()去打断它的节奏——那往往让本该慢慢清的东西提前被拖进重量级回收。
三、IDisposable 与 Dispose
凡是需要释放资源的类型都实现 IDisposable 接口,它只有一个方法 Dispose()。你要做的是:用完就调用它。
namespace CSharpDemo;
class ResourceHolder : IDisposable
{
private bool _disposed;
public void DoWork() => Console.WriteLine("正在使用资源...");
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Console.WriteLine("已释放非托管资源");
}
}
using var r = new ResourceHolder();
r.DoWork();
// 离开作用域时自动 Dispose,无需手写
当你自己写的类内部持有别人的 IDisposable 对象(比如封装了一个 Stream 或数据库连接),它自己也应该实现 IDisposable,并在 Dispose 里把内部的资源一并释放。这样「释放」会像多米诺骨牌一样层层传递:调用方只要 using 最外层,里层资源也全部被照顾到。这是组合复用时的标准做法,能避免「内层句柄忘了关」这种隐蔽泄漏。
四、using 声明:最省心的写法
手写 Dispose 容易忘,而且出异常时还可能跳过。C# 8 引入的 using 声明(using declaration)最稳妥:变量离开作用域时自动 Dispose,哪怕中途抛异常。
namespace CSharpDemo;
using var reader = new StreamReader("data.txt");
string? firstLine = reader.ReadLine();
Console.WriteLine(firstLine);
// 到这里 reader 自动 Dispose,文件句柄被释放
Tip也可以用经典的大括号
using语句块。两者等价,C# 14 首选 using 声明,代码更平、缩进更浅。读文件、开数据库连、建 HttpClient handler 时都该这么写。
Tip判断一个对象要不要
using,最快的办法是看它名字或文档里有没有「Stream / Connection / Reader / Writer / Handle」这类字眼,或者类型是否实现了IDisposable。拿不准时,把它当作需要释放的资源处理,总比漏放安全。养成「见到 IDisposable 就 using」的条件反射,能挡掉大部分资源泄漏。
五、标准 Dispose 模式(简述)
当你自己写的类直接握有非托管句柄(比如用 IntPtr 调系统 API),才需要完整的 Dispose 模式:实现 IDisposable,并在真正握有非托管资源时配一个终结器(finalizer,写法 ~ClassName())作兜底。
namespace CSharpDemo;
class NativeWrapper : IDisposable
{
private IntPtr _handle; // 非托管句柄
private bool _disposed;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // 已手动释放,无需终结器再跑
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
// 这里释放 _handle 指向的非托管资源
_disposed = true;
}
~NativeWrapper() => Dispose(false); // 兜底:忘了 Dispose 时 GC 调用
}
using var w = new NativeWrapper(); // 正常路径自动 Dispose
Note对绝大多数应用代码,你并不需要写终结器。只有「类内部直接持有非托管指针」时才用。日常用别人封装好的
FileStream、SqlConnection等,只要using就够了。
Note终结器的代价很高:被它标记的对象,GC 不能在第一轮直接回收,得先排队跑一遍终结器,下一轮才真正释放,等于多活一代、还拖慢整体回收节奏。所以只在「直接握有非托管句柄、且忘了 Dispose 就得兜底」时才写终结器,千万别拿它当普通清理工具,否则反而拖累性能。
六、新手踩坑
- 打开文件后忘了
Dispose/using,文件被长时间占用,别的代码删不掉也改不了。 - 以为「等 GC 回收就会自动关资源」。GC 只管内存,且时机不定;非托管资源必须主动释放。
- 在
using块外还想用那个对象,结果对象已被释放,抛ObjectDisposedException。 - 滥用析构函数(终结器)做清理。终结器运行昂贵、时机不可控,普通托管对象根本不需要它。
Warning千万不要在
using声明的变量还没离开作用域时,就把它存进一个外部集合或返回出去。等作用域结束,它会被自动释放,你手里那个引用就成了「已释放」的空壳,下次用会抛ObjectDisposedException。需要长期持有,就别用 using 声明,而是自己掌控释放时机。
七、为什么这样设计
为什么 C# 不像 C++ 那样让你手动 delete 一切?因为手动管理内存是 bug 大户:忘了释放会泄漏,释放早了会变野指针,重复释放会崩溃。GC 把「内存回收」这件事自动化,开发者只管创建对象,回收交给运行时,极大降低了出错概率。
但 GC 有个天生短板:它只理解「托管内存」,不理解「操作系统句柄」。一个文件句柄在 GC 眼里只是一小块内存,它不知道背后还占着磁盘资源。于是 C# 用 IDisposable 这个约定来补位:凡是握有外部资源的类型,都实现 Dispose,由你在代码里精确决定「什么时候释放」。using 声明再把这件事变成「离开作用域自动做」,把人为遗忘的可能降到最低。
八、using 声明 vs using 语句块 vs 手写 Dispose
| 方式 | 写法 | 何时用 |
|---|---|---|
| using 声明 | using var x = ...; | C# 14 首选,作用域结束自动释放,代码最平 |
| using 语句块 | using (var x = ...) { ... } | 想严格限制资源存活范围在一对花括号内时 |
| 手写 Dispose | x.Dispose(); | 不推荐,易忘且异常时会跳过 |
一句话:优先 using var,把释放交给编译器。
九、本节小结
普通内存交给 GC,安心写业务。凡是带 IDisposable 的资源类型(文件、连接、流),养成「用完即 using」的肌肉记忆。完整的终结器兜底只留给直接操作非托管句柄的底层封装。下章我们动手操作文件和目录。