第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服务或控制台应用,记得在.csproj或app.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 List或ConcurrentDictionary,只加不减。 - 匿名方法/闭包捕获变量:Lambda表达式捕获了外部变量,导致变量生命周期被延长。
- 未释放非托管资源:忘了调用
Dispose(),或者没正确实现IDisposable。 - WPF/UWP中的绑定泄漏:数据绑定没解除,导致View和ViewModel互相引用。
怎么诊断?我一般用这几招:
- 性能计数器:看
.NET CLR Memory下的# Bytes in all Heaps,如果持续增长不下降,大概率泄漏。 - Windbg + SOS:用
!dumpheap -stat看哪些类型占内存最多,用!gcroot找引用链。 - PerfView:微软的免费工具,可以抓GC分配和存活对象的快照。
- 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是个大话题,但核心就这几件事:理解代际、选对模式、避免泄漏、减少分配。下一章咱们聊聊线程和异步编程中的性能陷阱。