
有一段时间我负责一个 .NET Core 的后台服务服务刚上线头两天一切正常第三天内存稳定在 2.3 GB 左右不回落。业务量并没有明显增长GC 也一直在跑我用任务管理器看到那根几乎水平的弧线总觉得哪里不对劲。后来抓了一份 dump 用 SOS 查了一遍发现被我“以为”早就结束的任务对象全被一个 Timer 的静态缓存拽着一个都没走掉。这个案例恰好能回答标题里那个问题GC 号称自动内存管理可“自动”不代表你不用关心对象生命周期。GC 不会在对象不再用的时候立刻回收它只负责在合适的时机做一次可达性扫描把“从根上找不到”的对象清掉。问题的关键就在这里——你眼里“不再用”的对象在 GC 眼里可能仍然是活的而它到底卡在哪、为什么活得好好的才是所有 .NET 内存问题真正的入口。1. 先看 GC 的“自动”到底自动在哪里1.1 GC 不是在“对象不再用”的那一刻动手的很多开发者对 GC 的直觉是某个变量超出作用域或者我被赋值为 nullGC 马上就知道这块内存没用了然后立刻回收。这个直觉其实是错的。GC 本质上是一个带阈值的分配器。CLR 会为托管堆维护一个“当前分配预算”每当你在代码里 new 一个对象分配器就推进一次。当第 0 代的分配预算被耗尽时GC 才会被触发去做一次收集。你设置变量为 null 本身并不触发 GC它只是移除了一个引用而已真正决定对象是否被回收的是下一次 GC 做可达性扫描时这个对象还能不能从根集合被找到。这也是 .NET 的 GC 和 COM 时代的引用计数在直觉上的根本差异。引用计数是有“状态变化立即回调”的感觉的引用被释放计数器归零立刻销毁。而托管 GC 更接近“定期打扫卫生”垃圾可能已经在角落堆了一段时间但只要没到触发条件打扫的人不会来。所以标题的第一半答案已经出来了自动内存管理不是“实时内存管理”。你在意对象生命周期本质上是在意“在 GC 到来之前这个对象有没有被根集合以你不期望的方式长期引用”。1.2 对象为什么活着从根开始的引用链GC 判断对象是否存活的算法很朴素就是“从根集合出发沿着引用链遍历能走到的对象都标记为存活走不到的就是垃圾”。根集合包括这么几类静态字段引用的对象线程栈上的局部变量和参数引用的对象CPU 寄存器里引用的对象终结器队列里等待执行 Finalizer 的对象GC Handle 表里被 GCHandle 强引用的对象由 interop 传给非托管代码并保持引用的对象只要对象能从这些根节点中的任意一个出发被访问到GC 就认为它活着不管你是不是已经在业务上“放弃”了它。换句话说GC 不认识“业务上用完了”这件事它只认识“从根能不能访问到”。这两个视角之间的差别就是大量内存问题的温床。1.3 一个微小实验作用域结束不等于对象能回收再看一个日常写代码时常见的误判。很多人以为方法里的大括号限制了局部变量的生命周期代码走出作用域对象就可以释放了。真实情况比这复杂得多。看这个例子static void Demo() { var big new byte[1024 * 1024 * 64]; Console.WriteLine(step 1); // 到这里 big 理论上已经不会被使用了 Thread.Sleep(5000); Console.WriteLine(step 2); }在 Release 模式下JIT 可能在最后一次使用 big 之后就把这个局部变量标记为“不再活跃”GC 在 Sleep 过程中发生回收时big 是可能被回收的。但在 Debug 模式下为了调试体验JIT 会把局部变量的存活范围扩展到整个方法所以 big 在方法返回前一直被视为根。这就是为什么同一段代码 Debug 和 Release 的内存表现差异很大。有时候我们在 Debug 环境下用 dump 看到的引用到 Release 下未必存在。反过来有些你以为会被 JIT 自动缩短生命周期的引用在 Release 下反而因为寄存器分配、闭包或者内联等优化变得更隐蔽。所以“作用域结束”不等于“不会被回收”它只是告诉 JIT 和 GC 这个引用从某个时间点起不再参与后续计算。真正要紧的是这段代码是否还隐含了一个更长生命周期的根引用。2. 六大常见“根引用”把本该回收的对象死死拽住2.1 事件订阅没退订事件是 .NET 日常开发里最常见的隐形根来源。它本质上是一个委托链表的封装事件发布者通过内部委托列表强引用了所有订阅者。只要发布者活着所有订阅过它的对象就都活着。举个例子public class Channel { public event Actionstring? OnMessage; } public class Session { public void Handle(string msg) { } } // 使用方 _channel.OnMessage session.Handle; // 业务结束但忘记 _channel.OnMessage - session.Handle;如果 Channel 是长生命周期对象比如单例的消息总线Session 是短生命周期对象比如每来一个客户端连接就创建一个断开后应该销毁那么忘记退订这一行代码带来的结果就是所有曾经连接过的 Session 都被 Channel 持有一个都释放不掉。内存表现为持续增长GC 一直在跑但越跑越吃力。这也是“对象明明不再用了但不被回收”最典型的案例。订阅这个动作非常隐蔽因为它看起来只是“注册一个回调”很少有人会联想到它其实是把整个对象挂到了另一个对象身上。排查时看到一大堆 Session 或者 Service 实例堆积十有八九和事件订阅有关。2.2 静态引用与长期缓存静态变量是根集合里最直接的一类。一个 static 字段一旦指向了某个对象GC 就永远能从这个根出发访问到它。最常见的坑是“静态集合当缓存用”public static class Cache { public static Dictionarystring, TaskResult Results new(); }业务代码每次把结果塞进去但从来不清理。这个字典本身是静态的所以里面每个 TaskResult 对象都不会被 GC 回收。更隐蔽的是字典里可能还捕获了业务对象、DbContext、内存流等一大堆关联对象一条记录能牵连出几十个对象。MemoryCache、ConcurrentDictionary 这类工具本身也有这个问题。很多内存“高居不下”的服务打开 dump 一看里面躺着成千上万个永远不会再读的旧缓存项。2.3 lambda/async 闭包你以为局部函数只是一个函数闭包是另一个隐蔽的来源。写 lambda 表达式时如果它捕获了外部变量编译器会生成一个隐藏的闭包类来存放这些被捕获的变量。这个闭包对象就被委托对象引用着而委托对象如果被某个长生命周期对象保存那么整个捕获链上的对象都会被带活。var handler new Action(() { var sessionId _session.Id; Console.WriteLine(sessionId); }); _someLongLivedList.Add(handler);这里的 _session 被闭包捕获闭包被委托引用委托被某个 static List 引用。_session 虽然在业务里早就该销毁了但只要这个 List 不清空它就永远活着。async/await 也有类似的坑。异步方法会被编译器转成一个状态机对象这个状态机对象里保存了方法里的局部变量、参数和 this 引用。如果这个 async 方法返回的 Task 一直没完成或者被某个集合保存那么它捕获的所有上下文都会跟着活。排查的时候看到大量状态机类型通常是MethodNamed__N堆积就要想到异步调用链上可能有未完成的任务被外部强引用了。2.4 Timer 和后台线程的隐形根System.Threading.Timer 是一个特别容易造成“意外存活”的类型。它内部通过线程池定时触发回调而回调本身指向一个实例方法时会强引用目标对象。看这段代码public class MonitorService : IDisposable { private readonly Timer _timer; public MonitorService() { _timer new Timer(OnTick, null, TimeSpan.Zero, TimeSpan.FromSeconds(5)); } private void OnTick(object? state) { } }这个 Timer 本身被谁引用其实并不重要只要 Timer 还活着且没有被 Dispose它就会周期性触发 OnTick。而 OnTick 是 MonitorService 的实例方法所以 MonitorService 也一直活着。如果这段代码里 MonitorService 还持有大量业务数据那一次性泄漏的就是整个对象图。即便你是在某个方法内部 new 的 Timer只要这个方法没有主动 Dispose定时器就会一直存在。很多后台服务“内存涨上去下不来”的现场用 dump 一查都会发现 Timer 对象数量异常而它们的回调目标对象全部被牵扯住了。后台线程和 Task 也有类似效果。GC 根集合会扫描所有活动线程的栈只要一个线程还在执行方法栈帧里引用的对象就不会被回收。尤其是你自己 new 的 Thread 或者被阻塞的 Task如果方法里某个局部变量一直没出作用域这个对象就会一直活着。2.5 Finalizer 导致延迟回收和复活如果某个类写了终结器~ClassName() 形式的析构方法GC 回收它时会走一条特殊的延迟路径。普通对象被 GC 标记为垃圾后内存直接回收。但带终结器的对象第一次被发现不可达时GC 并不会马上回收而是把它放入终结器队列FReachable Queue由终结器线程调用它的 Finalize 方法。Finalize 执行完对象在下一轮 GC 里才能真正释放。这带来两个问题。第一对象回收被延迟了一到两轮 GC如果你在高频创建大量带 Finalizer 的对象内存压力会被明显放大。第二如果在 Finalize 方法里不小心把对象又引用给了某个静态字段对象就“复活”了而且因为它已经被标记过行为会变得非常诡异。所以如果只是为了释放非托管资源推荐使用 SafeHandle 或者实现 IDisposable而不是重写 Finalizer。终结器应该作为兜底方案而不是常规清理手段。2.6 GCHandle 与非托管资源GC 统计范围外的黑洞GCHandle 是托管对象和非托管代码之间的桥梁。如果代码里执行了 GCHandle.Alloc(obj, GCHandleType.Normal)这个对象就会被 GC Handle 表强引用即使业务上不用了GC 也不会回收直到你显式调用 Free。这在 P/Invoke、网络传输、图像处理等场景里很常见。比如某个底层库分配了一个 byte[] 给 native 代码写数据会用 GCHandle.Alloc(..., GCHandleType.Pinned) 固定住它。如果好多处代码只 Alloc 不 Free内存就会不断上涨而且从托管堆的角度看这些对象全都“活着”。还有一类是非托管资源比如 Windows 句柄、数据库连接、套接字、文件流。这些不属于 GC 管理范围。你看到托管堆并没有太大但整个进程内存一直涨多半是这类非托管资源没有释放。它们同样属于生命周期管理的一部分只是 GC 根本不负责。3. 用开源工具揪出“不死”对象的完整套路3.1 第一步看计数器判断是分配压力还是根引用问题不管问题多玄定位路径都是类似的。先不要猜用 dotnet-counters 看全局指标。dotnet-counters monitor --process-id 1234 --counters System.Runtime重点看这几个指标Generation 0/1/2 Collections各代 GC 发生次数。GC Heap Size当前托管堆大小。Allocated Bytes/sec每秒分配字节数。Finalization Queue Length等待终结器执行的对象数量。ThreadPool Queue Length线程池队列长度。如果 GC Heap Size 持续上涨且 Gen 0 Collections 也比较频繁但 Gen 2 Collections 很久才发生一次说明存在大量对象被长期根引用无法被正常回收。如果 Finalization Queue Length 一直维持较高数值说明有大量带 Finalizer 的对象积压后续要往终结器这个方向查。3.2 第二步抓内存快照缩小可疑类型范围确认压力来源后马上抓一份内存快照。dotnet-gcdump collect -p 1234 -o gcdump.gcdumpdotnet-gcdump 是微软官方工具生成的是 gcdump 格式文件可以在 Visual Studio 里直接打开看托管堆快照。它特别适合快速观察大对象类型、对象数量和引用关系。操作步骤也很简单打开 Visual Studio调试菜单里选择“分析 GCDump”加载刚才的文件就能看到托管堆上有哪些类型、实例数量、占用大小以及典型的引用关系。如果没有 Visual Studio也可以从命令行直接生成报告dotnet-gcdump report gcdump.gcdump -o report.html这份报告会列出每种类型的实例数量和总大小方便你锁定可疑类型。比如一眼看到某业务类有几千个实例而业务上明明只应该存在几十个那基本可以确定就是它泄漏了。3.3 第三步跟踪引用链找到根在哪里gcdump 能快速定位可疑类型但想真正弄清楚引用链条还得靠 dump 分析。先抓一份完整的进程 dumpdotnet-dump collect -p 1234 -o live.dmp然后进入分析模式dotnet-dump analyze live.dmp进入交互后先看概览 dumpheap -stat这条命令会列出托管堆上所有类型按实例数量和大小的统计。看到可疑类型后用下面的命令查看具体对象地址 dumpheap -type YourNamespace.Session -stat它会输出该类型所有实例的地址和大小。挑一个地址再来查引用链 gcroot -all object-addressgcroot 会从根集合出发把这个对象能被访问到的完整引用链打印出来。比如你能看到Found 1 unique roots (run gcroot -all for details). HandleTable System.EventHandler1... - ... - YourNamespace.Session这条链一旦出来问题基本就是透明的了。顺着链往回看是事件、静态字段还是 Timer 引用一目了然。3.4 完整的定位命令集合我把常用命令整理成一个固定流程遇到内存问题就按这个顺序走基本不会跑偏# 1. 看进程内 GC 指标 dotnet-counters monitor -p 1234 --counters System.Runtime # 2. 抓 gcdump看托管堆概况 dotnet-gcdump collect -p 1234 -o gcdump.gcdump # 3. 抓 dump用于引用链分析 dotnet-dump collect -p 1234 -o live.dmp # 4. 进入 dump 分析 dotnet-dump analyze live.dmp # 5. 在分析环境里依次执行 dumpheap -stat dumpheap -type 可疑类型 -stat gcroot -all 对象地址这套流程不依赖 Visual Studio 也能完整走下来适合在服务器上直接操作。注意 dump 文件大小可能和进程内存差不多抓之前确认磁盘空间充足。4. 真实案例复盘一个 Session 对象的“幽灵引用”4.1 现象与第一轮排查之前遇到的那个服务是长连接网关一类的程序。每次客户端连上来会创建一个 Session 对象断开后清理。业务逻辑上是这样设计的但跑了两三天后进程内存持续上升。我先用 dotnet-counters 看了一眼GC Heap Size 稳定在 2.3 GB 左右Gen 0 回收很频繁Gen 2 也有回收但堆大小就是降不下来。这已经基本排除了单纯分配压力过大更像是有一批对象被根引用着一直在堆里躺尸。接着抓了 gcdump用dumpheap -stat一看Gateway.Session的类型实例数接近一万而当时的活跃连接数才一两百。明显是 Session 对象堆积了。4.2 引用链确认我随机挑了一个 Session 对象地址执行gcroot -all看到的结果让我印象很深刻Found 1 unique roots. HandleTable Gateway.MessageChannel - System.Action1string - Gateway.Session.Handle(string) - Gateway.Session整条链非常清楚消息通道是个长期存活的对象它内部有一个事件委托订阅者是 Session 的实例方法所以每个 Session 都被消息通道间接引用。连接断开时退出逻辑只做了连接对象清理没有退订事件Session 自然就回收不掉。4.3 修复方案修复并不复杂让 Session 实现 IDisposable在 Dispose 里退订事件public class Session : IDisposable { private readonly MessageChannel _channel; public Session(MessageChannel channel) { _channel channel; _channel.OnMessage Handle; } public void Dispose() { _channel.OnMessage - Handle; } }然后保证连接断开、异常退出等所有路径都调用 Dispose。如果这类事件在很多地方都有订阅并且发布者的生命周期很长、订阅者生命周期很短还可以考虑弱事件模式也就是用 WeakReference 包装订阅者让事件发布者不持有强引用。但引入弱事件模式前要慎重它会让事件调用变成异步或反射调用带来额外复杂度最好还是先保证常规的订阅退订配对。4.4 后续验证修复后重新部署跑了两天dumpheap -stat里 Session 数量稳定在活跃连接数附近。内存曲线也变成了一条平线。这个案例给我的实际教训是事件订阅不是“注册回调”这么简单它本质上是在两个对象之间建立了一条强引用链。任何长期存活的对象订阅了你的短暂对象你都欠它一个 Dispose。5. 常见问题速查表与设计期避坑清单5.1 常见问题速查表现象可能根因推荐定位方式解决思路内存持续上涨GC 次数多但堆不降事件订阅未退订、静态缓存持有gcdump 看类型堆积gcroot 看引用链配对退订事件缓存加容量上限和过期托管堆很小但进程内存很高非托管资源未释放句柄、套接字、流任务管理器看句柄数结合 GC 指标使用 SafeHandle确保 Dispose 覆盖所有路径Finalization Queue 长期高位大量带 Finalizer 的对象积压dotnet-counters 查看该计数避免重写析构方法改用 IDisposable SafeHandle大量异步状态机对象堆积async 方法未完成Task 被长期引用dumpheap 查看MethodNamed__N类型检查 Task 是否有超时、取消机制避免无限等待大对象堆不断增长且碎片化频繁分配几十 MB 大数组且不释放gcdump 看 byte[] 大小和数量用 ArrayPool 或缓冲区复用减少大对象分配Timer 对象数量异常多Timer 未 Dispose被长期持有dumpheap -type System.Threading.Timer将 Timer 作为对象成员在 Dispose 中释放5.2 设计期避坑清单写代码阶段如果能规避一些反模式后面排查会轻松很多。第一遵循“引用方向”原则长期存活的对象不要引用短暂存活的对象除非你明确知道自己在做什么。事件、委托、静态字段、缓存都是这个原则最容易失控的地方。第二事件订阅和退订要成对出现。最好的做法是让订阅者的生命周期和订阅动作绑定在同一个方法里比如构造函数中订阅、Dispose 中退订。不要在零散的业务逻辑里随手注册事件最后漏掉某个分支。第三Timer 和后台线程尽量作为某类对象的内部成员并且让该对象实现 IDisposable。这样清理 Timer 的生命周期就有了明确归属否则找一个被到处 new 的 Timer 是否存在全靠代码 review 的运气。第四缓存一定要有边界。所有缓存类容器都需要容量上限、过期时间或淘汰策略没有边界的缓存本质上就是内存泄漏。第五能不用 Finalizer 就不用。如果确实需要清理非托管资源优先使用 SafeHandle终结器只做兜底。5.3 个人经验小技巧最后分享几个我实际工作中的小习惯。第一上线前先做 24 小时压测期间每隔几小时自动抓一次 dump。内存问题很多是慢性的跑几个小时才出现不压测很难在上线前发现。第二排查内存问题时不建议在生产代码里用 GC.Collect 做“强制回收”来验证。GC.Collect 可能会暂时把堆压下去但根引用产生的泄漏依然存在而且强制回收会带来明显的性能抖动。真正要紧的是找出为什么对象还能从根访问到而不是用强刷来掩盖问题。第三给关键业务对象加一个弱可观测的入口。比如在 Session 创建和 Dispose 时打日志统计活跃数量。一旦生产环境的活跃对象数量和 dump 里的实例数量对不上问题当场就能暴露。第四看到 cmd 提示git pull时提示fetch see help gc for manual housekeeping这类输出也别过度解读。GC 这个词在 Git 里也有和 .NET GC 一点关系都没有不过这说明 Git 也在做自己的垃圾回收。各行各业的“GC”都类似自动归自动但触发时机、引用关系和生命周期边界始终是使用者自己的责任。