目 录CONTENT

文章目录

CSharp(六十) 垃圾回收机制(GC)详解

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 采用三代模型高效回收(新对象频繁扫,老对象偶尔扫),核心优化原则是减少临时对象分配。

0
博主关闭了当前页面的评论