C# 垃圾回收机制(GC)详解
一、什么是垃圾回收?
1.1 生活比喻
垃圾回收(Garbage Collection,简称 GC)就像是办公室里的保洁阿姨:
你上班时用过的草稿纸、喝完的咖啡杯、废弃的文件——不需要自己收拾。保洁阿姨每隔一段时间过来巡视,把确定没人用的东西清理掉,腾出空间。
在 C/C++ 里,你要自己 malloc / free(相当于自己倒垃圾)。忘记 free → 内存泄漏(垃圾堆满办公室)。提前 free → 踩到别人还在用的东西(程序崩溃)。
C# 有 GC —— 你只管用,保洁阿姨帮你收。 你创建对象用 new,不用操心什么时候释放,GC 会自动判断并回收。
1.2 一句话理解
GC = .NET 运行时自带的内存自动管理机制。它自动跟踪哪些对象还在用、哪些已经没用了,然后释放没用的对象所占的内存。
1.3 C/C++ vs C# 对比
// C/C++ —— 手动管理内存
int* arr = (int*)malloc(100 * sizeof(int)); // 申请内存
// ... 使用 ...
free(arr); // 必须手动释放!忘了就内存泄漏
arr = NULL;
// 如果提前 free 了,后面再用 arr → 💥 野指针崩溃
// C# —— GC 自动管理
int[] arr = new int[100]; // 申请内存
// ... 使用 ...
// 不需要 free!GC 会在合适的时候自动回收
arr = null; // 置空后,GC 下次运行时就会回收这 100 个 int 的内存
二、GC 是怎么工作的?—— 三代回收模型
2.1 核心思想:大部分对象都是"短命鬼"
统计发现:程序中 90% 以上的对象在创建后很快就不用了。只有少数对象会存活很久。
基于这个发现,GC 把托管堆分成了三代:
┌────────────────────────────────┐
│ 第 2 代(Gen2) │ ← 活最久的
│ ┌────────────────────────┐ │
│ │ 第 1 代(Gen1) │ │ ← 中间
│ │ ┌──────────────────┐ │ │
│ │ │ 第 0 代(Gen0) │ │ │ ← 新来的
│ │ │ 新创建的对象 │ │ │
│ │ └──────────────────┘ │ │
│ └────────────────────────┘ │
└────────────────────────────────┘
2.2 三代回收的工作原理
// 模拟 GC 的工作过程
void CreateObjects()
{
// 这些对象创建时,都放在 Gen0
string temp1 = "临时字符串";
int[] tempArr = new int[100];
Student tempStudent = new Student();
// 方法结束后,temp1、tempArr、tempStudent 不再被引用
// GC 触发 Gen0 回收:
// → temp1、tempArr、tempStudent 没人用 → 回收!
// → 如果有什么对象仍然被引用 → 升级到 Gen1
}
三代模型的规则:
| 代 | 特点 | 回收频率 | 回收开销 |
|---|---|---|---|
| Gen0 | 新创建的对象 | 最频繁 | 最小(对象少) |
| Gen1 | 从 Gen0 活下来的 | 较少 | 中等 |
| Gen2 | 从 Gen1 活下来的(长期存活) | 很少 | 最大(扫描整个 Gen2) |
升级过程图解:
1. 新对象创建 → 进 Gen0
2. Gen0 满了 → 触发 Gen0 回收
├─ 没人引用的 → 回收掉 ✅
└─ 有人引用的 → 升级到 Gen1 ⬆️
3. Gen1 也满了 → 触发 Gen1 回收(同时回收 Gen0)
├─ 没人引用的 → 回收掉 ✅
└─ 有人引用的 → 升级到 Gen2 ⬆️
4. Gen2 满了 → 触发 Gen2 回收(Full GC,回收全三代,最慢!)
2.3 查看 GC 信息
using System;
class Program
{
static void Main()
{
Console.WriteLine($"Gen0 回收次数: {GC.CollectionCount(0)}");
Console.WriteLine($"Gen1 回收次数: {GC.CollectionCount(1)}");
Console.WriteLine($"Gen2 回收次数: {GC.CollectionCount(2)}");
// 总内存
Console.WriteLine($"当前托管内存: {GC.GetTotalMemory(false) / 1024} KB");
// 触发一次 Gen0 回收
GC.Collect(0);
Console.WriteLine($"\nGen0 回收后次数: {GC.CollectionCount(0)}");
Console.WriteLine($"回收后托管内存: {GC.GetTotalMemory(false) / 1024} KB");
}
}
三、手动控制 GC
3.1 GC.Collect —— 手动触发垃圾回收
// ⚠️ 一般不需要手动调用!GC 会自动选择最佳时机。
// 强制回收所有代
GC.Collect(); // 等同于 GC.Collect(GC.MaxGeneration)
// 只回收指定代
GC.Collect(0); // 只回收 Gen0
GC.Collect(1); // 回收 Gen0 + Gen1
GC.Collect(2); // Full GC(回收全部,最慢)
// 回收后立即等待所有终结器执行完毕
GC.Collect();
GC.WaitForPendingFinalizers(); // 等待析构函数执行完
什么时候需要手动调用?
- 刚刚释放了大量内存(如关闭了一个占用大量内存的窗口)
- 在性能不敏感的时间点(如加载完成后)预清理
- 日常业务代码中几乎不需要!
3.2 GC.SuppressFinalize —— 跳过终结器
public class MyResource : IDisposable
{
public void Dispose()
{
// 清理托管资源
ReleaseResources();
// 告诉 GC:这个对象我已经手动清理过了,
// 回收时不要再调用它的析构函数了(省性能)
GC.SuppressFinalize(this);
}
~MyResource()
{
// 析构函数——如果用户忘了 Dispose,GC 最后兜底调用
ReleaseResources();
}
private void ReleaseResources()
{
// 释放非托管资源(文件句柄、网络连接等)
}
}
3.3 GC 常用方法速查
| 方法 | 作用 | 备注 |
|---|---|---|
GC.Collect() |
强制引发 GC | 一般不手动调用 |
GC.Collect(generation) |
回收指定代 | 0=Gen0, 1=Gen0+1, 2=Full GC |
GC.WaitForPendingFinalizers() |
等待终结器执行 | 和 Collect 配合使用 |
GC.SuppressFinalize(obj) |
跳过终结器 | Dispose 模式中调用 |
GC.GetTotalMemory(force) |
获取托管内存大小 | true 表示先 GC 一次再返回 |
GC.CollectionCount(gen) |
获取指定代回收次数 | 用于性能监控 |
GC.GetGeneration(obj) |
获取对象在第几代 | 调试用 |
GC.MaxGeneration |
最大代数(通常是 2) | 判断是否 Full GC |
四、析构函数(Finalizer)—— 最后的保险
4.1 什么是析构函数?
public class FileWriter
{
private IntPtr _fileHandle; // 非托管资源(文件句柄)
public FileWriter(string path)
{
_fileHandle = NativeMethods.OpenFile(path);
}
// 析构函数(Finalizer)
// GC 回收这个对象时,会自动调用
~FileWriter()
{
NativeMethods.CloseFile(_fileHandle);
Console.WriteLine("析构函数被调用——文件句柄已释放");
}
}
析构函数的特点:
- 不能手动调用,GC 回收时自动调用
- 不能有参数,不能有访问修饰符
- 调用时机不确定——GC 什么时候跑,析构函数就什么时候跑
- 有析构函数的对象回收更慢(要进终结队列,多一步)
4.2 析构函数的问题——不确定性
static void Main()
{
CreateAndForget();
// 方法结束了,writer 应该被回收
// 但析构函数可能 1 秒后执行,也可能 1 分钟后执行——不确定!
// 在这期间文件可能还被锁着!
Console.WriteLine("等待...");
Thread.Sleep(5000);
}
static void CreateAndForget()
{
FileWriter writer = new FileWriter("data.txt");
// 忘记关闭 writer
}
问题:析构函数调用时机不确定,如果依赖它来释放资源,资源可能被占用很久。
五、IDisposable 模式 —— 正确的资源管理
5.1 为什么需要 IDisposable?
析构函数的调用时机不确定。对于文件、数据库连接、网络套接字等需要及时释放的资源,我们希望在"用完就关",而不是"等 GC 心情好了再关"。
IDisposable + using = 确定性资源释放。
5.2 标准 Dispose 模式
public class FileManager : IDisposable
{
private FileStream _fileStream;
private bool _disposed = false; // 防止重复释放
public FileManager(string path)
{
_fileStream = new FileStream(path, FileMode.OpenOrCreate);
}
// ===== IDisposable 接口 =====
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // 告诉 GC 不用调析构函数了
}
// 核心清理逻辑
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// 清理托管资源(其他 .NET 对象)
_fileStream?.Dispose();
}
// 清理非托管资源(文件句柄、GDI 对象等)
// ...
_disposed = true;
}
// 析构函数——最后的兜底
~FileManager()
{
Dispose(false); // 只清理非托管资源
}
// 使用前检查是否已释放
public void WriteData(string data)
{
if (_disposed)
throw new ObjectDisposedException(nameof(FileManager));
byte[] bytes = Encoding.UTF8.GetBytes(data);
_fileStream.Write(bytes, 0, bytes.Length);
}
}
// ===== 使用 =====
// 方式一:using 语句(推荐!自动调用 Dispose)
using (var fm = new FileManager("data.txt"))
{
fm.WriteData("Hello World!");
} // ← 离开 using 时自动调用 fm.Dispose()
// 方式二:手动 try-finally
var fm = new FileManager("data.txt");
try
{
fm.WriteData("Hello World!");
}
finally
{
fm?.Dispose(); // 无论如何都要释放
}
5.3 using 的本质
// 你写的:
using (var resource = new MyResource())
{
resource.DoSomething();
}
// 编译器翻译成:
var resource = new MyResource();
try
{
resource.DoSomething();
}
finally
{
if (resource != null)
((IDisposable)resource).Dispose();
}
5.4 using 的简写(C# 8.0+)
// 传统写法
using (var reader = new StreamReader("data.txt"))
{
string content = reader.ReadToEnd();
}
// C# 8.0 简写——using 声明
using var reader = new StreamReader("data.txt");
string content = reader.ReadToEnd();
// reader 在变量离开作用域时自动 Dispose
六、弱引用(WeakReference)—— 特殊的引用
6.1 什么是弱引用?
普通引用(强引用):只要我拿着,GC 就不回收。
弱引用:我虽然有这个对象的引用,但如果内存不够,GC 可以回收它。
// 强引用——GC 不会回收
Student strongRef = new Student { Name = "张三" };
// 只要 strongRef 还指向它,GC 就绕着走
// 弱引用——GC 可以回收
WeakReference<Student> weakRef = new WeakReference<Student>(
new Student { Name = "李四" });
// 使用前要检查
if (weakRef.TryGetTarget(out Student student))
{
Console.WriteLine($"还在: {student.Name}");
}
else
{
Console.WriteLine("已经被 GC 回收了");
}
适用场景:缓存系统——能留着就留着,但内存紧张时可以被回收。
七、GC 的性能影响与优化
7.1 什么操作会触发 GC?
// 1. Gen0 满了——自动触发(最常见)
for (int i = 0; i < 100000; i++)
{
var temp = new byte[1024]; // 大量临时对象 → Gen0 很快满 → GC 频繁
}
// 2. 手动调用
GC.Collect();
// 3. 系统内存不足
// 操作系统告诉 .NET 内存紧张 → GC 开始积极回收
7.2 减少 GC 压力的最佳实践
// ===== ❌ 不好的做法:频繁创建临时对象 =====
string result = "";
for (int i = 0; i < 10000; i++)
{
result += i + ", "; // 每次 += 都创建新的 string 对象!大量 GC 压力
}
// ===== ✅ 好的做法:用 StringBuilder =====
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
sb.Append(i);
sb.Append(", ");
}
string result = sb.ToString();
// ===== ❌ 不好:装箱产生垃圾 =====
ArrayList list = new ArrayList(); // 已过时!
for (int i = 0; i < 10000; i++)
{
list.Add(i); // 每次都装箱!产生 10000 个临时 object
}
// ===== ✅ 好:泛型不装箱 =====
List<int> list = new List<int>();
for (int i = 0; i < 10000; i++)
{
list.Add(i); // 无装箱
}
// ===== ❌ 不好:在循环中拼接大字符串 =====
void GenerateReport()
{
string html = "<html><body>";
foreach (var item in GetItems()) // 假设有 10000 条
{
html += $"<div>{item}</div>"; // 每次都生成新 string!
}
html += "</body></html>";
}
// ===== ✅ 好:用 StringBuilder =====
void GenerateReport()
{
StringBuilder sb = new StringBuilder();
sb.Append("<html><body>");
foreach (var item in GetItems())
{
sb.Append("<div>").Append(item).Append("</div>");
}
sb.Append("</body></html>");
string html = sb.ToString();
}
7.3 对象池——复用而非新建
// 对于频繁创建/销毁的大对象,用对象池复用
// StringBuilder 就是一个自然的对象池用法
// 如果真要自己写对象池:
public class ObjectPool<T> where T : new()
{
private Stack<T> _pool = new Stack<T>();
public T Get()
{
return _pool.Count > 0 ? _pool.Pop() : new T();
}
public void Return(T item)
{
_pool.Push(item);
}
}
7.4 大对象堆(LOH)
// 大于 85000 字节(约 83KB)的对象不在常规堆上
// 它们直接进入大对象堆(Large Object Heap)
// ❌ 避免频繁创建大对象
byte[] bigArray = new byte[100000]; // > 85KB,进 LOH
// LOH 不会自动压缩(类似碎片整理),可能造成内存碎片
// ✅ 大对象考虑复用
// ✅ 多个小对象比一个超大对象好(如果业务允许)
八、完整的实战示例——内存监控工具
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
Console.WriteLine("===== GC 内存监控 =====\n");
// 显示初始状态
ShowGCStatus("初始状态");
// 测试1:创建大量临时对象
Console.WriteLine("\n--- 创建临时对象 ---");
for (int i = 0; i < 100000; i++)
{
var temp = new { Id = i, Name = $"Name{i}", Value = i * 1.5 };
}
ShowGCStatus("创建 10 万临时对象后");
// 测试2:强制 GC
Console.WriteLine("\n--- 强制 GC ---");
GC.Collect();
GC.WaitForPendingFinalizers();
ShowGCStatus("强制 GC 后");
// 测试3:展示 using 的正确用法
Console.WriteLine("\n--- using 演示 ---");
using (var resource = new ManagedResource("测试资源"))
{
resource.DoSomething();
} // 自动 Dispose
Console.WriteLine("资源已释放(using 自动调用 Dispose)");
}
static void ShowGCStatus(string label)
{
Console.WriteLine($"\n[{label}]");
Console.WriteLine($" 托管内存: {GC.GetTotalMemory(false) / 1024.0:F1} KB");
Console.WriteLine($" Gen0 回收: {GC.CollectionCount(0)} 次");
Console.WriteLine($" Gen1 回收: {GC.CollectionCount(1)} 次");
Console.WriteLine($" Gen2 回收: {GC.CollectionCount(2)} 次");
}
}
public class ManagedResource : IDisposable
{
private string _name;
private bool _disposed = false;
public ManagedResource(string name)
{
_name = name;
Console.WriteLine($" 创建资源: {_name}");
}
public void DoSomething()
{
if (_disposed)
throw new ObjectDisposedException(_name);
Console.WriteLine($" 使用资源: {_name}");
}
public void Dispose()
{
if (!_disposed)
{
Console.WriteLine($" 释放资源: {_name}");
_disposed = true;
GC.SuppressFinalize(this);
}
}
~ManagedResource()
{
Console.WriteLine($" ⚠️ 析构函数调用: {_name}(用户忘了 Dispose!)");
Dispose();
}
}
输出示例:
===== GC 内存监控 =====
[初始状态]
托管内存: 45.2 KB
Gen0 回收: 0 次
Gen1 回收: 0 次
Gen2 回收: 0 次
--- 创建临时对象 ---
[创建 10 万临时对象后]
托管内存: 8234.1 KB
Gen0 回收: 3 次
Gen1 回收: 0 次
Gen2 回收: 0 次
--- 强制 GC ---
[强制 GC 后]
托管内存: 128.5 KB
Gen0 回收: 4 次
Gen1 回收: 1 次
Gen2 回收: 1 次
--- using 演示 ---
创建资源: 测试资源
使用资源: 测试资源
释放资源: 测试资源
资源已释放(using 自动调用 Dispose)
九、常见易错点(避坑指南)
坑1:忘写 using 导致资源泄漏
// ❌ 文件一直没有被关闭——直到 GC 回收才关
var reader = new StreamReader("data.txt");
string content = reader.ReadToEnd();
// 忘了 reader.Dispose()!文件可能被锁很久
// ✅ 用 using
using (var reader = new StreamReader("data.txt"))
{
string content = reader.ReadToEnd();
} // 自动关闭
坑2:析构函数里访问其他托管对象
// ❌ 析构函数里不能访问其他托管对象!
class MyClass
{
private StreamWriter _writer;
~MyClass()
{
_writer.Flush(); // ⚠️ _writer 可能已经被 GC 回收了!
}
}
// ✅ 用 IDisposable 模式
class MyClass : IDisposable
{
private StreamWriter _writer;
public void Dispose()
{
_writer?.Flush();
_writer?.Dispose();
GC.SuppressFinalize(this);
}
}
坑3:手动 GC.Collect 过于频繁
// ❌ 不要每循环一次就 GC
for (int i = 0; i < 1000; i++)
{
DoWork();
GC.Collect(); // 严重拖慢性能!
}
// ✅ 让 GC 自己决定
for (int i = 0; i < 1000; i++)
{
DoWork();
}
坑4:大对象频繁创建/销毁
// ❌ 循环里频繁创建大数组
for (int i = 0; i < 100; i++)
{
byte[] buffer = new byte[100000]; // LOH 分配,容易碎片
ProcessData(buffer);
}
// ✅ 复用同一个缓冲区
byte[] buffer = new byte[100000];
for (int i = 0; i < 100; i++)
{
Array.Clear(buffer, 0, buffer.Length);
ProcessData(buffer);
}
坑5:以为置 null 就能立即回收
// ❌ 误区:以为 obj = null 就能立刻回收
var obj = new MyClass();
obj = null; // 只是解除了引用,GC 不会立刻回收!
// GC 什么时候跑,取决于内存压力和 GC 自己的算法
// ✅ 正确的理解:obj = null 只是让它变成了"可回收",
// 实际回收时间由 GC 决定
坑6:在终结器中抛出异常
// ❌ 终结器中的异常会直接终止进程!
~MyClass()
{
try
{
Cleanup();
}
catch
{
// 必须捕获所有异常,不能让它泄露出去
}
}
十、总结
GC 核心概念速查
| 概念 | 说明 |
|---|---|
| GC 是什么 | .NET 自动内存管理,跟踪对象引用,回收不再使用的内存 |
| 三代模型 | Gen0(新对象,频繁回收)、Gen1(过渡)、Gen2(长期存活,回收慢) |
| 触发时机 | Gen0 满了 / 系统内存不足 / 手动 GC.Collect() |
| IDisposable | 确定性资源释放接口,配合 using 使用 |
| 析构函数 | GC 回收前的最后兜底,不确定何时调用 |
| using | using (var x = ...) { } 离开时自动调 Dispose |
最佳实践速查
✅ 用 using 包裹实现了 IDisposable 的对象
✅ 频繁拼接字符串用 StringBuilder
✅ 用 List<T> 代替 ArrayList(避免装箱)
✅ 大对象尽量复用,避免频繁创建 >85KB 的对象
✅ 实现 IDisposable 模式,不要单靠析构函数
❌ 不要手动频繁调用 GC.Collect()
❌ 不要在析构函数中访问其他托管对象
❌ 不要让终结器抛出异常
❌ 不要以为 obj = null 就会立刻释放内存
记忆口诀
GC 就像保洁员,自动回收省心力
三代模型分对象,新来短命常清理
托管资源 using 管,用完就关不占坑
非托管用析构兜,Dispose 才是正道走
字符串拼接 StringBuilder,泛型避免装箱累
大对象要复用快,别总 GC.Collect 手动来
一句话总结:C# 的 GC 自动管理内存(堆上的对象),你不需要手动释放。对于文件、网络连接等需要"用完就关"的资源,实现
IDisposable接口并用using语句包裹。GC 采用三代模型高效回收(新对象频繁扫,老对象偶尔扫),核心优化原则是减少临时对象分配。