
一次生产事故服务内存一路飙高直到进程被系统杀掉日志刚好停在某个批量数据处理的入口。排查到最后问题出在一堆“看起来早就该被释放”的对象身上。当时好几个同事的第一反应都一样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 Dictionarystring, 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 调优说到底就是减少分配、缩短存活时间、降低回收频率。我实际操作后觉得最有效的是这几条。第一减少大对象堆分配。把频繁创建的大数组改成复用数组或者用ArrayPoolT做租赁式使用。每少一次 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 自然回报你稳定和高效你随随便便乱引用再智能的回收器也没法替你擦屁股。最后再送一个小技巧真遇到内存问题别急着改代码先抓数据。跑几分钟看计数器再抓转储看对象分布哪怕多花半小时定位到真凶也比盲目优化半天有效得多。这套“先量化、再定位、后动手”的思路我一直沿用到今天。