一次生产事故,服务内存一路飙高直到进程被系统杀掉,日志刚好停在某个批量数据处理的入口。排查到最后,问题出在一堆“看起来早就该被释放”的对象身上。当时好几个同事的第一反应都一样:C# 不是自带垃圾回收吗?为什么还会内存爆掉?
这是很多人对 .NET 垃圾回收机制(Garbage Collector,简称 GC)最大的误解。它是 .NET 运行时负责管理托管堆内存的核心组件,能干自动分配对象内存、自动回收无用对象这种脏活累活,但“自动”不等于“不用关心”。你不理解它的工作方式,就永远说不清一个问题:对象到底什么时候才被回收?为什么有些对象明明没用了却一直占着内存?为什么 GC 跑一轮,服务就卡一下?
这篇文章我把 GC 从内到外完整拆一遍:它解决什么问题、托管堆是怎么设计出来的、分代模型和根引用是怎么回事、终结器和弱引用该怎么用,以及线上内存问题怎么排查、调优怎么做。适合所有写 C# 的开发者,不管你是刚入门的新手,还是在服务端摸爬滚打多年的老手,只要写过长期运行的进程,里面提到的坑你早晚会踩到,提前看完能省很多半夜排查的事故时间。
1. 理解 GC 之前:托管堆与手动内存管理
1.1 没有 GC 的日子是怎么过来的
很多人没用过 C 系语言写大型项目,所以很难体会 GC 的价值。早期的 C/C++ 里,开发者自己负责内存分配和释放,流程大概是:malloc一段内存,用完再free,或者用new配delete。听起来简单,真实世界远没有这么干净。函数嵌套调用、异常提前返回、多个分支路径都可能导致某个地方忘了free,于是内存泄漏就开始积累了。反过来,如果释放早了,后续代码再访问这块内存就变成悬空指针,轻则数据错乱,重则直接崩溃。
我自己早年写过一段时间原生 C++,最怕的就是两条:一条是异常路径里忘记释放资源,另一条是对象被提前释放后面又有引用去访问。这两类问题靠肉眼 review 很难彻底避免,尤其是项目规模上去以后,对象的生命周期交叉嵌套,谁拥有谁、谁负责释放谁,光靠“约定”根本管不住。
于是很多项目开始套用智能指针、引用计数之类的技巧,但这本质上是把“手动管理”变成“半自动管理”,引用环、循环引用一样能让你头疼。所以后来看到 .NET 这种托管环境的时候,我的第一反应是:这个思路确实能救很多人的命。GC 把“内存什么时候回收”这件事从开发者的责任里摘了出去,运行时自己跟踪对象的存活状态,主动回收不可达对象,开发者只需要专注业务逻辑。
1.2 托管堆的设计思路
GC 管理的这块内存叫“托管堆”。它是进程启动时由运行时保留的一大段连续虚拟内存区域,和 C/C++ 里的堆不同,它不需要开发者手动申请和释放单个块。你在 C# 里写new SomeClass(),运行时会从托管堆里划分一块内存返回给你,这个过程叫“分配”。
分配的速度是很多人关心的重点。早期垃圾回收器名声不好,主要就是因为“分配慢、回收更慢”。但 .NET 的托管堆在分配上做了个非常聪明的设计:新对象默认分配在堆上叫做“第 0 代”的区域,这块区域实际上是一段连续空间,分配方式就是一个指针向后移动。也就是说,正常情况下创建一个新对象只需要把分配指针往后挪一挪,这个操作比 C/C++ 里很多时候还要快,因为它不用扫描空闲链表、不用处理碎片。代价嘛,就是这套“快速分配”建立在一个前提下:内存不够了怎么办?不够就触发 GC,把垃圾清掉,把空间腾出来,然后继续快速分配。
整个托管堆内部按“代”划分,新对象进 0 代,存活时间长的对象逐步晋升到 1 代、2 代。这个分代模型背后藏着一个非常重要的经验观察,下一章我会专门拆开讲。
2. GC 核心原理拆解:分代、根与标记压缩
2.1 分代模型为什么高效
分代模型的依据,是几十年来积累的一个经验统计:大多数对象生命周期极短。你写一个for循环,里面临时创建的字符串、中间对象,往往在下一轮迭代开始前就没人用了。真正能活过几次 GC、活到进程后期的对象,数量其实占比很小。
如果每次 GC 都把整个托管堆从里到外翻一遍,那代价就太大了。所以运行时给对象分了三个代:0 代存放最新创建的对象,1 代是 0 代里幸存下来的对象,2 代存放长期存活的对象。2 代里还包含了大对象堆(LOH),这块后面单独说。GC 执行时不是每次都全量回收,它优先回收 0 代,也就是“简称为第 0 代回收”。如果 0 代回收之后内存还是紧张,再把 1 代带上,逐步扩大范围。这就是为什么你在日志或者性能监视器里会看到GC 0、GC 1、GC 2的说法。
这个设计直接带来的好处是:绝大多数时候,GC 只需要扫描内存里最小、最年轻的那一块,停顿时间短,回收效率高。只有老对象越来越多、碎片越来越严重的时候,才不得不做一次 2 代 GC 来“大扫除”。
2.2 根对象:GC 判断存活的起点
GC 怎么知道一个对象到底还“活着”还是“已经没人要了”?答案是它从一组固定的“根”出发,沿着对象之间的引用关系遍历,碰到一个对象就标记一次。遍历结束之后,没有被标记到的对象就是垃圾,这就是“标记-清除”阶段的基本思路。
“根”具体包括什么?主要有这么几类:静态字段引用的对象、线程栈上的局部变量和参数引用的对象、CPU 寄存器里指向托管对象的引用、以及通过GCHandle显式分配的类型为Strong、Pinned的托管句柄。你看,GC 不是靠猜,也不是靠什么“名字里带 Temp 就是临时对象”的玄学,它完全是按引用可达性来判断的。
从根出发能遍历到的对象,被视为“可达对象”,不允许回收。从根出发找不到任何路径到达的对象,就是不可达对象,等待它的就是被回收。这个概念极其重要,也是后面排查内存问题时的主要工具。你跟别人争论“这个对象明明该被回收却一直没回收”的时候,本质上就是在争论“这个对象到底还有没有引用路径让它保持可达”。
2.3 标记-清除-压缩的执行流程
一次完整 GC 大体分三个阶段。
标记阶段,GC 暂停应用线程(术语叫 stop-the-world),从根出发遍历托管堆里的对象图,把存活对象打上标记。对于 .NET 的后台 GC、并发 GC,某些阶段可以和业务线程并发执行,不是整个流程都停顿,但是核心的关键步骤仍然有短暂的挂起。
清除阶段,识别那些没被标记的对象。如果是在 0 代这种碎片化不严重、对象又比较集中的区域,GC 可以比较简单地把它们看成“空隙”。但这里有个问题:内存被清出来后,如果对象之间空一段、实一段,新对象可能找不到足够大的连续区域,或者堆空间浪费明显。这时候就需要第三个阶段。
压缩阶段,把存活对象往一端移动,腾出连续的空闲区域,然后更新所有引用。这里有个细节,对象一旦移动,指向它的所有引用都得同步修正,包括根里的引用和对象内部指向别的对象的引用,所以压缩阶段通常必须暂停线程来做,代价不低。不是每次 GC 都会压缩,比如第 0 代回收经常直接“清扫”不做压缩,而大对象堆为了性能代价甚至默认不参与压缩。
2.4 什么样的时机触发 GC
很多人以为 GC 是“内存用到某个阈值才触发”,这个理解部分对但不完整。常见触发场景有三类。
第一类是分配触发。托管堆的每个“代”都有预算上限,0 代的空间用完了,就会触发一次第 0 代回收。这是最常见的形式,你不断创建新对象,堆空间不足,GC 就跳出来干活。
第二类是系统内存压力。操作系统报告物理内存紧张,运行时也会主动触发 GC,毕竟整个机器都要活下去,该让出内存就让出。
第三类是显式调用。你在代码里调GC.Collect(),运行时立刻执行回收。这个操作正常情况下绝对不要随意做,工作负载没到那个程度就强制全量回收,只会白白浪费性能。只有极少数场景,比如你认为进程马上要进入空闲期、或者你需要主动归还内存给操作系统,才值得考虑。
另一个大家容易忽略的是 GC 的模式配置。.NET 支持工作站 GC 和服务器 GC,服务器模式会为每个核创建独立的堆和回收线程,适合高吞吐、多并发的服务端场景。配置方法很简单,放在项目配置文件里:
{ "runtimeOptions": { "configProperties": { "System.GC.Server": true, "System.GC.Concurrent": true } } }大多数情况下,多核服务器上的 Web 服务默认会启用服务器 GC,但如果你写的是桌面工具、后台小任务,默认走工作站 GC 也没问题。选哪种模式取决于你的核心指标是吞吐还是延迟,没有绝对的对错。
3. 实操要点:终结器、Dispose 与弱引用
3.1 终结器与 IDisposable 如何配合
GC 管的是托管内存,但你的对象可能还包着文件句柄、数据库连接、网络连接这些非托管资源。非托管资源不会跟随 GC 自动释放,必须显式处理。这就是终结器(finalizer)存在的意义:你可以给类写一个析构函数,运行时在对象被回收前会调用它,给你一个最后清理非托管资源的机会。
好,这就是最容易出坑的地方。终结器确实能干活,但有三个明显的代价。第一,带终结器的对象回收速度慢,因为它要先进终结队列,等终结线程执行析构函数,之后再等下一轮 GC 才能真正清除内存,这个对象等于“多活了一轮”。第二,终结线程是单线程,万一析构函数里写了耗时操作,整个终结队列被卡住,后面所有待终结对象都跟着排队。第三,终结器里不能安全地访问其他托管对象,因为你不知道对方是不是已经被回收或者正在被终结。
所以正确姿势是:只有真正持有非托管资源时,才需要终结器兜底;日常使用必须走IDisposable模式,主动、确定性地释放。
public class ResourceHolder : IDisposable { private bool disposed; public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (disposed) return; if (disposing) { // 释放托管资源,内部可能的 IDisposable 对象 } // 释放非托管资源 disposed = true; } ~ResourceHolder() { Dispose(false); } }注意GC.SuppressFinalize(this)这行,很多人会漏掉。它的意思是:既然你已经主动调用Dispose把资源清干净了,那对象被 GC 回收时就别再执行终结器了,省去进终结队列、多活一轮的全部开销。写了它,带终结器对象的性能损耗才能降到最低。
3.2 弱引用:想用又不阻止回收
有时候你希望保存一个对象,但又不希望因为自己的引用导致它无法被 GC 回收。比如缓存大量数据,又不确定哪些数据什么时候会用上。直接持有强引用,内存压力会越来越大,最后所有缓存对象都堆在 2 代里,GC 救不回来。
方案是使用弱引用。弱引用指向一个对象,但不参与可达性判断。只要没有其他强引用指向这个对象,GC 在回收时就会把它干点,弱引用本身变成悬空状态。每次要使用时先检查Target是否还活着,活着就拿来用,死了就重新加载或重新构建。
var cache = new Dictionary<string, WeakReference>(); // 存入 cache[key] = new WeakReference(new DataObject(key)); // 取出 if (cache.TryGetValue(key, out var reference) && reference.Target is DataObject data) { // 命中缓存 } else { // 缓存失效,重新加载 }弱引用特别适合做“可有可无”的缓存层,不会因为你这边记了一个引用,就把整个缓存生命周期拖到进程末尾。但是要注意,弱引用本身也有开销,而且Target可能随时被回收,写代码时必须做好拿不到对象的兜底逻辑,不能假设存在。
3.3 大对象堆的特殊处理
.NET 里有个特殊区域叫大对象堆(LOH),存放大小超过 85000 字节(约 83 KB)的对象。典型例子包括大型数组、大型字符串,还有某些图形相关的对象。
大对象堆特殊在哪?最核心的一点是:它默认不参与压缩。原因很直接,把几十 KB、几百 KB 的对象搬来搬去,复制成本太高,对整体性能拖累太大。但这样做的代价是,大对象的反复分配和释放会逐步形成碎片,明明空余空间很多,却找不到一块足够大的连续区域来分配新的大对象,最后就只能触发一次代价更高的 2 代 GC,还要配合压缩来做整理。
所以写代码时,不能频繁创建大数组,尤其是那种“一会儿建一遍一会儿又丢弃”的写法。一个典型场景是日志信息拼接:一次拼接生成了超大的字符串,用完即弃,频繁发生就会不断冲击大对象堆。更好的做法是复用数组或者使用流式写入。从 .NET Core 3.0 开始,运行时提供了GCSettings.LargeObjectHeapCompactionMode,你可以让它在下一次阻塞式全量 GC 里压缩大对象堆,但这是应急手段,不是日常优化的首选。
4. 日常开发和性能调优中的 GC 实践
4.1 哪些情况下才考虑手动 GC.Collect
先说结论:生产代码里尽量不要出现显式的GC.Collect()。我见过一些同事为了“让内存降下来”,在每次调用某个接口前都调一次,结果就是 GC 线程疯狂干活,CPU 占用直线飙升,吞吐量掉得厉害,内存反而因为每次都要做大量标记和压缩而更不稳定。
那什么情况下值得调?我总结过几种不算太离谱的场景。
一种是程序即将进入长期空闲阶段。比如启动时加载一批配置数据、预建一批对象池,加载完成后你确定会有很长一段时间不再有高分配压力,这时可以调一次 GC,把启动期产生的临时对象清掉,让工作集回归平静。
另一种是进程刚完成一次巨大的批量处理,例如导出报表、批量导入数据,处理完之后内存里堆积了大量 2 代对象,你希望回收到操作系统一部分,可以调用GC.Collect()配合GC.WaitForPendingFinalizers(),但操作前后要用计数器记录对比,确认这个调用确实改善了问题,而不是心理安慰。
其余情况下,请相信运行时自己的决策。它的 GC 触发策略经过了海量场景的验证,比你的“我觉得该回收了”可靠得多。
4.2 观察 GC 行为的实用工具
排查 GC 问题,不能靠猜。工具选对,效率翻倍。我比较常用的是 .NET 官方 SDK 自带的几个命令行工具,整套流程下来基本覆盖大部分场景。
先看基础计数器,用dotnet-counters监控进程的 GC 指标:
dotnet-counters monitor --process-id 1234 --counters System.Runtime重点关注名称里面带gc-heap-size、gen-0-size、gen-2-size、alloc-rate、pause-time这些指标。如果alloc-rate长时间很高,说明分配压力大;如果pause-time频繁暴涨,说明 GC 停顿已经开始影响业务了。数字会说话,任何优化动手前先记录基线。
再看内存转储。当怀疑有内存泄漏时,用dotnet-dump抓转储文件:
dotnet-dump collect --process-id 1234 dotnet-dump analyze /path/to/dump在分析器里可以执行dumpheap -stat查看各类对象的数量和总大小,执行gcroot <对象地址>找到是谁引用了某个对象。这两个命令基本就是内存泄漏排查的主力了。转储文件可能很大,抓取前确认磁盘空间,分析时主要看占用最大、数量最多的类型,别一上来就看无关小对象。
4.3 几类常见的 GC 性能优化方向
GC 调优,说到底就是减少分配、缩短存活时间、降低回收频率。我实际操作后觉得最有效的是这几条。
第一,减少大对象堆分配。把频繁创建的大数组改成复用数组,或者用ArrayPool<T>做租赁式使用。每少一次 LOH 分配,就少一次 2 代 GC 的压力。
第二,减少中间字符串。比如大量日志拼接使用string +连续拼,每次拼接都会产生新字符串,大量中间对象压在 0 代。改用StringBuilder或字符串插值的一次性构造,分配次数立刻降下来。
第三,避免不必要的闭包捕获。Lambda 表达式里捕获了外部变量,会让编译器生成一个临时对象来保存捕获的变量,如果在热路径上频繁进入局部函数又捕获变量,就会不断产生这种对象。能用静态方法或参数传递的,就别捕获。
第四,关注事件订阅的取消。对象 A 订阅了对象 B 的事件,B 就持有了 A 的引用,这种关系不解除,A 永远不会被回收。静态事件、长期单例对象的事件更危险,因为引用可能贯穿整个进程生命周期。这类问题在第五节的排查实录里会看到实例。
调优没有银弹,核心思路就是让 GC 的工作量变小。你把分配速率压下去,GC 自动就消停了,应用自然更流畅。
5. 常见问题与排查技巧实录
5.1 内存只增不减:事件订阅与静态引用
有一次排查某监控服务,启动后内存平稳,运行几个小时以后开始缓慢爬升,峰值来回收割下不来。抓转储分析,发现某类日志消息对象数量异常多。顺着对象地址往上找引用链,最后看到问题出在一个静态事件上。某个全局状态管理器发布了事件,日志订阅者在监听,完成之后取消订阅的代码只在异常路径里执行。正常路径下,事件源和订阅者一直互相持有,导致所有订阅对象全部升到 2 代,再也没有回收机会。
这种问题很难靠肉眼发现,因为代码里每个模块看起来都很正常。排查路径推荐这样走:先观察计数器确认堆增长和分配速率都是什么水平,再抓转储查看对象数量排行,定位到可疑类型后用gcroot追引用链。一旦确认是静态事件或静态集合持有引用,立刻整改“取消订阅”逻辑,或者把静态事件源改造成弱事件模式。
5.2 频繁 GC 拖垮吞吐量
另一个典型场景是,某个 Web 服务的请求延迟开始抖动,平均耗时不高,但 p99 经常上一秒 5 毫秒、下一秒 2 秒。通过计数器发现gen-0 gc count飙升得离谱,每分钟几十上百次,说明分配压力极大。转储抓下来,发现一个热点方法在循环里创建大量小对象:遍历一批订单,每个订单都做一次字符串拼接、一个临时 DTO 赋值、再塞进一个后续根本没用的集合。
解决方法是重构热点路径:字符串拼接改StringBuilder,临时 DTO 能复用就复用,不能用就简化成值类型避免堆分配,集合改为预分配容量避免扩容触发额外分配。改完之后 GC 频率从每分钟几十次降到个位数,p99 延迟立刻回到正常范围。这个案例给我的经验是:GC 停顿并不可怕,可怕的是你自己不断地催着它跑。
5.3 我踩过的坑与几条避坑建议
把这几年和 GC 打交道的经验整理成几条列表,每条背后都有一段排查经历。
- 不要在循环里调用
GC.Collect()。在热路径上频繁触发全量回收会导致性能崩塌式的下降。 - 不要假设
null赋值立刻能释放对象。只要还有引用在某个根上,对象就活得下去。 - 带终结器的类一定要配套
GC.SuppressFinalize,否则回收延迟会被放大。 - 用弱引用做缓存时,必须处理
Target为空的路径,否则上线就能复现空引用。 - 大型集合请在创建时指定合理的初始容量,避免扩容时大量的数组复制和老对象晋升。
我个人的原则很简单:能靠结构设计解决的问题,不要靠 GC 参数去硬扛。把资源生命周期理清晰,比什么调优技巧都管用。
6. 常见问题速查表
把最常遇到的几个问题整理成表格,方便以后排查时直接对号入座。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 内存持续上涨不回落 | 静态引用、事件未取消、缓存无过期 | 转储看 gcroot,查对象数量和类型 |
| GC 计数高、CPU 打满 | 短期对象分配过多 | 看分配速率,检查热路径临时对象 |
| 大对象频繁触发 2 代 GC | 频繁创建大数组或大字符串 | 查 LOH 对象,改用复用或流式写入 |
| 终结器导致回收延迟 | 大量带析构类的对象 | 检查析构函数耗时和 SuppressFinalize |
| 某个对象一直不被回收 | 被根对象链可达 | gcroot 追引用路径,解除强引用 |
排查的顺序建议是:计数器看趋势,转储看分布,gcroot找源头,改完再回看计数器。这个流程稳得很,我这些年处理线上问题基本都是这套打法。
结束语
讲了这么多,GC 说到底就是一个词:权衡。运行时用停顿换空间,用代际换效率,开发者则用合理的生命周期设计来降低 GC 的工作压力。我自己在实际项目里最深的体会是:GC 不是让你完全不需要管内存,而是让你把原本手写free的那些精力,集中在更值得关心的“引用关系”上。你认真设计对象的生命周期,GC 自然回报你稳定和高效;你随随便便乱引用,再智能的回收器也没法替你擦屁股。
最后再送一个小技巧:真遇到内存问题别急着改代码,先抓数据。跑几分钟看计数器,再抓转储看对象分布,哪怕多花半小时定位到真凶,也比盲目优化半天有效得多。这套“先量化、再定位、后动手”的思路,我一直沿用到今天。