第3章 内存管理深度剖析:垃圾回收(GC)机制、GC代际与模式、内存泄漏诊断

聊到.NET性能,内存管理绝对是绕不开的核心话题。我见过太多项目,代码写得挺漂亮,一上生产就频繁GC,CPU飙高,响应时间直线上升。说白了,很多性能问题根子就在内存上。

这一章,咱们就深入看看GC到底在干什么,以及怎么跟它和平共处。

3.1 GC的基本工作原理

GC的全称是Garbage Collector,垃圾回收器。它的职责很简单:自动回收不再使用的对象所占用的内存。

你想想看,在C/C++时代,开发者得手动malloc和free。忘了free?内存泄漏。多free了?程序崩溃。.NET的GC帮我们扛下了这个苦活。

GC是怎么知道哪些对象“不再使用”的呢?它从应用程序的“根”(Roots)出发,沿着对象引用图遍历。能遍历到的,就是“可达”的,不能释放。遍历不到的,就是“垃圾”,可以回收。

这些“根”包括:

  • 静态字段引用的对象
  • 线程栈上的局部变量和参数
  • CPU寄存器中的对象引用
  • GC Handle(如GCHandle)

嗯,这里要注意:GC只回收托管堆上的对象。非托管资源(文件句柄、数据库连接、GDI对象)它不管,你得自己通过IDisposable模式来释放。

核心结论:GC是“引用追踪”式的回收器,不是“引用计数”。它不关心对象被引用了多少次,只关心从根出发能不能到达。

3.2 GC的代际(Generations)机制

为什么GC要分代?我刚开始学的时候也觉得多此一举。后来在项目中调优才发现,这设计太聪明了。

研究表明:大多数对象,生命周期都很短。比如你在一个方法里new了一个临时List,方法执行完它就没人用了。只有少数对象会长期存活,比如缓存、单例、静态数据。

基于这个观察,GC把托管堆分成三代:

代际 特点 回收频率 典型对象
第0代 最新分配的对象,体积小 最频繁 局部变量、临时对象
第1代 从第0代幸存下来的对象 中等 中等生命周期对象
第2代 长期存活的对象,体积大 最少 缓存、静态数据、大型集合

GC的回收策略是:先回收第0代。如果第0代回收后内存还不够,再回收第1代。以此类推。这叫“代际回收”。

为什么会这样设计?因为回收第0代最快。第0代通常很小,而且大部分对象都是垃圾,回收效率极高。第2代回收一次,代价很大,所以尽量少触发。

个人经验:我在项目中遇到过,一个缓存服务频繁触发第2代GC,导致CPU每隔几分钟就飙到90%。后来发现是缓存对象没有设置过期时间,对象一直存活,堆越来越大。加了过期策略后,第2代GC次数直接降了80%。

3.3 GC的工作模式

.NET提供了几种GC模式,适用于不同的场景。选错了模式,性能可能直接崩掉。

3.4.1 工作站模式 vs 服务器模式

模式 适用场景 特点
工作站(Workstation) 客户端应用、单核机器 每个触发GC的线程自己回收,暂停时间短
服务器(Server) 服务端应用、多核机器 每个CPU核心一个专用GC线程,并行回收,吞吐量高

我建议,如果你的应用跑在多核服务器上(现在基本都是),一定要用服务器模式。默认情况下,ASP.NET Core应用就是服务器模式。但如果你在写Windows服务或控制台应用,记得在.csprojapp.config里显式配置。

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

3.4.2 并发模式 vs 非并发模式

并发模式(Background GC)允许GC在回收时,应用程序线程继续运行。非并发模式(Blocking GC)会暂停所有线程。

从.NET 4.5开始,默认就是后台GC(Background GC)。它把回收工作分成两部分:

  • 前台GC(Foreground GC):回收第0代和第1代,会短暂暂停线程。
  • 后台GC(Background GC):回收第2代,与应用程序线程并发执行。

嗯,这里要注意:后台GC虽然并发,但也不是完全无暂停。在回收过程中,它需要短暂“安全点”来同步状态。不过这个暂停时间通常很短,毫秒级。

避坑指南:我曾经在一个实时交易系统里,为了追求极低延迟,把GC模式改成了非并发。结果第2代GC一触发,所有线程暂停了200多毫秒,交易直接超时。后来老老实实改回后台GC,虽然吞吐量略降,但延迟稳定多了。

3.4 内存泄漏的常见原因与诊断

很多人以为.NET有GC就不会内存泄漏。大错特错。GC只管托管堆,管不了你代码里的“逻辑泄漏”。

常见的泄漏原因:

  • 事件订阅未取消:订阅了事件,对象销毁时没取消订阅,导致被事件源长期引用。
  • 静态集合无限增长:把对象塞进static ListConcurrentDictionary,只加不减。
  • 匿名方法/闭包捕获变量:Lambda表达式捕获了外部变量,导致变量生命周期被延长。
  • 未释放非托管资源:忘了调用Dispose(),或者没正确实现IDisposable
  • WPF/UWP中的绑定泄漏:数据绑定没解除,导致View和ViewModel互相引用。

怎么诊断?我一般用这几招:

  1. 性能计数器:.NET CLR Memory下的# Bytes in all Heaps,如果持续增长不下降,大概率泄漏。
  2. Windbg + SOS:!dumpheap -stat看哪些类型占内存最多,用!gcroot找引用链。
  3. PerfView:微软的免费工具,可以抓GC分配和存活对象的快照。
  4. JetBrains dotMemory / RedGate ANTS:商业工具,界面友好,适合快速定位。

实战案例:有一次线上服务内存持续增长,我用dotMemory抓了快照,发现一个ConcurrentDictionary<string, Socket>里有几万个Socket对象。查代码发现,客户端断开连接后,只关了Socket,没从字典里移除。这就是典型的“静态集合泄漏”。

3.5 如何写出“GC友好”的代码

说白了,GC不是敌人,是我们的工具。但滥用它,性能就会变差。我总结了几条原则:

  • 减少分配:能复用对象就复用,别频繁new。比如用ArrayPool<T>代替临时数组。
  • 避免大对象堆(LOH)碎片:大于85KB的对象进LOH,LOH不压缩,容易碎片化。尽量拆分大对象,或者用GCSettings.LargeObjectHeapCompactionMode
  • 弱引用(WeakReference):缓存场景用弱引用,允许GC在内存紧张时回收。
  • 及时释放非托管资源:using语句,或者实现IDisposable模式。
  • 避免终结器(Finalizer):终结器会让对象多活一次GC,延迟回收。能用SafeHandle就别自己写终结器。
// 不好的写法:频繁分配临时数组
byte[] buffer = new byte[1024];
// 处理...
// 下次又 new 一个

// 好的写法:复用数组
byte[] buffer = ArrayPool<byte>.Shared.Rent(1024);
try
{
    // 处理...
}
finally
{
    ArrayPool<byte>.Shared.Return(buffer);
}

我的习惯:在写高性能代码时,我会用BenchmarkDotNet来测量GC分配量。如果发现某个方法每次调用都分配几百KB,那肯定要优化。目标是把GC分配压到最低,甚至零分配。

好了,这一章的内容就到这里。GC是个大话题,但核心就这几件事:理解代际、选对模式、避免泄漏、减少分配。下一章咱们聊聊线程和异步编程中的性能陷阱。