上周三半夜,一个做检测设备上位机的老哥甩过来一句话:“程序跑两天内存干到 2G,重启能撑一天,客户已经把投诉递到老板桌上了。”他电脑上就装着 Visual Studio 2022 Community,习惯性打开任务管理器盯了半天,除了那条节节攀升的曲线,什么也没看出来。
这个场景我太熟了。做 C# 桌面程序、上位机、后台服务的,几乎都会撞上内存泄漏这堵墙。麻烦的地方在于:C# 有 GC,很多人下意识觉得“托管语言不会泄漏”,于是排查方向全错——去怀疑内存碎片、怀疑操作系统、怀疑第三方库,就是不怀疑自己写的那几行订阅和缓存。实际情况是,GC 只负责回收“不可达”的对象,只要还有一条引用链挂在 GC Root 上,这个对象就是“活着的垃圾”,堆里永远清不掉。
下面这些内容,是我用 Visual Studio 2022 自带的诊断工具窗口和内存使用率工具,实打实排掉几个泄漏之后整理出来的完整链路:怎么判断是真泄漏、怎么抓快照、怎么读快照、怎么顺引用链找到那一行代码、几类高频泄漏的改法,以及生产环境不能挂调试器时该用什么兜底。不管你是刚学会拖 WinForm 控件的新人,还是写了好几年 C# 上位机的老手,这套流程都能直接拿去用。
1. 先弄明白“托管内存泄漏”到底漏在哪
1.1 GC 回收的是“不可达”,不是“没人用”
先把这件事说透,不然工具用起来全是瞎点。.NET 的 GC 判断对象该不该死的唯一标准是可达性:从一组固定的根出发,比如静态字段、线程栈上的局部变量、CPU 寄存器、GC 句柄表、终结器队列等等,沿着引用关系做一次遍历,凡是能走到的对象都标记成“活的”,走不到的一律回收。注意,这里面完全没有“这个对象还有没有用”“业务上是不是已经不需要了”这种语义判断,GC 不懂业务。
所以 C# 里的内存泄漏,本质只有一种形态:你在业务上已经不需要某个对象了,但客观上还有一条引用链从 GC Root 指向它。这条链可能长这样:某个静态字典 → Value → List → 某个 ViewModel → 它订阅的事件回调 → 它持有的那个 200KB 的字节数组。只要链没断,这一整串都会留在堆里,而且后续每次操作还会往里加新的。
理解这一点之后,排查动作就变得非常明确了:不是去找“哪里没调 Dispose”,而是去找“谁会一直抓着我不放”。这个思路转变,是后面所有工具操作的底层逻辑。
1.2 五类最常见的根引用,占据了八成以上的泄漏
我这些年排到的泄漏,绝大多数都能归到下面这几类。列出来不是让你背,而是让你在快照里看到某个类型数量只增不减的时候,能条件反射地想到该去哪个方向找引用链。
| 类别 | 典型写法 | 对象为什么活着 |
|---|---|---|
| 事件订阅未退订 | publisher.Event += handler后从不-= | 发布者(尤其是静态发布者)通过委托的 Target 抓住订阅者 |
| 静态集合当缓存 | static Dictionary<string, object> Cache只写不删 | 静态字段本身就是 GC Root |
| 定时器与 CancellationTokenSource | new Timer(...)、带超时的CancellationTokenSource从未 Dispose | 定时器队列(静态根)持有回调,连带持有回调的 Target |
| 长生命周期 Task 的闭包 | 异步方法里捕获this或大对象,Task 迟迟不结束 | 异步状态机把捕获的引用存在堆上,任务不完成就不释放 |
| 非托管资源包装类 | Bitmap、SerialPort、文件流、HttpClient用错 | 句柄和本机内存不在托管堆里,GC 管不着,只能靠 Dispose |
还有几类属于“看起来像泄漏、其实不是”的,后面第 6 章会专门讲,先按下不表。
1.3 三十行代码复现一个标准的真泄漏
理论讲多了容易飘,先给你一段能跑的代码。新建一个控制台或 WinForm 项目,把下面两个类贴进去,循环调用OnFrame,然后用内存工具观察,你能非常清楚地看到MonitorView的实例数一路上涨,而且强制回收也降不下来。
public class DataEventArgs : EventArgs { public byte[] Frame { get; } public DataEventArgs(byte[] frame) => Frame = frame; } public class DeviceManager { // 注意这里是 static event,它是 GC Root public static event EventHandler<DataEventArgs> DataReceived; public void OnFrame(byte[] frame) { DataReceived?.Invoke(this, new DataEventArgs(frame)); } } public class MonitorView { private readonly byte[] _buffer = new byte[200 * 1024]; public MonitorView(DeviceManager mgr) { // 订阅了,但整个类的生命周期里从没退订过 DeviceManager.DataReceived += OnData; } private void OnData(object sender, DataEventArgs e) { // 更新界面 } }问题出在DeviceManager.DataReceived是静态事件。委托对象里有一个Target字段指向MonitorView实例,而这个委托被静态字段持有,静态字段是 GC Root。于是每一轮new MonitorView(...)都会往这条链上再挂一个 200KB 的_buffer,谁也别想被回收。这段代码的价值在于:它足够小,你可以在它身上把“抓快照 → 看计数差 → 看引用路径”整套流程走一遍,成本极低。等你在简单案例上把工具用熟了,再去处理几十万行的真实项目,就不会手忙脚乱。
2. 打开诊断工具,先分清“真泄漏”和“假增长”
2.1 诊断工具窗口怎么开,两种采集模式怎么选
Visual Studio 2022 里跟内存相关的入口有两个,很多人分不清,我用下来是这样分工的:
第一个是诊断工具窗口,调试状态下按Ctrl + Alt + F2呼出(或者菜单“调试 → 窗口 → 显示诊断工具”)。它默认就开着,属于轻量级的实时监视,好处是不用额外启动会话,坏处是它偏向于“看一眼趋势”。要让它采集内存数据,得在窗口里勾上“内存使用率(Memory Usage)”,这里有个坑——勾选之后必须重启当前的调试会话才生效,很多人勾完发现没数据,就是因为没重启。
第二个是性能探查器,按Alt + F2,从“可用工具”里选“内存使用率”,点击“开始”后会重新启动目标进程。它适合做正式的、需要留存快照和分析的排查,还能同时勾选“.NET 对象分配跟踪”来看谁在疯狂分配。
关于采集精度,内存工具里有个容易忽略的点:托管堆快照本身是精确的(它会先做一次回收整理再抓),但如果你勾了“启用本机内存分析”(这个选项主要面向 .NET Core / .NET 5+ 的项目),采集开销会明显上升,程序会变卡。所以我的习惯是:先用托管堆快照定位托管侧的泄漏,确认托管堆稳定了还有内存涨,再去开本机内存分析查非托管那部分。
2.2 强制回收三连:给 GC 一次机会再下结论
看到曲线往上涨就喊泄漏,是新手最容易犯的错。堆涨了可能只是垃圾还没来得及收,或者刚发生过一次大分配。我判断的方法很土但很管用:
- 让程序跑完几轮典型操作,停在断点上;
- 打开“即时窗口”(
Ctrl + Alt + I),敲下面三行并回车; - 等几秒,再取一次快照,看数字有没有回落。
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();为什么要调两次Collect中间夹一个WaitForPendingFinalizers?因为重写了终结器(析构函数)的对象在第一轮 GC 里会被放进终结器队列——终结器队列本身是一个 GC Root,所以这一轮它死不了;等终结器线程跑完,第二轮才能把它真正回收掉。这就是常说的“带终结器的对象至少活过两次 GC”。如果你的对象数量在强制回收后明显下降,那之前看到的多半只是延迟回收,不是泄漏;如果纹丝不动,恭喜,可以进入正式排查了。
提示:即时窗口执行方法调用需要处于中断(断点命中)状态,程序全速运行时是敲不进去的。
2.3 进程内存、托管堆、GC 堆这三个数字别混着看
这三个数经常被混为一谈,导致判断方向出错。任务管理器里的“内存(专用工作集)”是整个进程占用的物理内存,包含托管堆、JIT 编译后的代码、加载的 DLL、非托管分配、内存映射文件等等。内存工具里的“托管堆”只统计 GC 管的那些对象。而 GC 从操作系统申请的段(segment)总大小,又会大于当前实际存活对象的体积——因为 GC 保留了一些预留空间,回收后的空洞未必立刻还给操作系统,尤其 Server GC 模式下每个核心一份堆,看起来更吓人。
所以正确的判断顺序是:先看托管堆的快照,确认是不是托管对象在涨;如果托管堆稳如老狗而进程内存一路走高,那嫌疑就转移到非托管侧,比如 GDI 对象、句柄、图像缓冲区、串口或者设备 SDK 内部申请的缓冲区。方向错了,工具再熟也白搭。
3. 快照对比:从几万个对象里把嫌疑人揪出来
3.1 两次快照怎么取,决定了后面两小时还是两分钟
快照对比的效率,八成取决于你取快照的时间点选得对不对。我的固定套路是:
- **第一张快照(基线)**在程序热身之后取。所谓热身,就是界面已经打开、数据库/设备连接已经建立、走过一两轮完整业务流程。这时候堆里那些“本来就该长期存在”的常驻对象都已经就位,它们不会干扰后面的判断。
- 第二张快照在重复执行某个具体操作 N 次之后取。这个 N 很关键,建议取 20 到 50 次这种量级:次数太少,泄漏量的差异淹没在噪声里;次数太多,自己等得心烦。
- 中间不要手动触发回收,让程序按自然节奏跑,最后再看差异。
为什么要强调“重复同一个操作”?因为泄漏的特征就是对象数量与操作次数成正比。你重复 20 次打开设备详情页,某个类型多出 20 个实例,这就不是巧合,是铁证。反过来,如果只是漫无目的地跑了一天,堆里多了一堆东西,你根本不知道哪个跟哪个操作有关,分析时间会成倍增加。
3.2 读懂计数差和大小差,先锁定类型再谈细节
两张快照取完之后,在快照列表上选定第二张,右键选“与快照比较”,基线选第一张,就进入了差异视图。这里有两组数字要盯:
- 计数差(Count Diff):对象实例数量的变化;
- 大小差(Size Diff):这些实例占用的总字节数变化。
表格默认按大小差排序,我一般会先按计数差看一遍,再按大小差看一遍,两个视角经常能给出不同的线索。判断规律大致是:
| 观察到的现象 | 大概率结论 |
|---|---|
| 计数差 ≈ 操作重复次数,且大小差同步线性增长 | 按次泄漏,且泄漏对象本身就是这个类型 |
| 计数差很小但大小差很大 | 单个对象在变大,或命中了 85000 字节以上的大对象堆(LOH) |
| 只有某个集合类的计数在涨,业务类型没涨 | 泄漏在容器里,比如 List、Dictionary 扩容后旧数组还活着 |
| 所有类型都在小幅波动,没有明显线性关系 | 很可能不是泄漏,是缓存或池化的正常行为 |
这里补一个常被忽略的知识点:85000 字节以上的对象会进大对象堆(LOH),默认情况下 LOH 不做压缩整理,回收后留下的空洞未必能被复用,容易表现为“我明明释放了,内存还是那么高”。如果你在快照里看到byte[]的计数差不大但大小差动辄几十兆,就要往这个方向想,比如一个超大的序列化缓冲区、一张没压缩的位图数组。
3.3 引用路径才是终局证据
到这一步你还只知道“某个类型在涨”,距离改代码还差最关键的一步:它为什么活着。在差异视图里选中那个可疑类型,双击实例列表中的某一个实例,打开“引用路径(Paths to Root)”,工具会给你展示从这个对象一路走到 GC Root 的最短路径。
这条路径读起来是这样的顺序:从下往上看,最底下是根,最上面是你要查的那个对象。我分享一个非常实用的读法——只看路径的最后两三跳。因为中间那些List<object>、DictionaryEntry、数组元素之类的框架内部结构没有价值,真正有用的是:
- 根的类型是什么?是静态字段、某个线程的栈、还是终结器队列?
- 根下面第一跳的对象是哪个?这个对象你认不认识?
如果路径的尽头是一个静态字段,那基本可以确定是缓存或者静态事件;如果尽头是某个线程的调用栈,说明这个对象被一个还没返回的方法的局部变量扣着,往往是死循环或者长阻塞导致的;如果尽头是终结器队列,那就是资源释放没做好。把这一跳对上号,下一步改哪个文件基本就心里有数了。
4. 高频泄漏的改法清单
4.1 事件订阅忘了退订,是最经典也最容易反复犯的错
第 1.3 节的例子已经展示了原理,这里说改法。最直接的是让订阅方实现IDisposable,在Dispose里把订阅撤掉:
public class MonitorView : IDisposable { private bool _disposed; public MonitorView(DeviceManager mgr) { DeviceManager.DataReceived += OnData; } public void Dispose() { if (_disposed) return; DeviceManager.DataReceived -= OnData; _disposed = true; } private void OnData(object sender, DataEventArgs e) { /* ... */ } }但我得说句实在话:光靠“记得退订”是靠不住的,尤其是在 WPF 或 WinForm 里,控件之间的订阅关系网一复杂,漏一个很正常。我更推荐两种更抗造的写法。
第一种是事件弱引用模式,也就是WeakEventManager(WPF 自带)或者自己封装一个弱事件。它的核心思路是让发布者用WeakReference持有订阅者,这样订阅者不再被强引用,该回收就回收。WPF 里的WeakEventManager<TEventSource, TEventArgs>用起来还算顺手,适合那种订阅关系天生就是“一对多且生命周期不一致”的场景。
第二种是统一在容器层做生命周期管理:凡是订阅了长生命周期对象事件的 ViewModel 或服务,都注册到一个CompositeDisposable之类的容器里,容器随宿主一起销毁。这样即使某个人忘了退订,容器销毁时也会兜底。这个习惯一旦养成,后面维护成本会低非常多。
还有一个隐蔽场景要特别提一句:Lambda 捕获变量之后订阅。比如manager.DataReceived += (s, e) => UpdateUi(this, e);,这种写法你没法用-=退订,因为它每次都是一个新的委托实例,-=一个不存在的委托是无效操作。要么改成方法组引用,要么把委托存到字段里。
4.2 静态集合当缓存用,涨起来悄无声息
static Dictionary<string, UserInfo> _cache = new();这种代码,在很多上位机和服务端项目里到处都是。它的问题是:没有淘汰策略。今天查了 1000 个设备号,明天查了 10000 个,字典就一直是这么涨,而且静态字段是根,永远不会被回收。
改法分三档,按投入成本递增:
第一档,用Microsoft.Extensions.Caching.Memory里的MemoryCache,设置绝对过期时间和容量上限。它的 API 跟你手写字典差别不大,但自带淘汰和过期,成本极低,是最推荐的起步方案:
private static readonly MemoryCache _cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 1000 }); public static UserInfo Get(string key) { return _cache.GetOrCreate(key, entry => { entry.Size = 1; entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10); return LoadFromDb(key); }); }注意SizeLimit一定要配合entry.Size一起用,只设前者不设后者,运行时会直接抛异常,这是很多人的第一脚坑。
第二档,用ConditionalWeakTable<TKey, TValue>。它比较适合“给某个对象附加一份额外数据”的场景,键是弱引用,键对象没了,条目就自动消失。典型的用法是给控件附加元数据,不用操心清理。
第三档,自己封装WeakReference缓存。这个自由度最高,但坑也最多,除非有特殊需求,一般用不着。
4.3 定时器、异步任务和闭包里的隐性持有
这三样东西的共同点是:代码看起来已经“用完了”,但引用链还在。先说定时器,这里有个写在官方文档里的真实泄漏点:System.Threading.Timer的回调会被内部的定时器队列(一个静态结构)持有,如果你不调Dispose,那么定时器对象和它回调指向的目标对象会一直活着,哪怕你的业务代码早就把它的引用置空了。
// 有问题的写法:一直 new,从来不释放 private void RestartPolling() { var timer = new Timer(OnPoll, null, 0, 1000); _timers.Add(timer); // 换设备时又 new 一个,老的从没 Dispose } // 正确写法:换之前先释放 private void RestartPolling() { _timer?.Dispose(); _timer = new Timer(OnPoll, null, 0, 1000); }顺带说一个很常见的连带问题:带超时或者通过CreateLinkedTokenSource创建的CancellationTokenSource必须 Dispose。因为它内部挂着一个定时器来实现超时,不释放就等于漏了一个定时器。我见过一个轮询程序,每 500ms 建一个带超时的 CTS 去做设备请求,一天下来堆里躺着十几万个没释放的 CTS,全是这个原因。
再说异步闭包。下面这种写法,每次调用都会创建一个闭包对象,而这个闭包捕获了this:
public void KickOff() { _ = Task.Run(async () => { await Task.Delay(1000); RefreshUI(this); // 捕获了 this }); }如果外层对象是个长生命周期的服务,而 Task 迟迟不结束(比如等待一个永远不返回的网络请求),那这个闭包和它捕获的一切都会一直挂在异步状态机里。改法一方面是给网络操作加超时和取消令牌,另一方面是别在循环里无脑Task.Run,用Task.WhenAll加并发上限来控制。顺带提一句async void:除了事件处理器,其他地方尽量别用。它的异常没法捕获,而且调用方拿不到 Task,你连它什么时候结束都不知道,排查起来极其痛苦。
4.4 非托管资源与 IDisposable 的正确姿势
Bitmap、SerialPort、FileStream、Pen、Font、设备 SDK 返回的句柄类,这些都属于托管对象里包着非托管资源的类型。GC 只管托管内存,这些资源得靠Dispose。写这类代码,我坚持三个习惯:
第一,能用using就绝不手写 try/finally。using声明(C# 8 之后可以不用大括号)写起来最省事,比如using var stream = File.OpenRead(path);,出了作用域自动释放,几乎不可能忘。
第二,实现了 IDisposable 的类型,自己也要实现 IDisposable,并且在Dispose里级联释放自己持有的字段。这个链条断一环,整条链都漏。
第三,理解终结器的代价。带终结器的对象至少要活过两次 GC,而且终结器线程是单线程的,如果终结器里干了重活(比如关闭网络连接、刷盘),会让整个终结器队列堵住,堆增长速度直接起飞。所以标准写法是这样:
public class DeviceSession : IDisposable { private SafeHandle _handle; // 优先用 SafeHandle 包装句柄 private bool _disposed; public void Dispose() { Dispose(true); GC.SuppressFinalize(this); // 手动释放过就别再走终结器 } protected virtual void Dispose(bool disposing) { if (_disposed) return; if (disposing) { // 释放托管资源 } // 释放非托管资源 _handle?.Dispose(); _disposed = true; } ~DeviceSession() => Dispose(false); }如果你用的是 .NET Core / .NET 5+,还有个更省心的选择:把资源包进SafeHandle派生类,终结器和引用计数都由框架管,你只需要专心写业务逻辑。这是官方推荐的做法,能避掉一大类“忘记释放句柄”的问题。
4.5 弱引用和 ConditionalWeakTable 的实战用法
前面反复提到弱引用,这里给你一个具体可用的场景。假设你做了一个设备树,每个节点要挂一份“最近一次上报数据”,但你不希望这份数据影响节点本身的生命周期。用普通字典就会导致节点永远删不掉,用ConditionalWeakTable就刚刚好:
private static readonly ConditionalWeakTable<DeviceNode, LatestReport> _reports = new ConditionalWeakTable<DeviceNode, LatestReport>(); public static void SetReport(DeviceNode node, LatestReport report) { _reports.Remove(node); // 覆盖旧值 _reports.Add(node, report); } public static LatestReport GetReport(DeviceNode node) { return _reports.TryGetValue(node, out var r) ? r : null; }键是弱的,DeviceNode一旦从别的地方失去引用,这个条目会自动消失,你不需要写任何清理代码。代价是:它不适合做需要按时间淘汰的缓存,因为淘汰时机完全由 GC 决定,你控制不了。所以它和MemoryCache的分工很清楚:附加数据用前者,业务缓存用后者。
5. 一次上位机泄漏的完整复盘
5.1 现象与第一轮猜测
回到开头那位老哥的项目。程序的功能是:通过 OPC UA 采集若干点位数据,同时接一路相机取流做实时显示,界面用 WPF,跑在工控机上。现象很明确:连续运行约 40 小时后,进程内存从 180MB 涨到 1.9GB,然后开始卡顿,最终抛内存不足。
他一开始的猜测是相机 SDK 的问题,因为“只要不打开视频界面,涨得就慢很多”。这个观察其实很有价值,但结论下早了。我让他先做一件事:把相机界面关掉之后,重复开关“点位配置”页面 30 次,看内存涨不涨。结果是涨了,从 210MB 涨到 520MB。这就说明泄漏点不止一个。
5.2 快照对比把范围压到一个类型
我们用诊断工具窗口取了两张快照:第一张在程序启动、连上 OPC UA 之后;第二张在重复开关配置页 30 次之后。差异视图按计数差排序,排在最前面的几个类型很扎眼:
| 类型 | 计数差 | 大小差 | 判断 |
|---|---|---|---|
| DeviceConfigViewModel | +31 | +2.1 MB | 与操作次数吻合,高度可疑 |
| PropertyChangedEventHandler | +186 | +8.9 MB | 委托对象堆积,说明订阅没退 |
| byte[] | +42 | +18.6 MB | 大量中等大小的缓冲区 |
| OpcUaSubscription | +30 | +1.5 MB | 与页面打开次数吻合 |
看到PropertyChangedEventHandler涨了 186 个,基本就能确定是绑定或者订阅的问题。31 个 ViewModel 对应 186 个处理器,平均每个实例挂了 6 个订阅,这个比例在 WPF 项目里很典型。
5.3 顺着引用路径找到那一行
选中DeviceConfigViewModel的一个实例,看引用路径。路径大概是这样:DeviceConfigViewModel→PropertyChangedEventHandler→OpcUaSubscription→ 静态字段OpcService.RootSubscription。看到“静态字段”三个字,问题就清楚了:每次打开配置页,都会新建一个OpcUaSubscription,把它的事件订阅到ViewModel的PropertyChanged上,但页面关闭时没有任何退订动作,而这个订阅对象被服务层的静态字段一直拿着。
相机那一侧的泄漏则是另一回事:视频帧转成BitmapSource之后,原始的Bitmap没有释放。这种情况在托管堆快照里看不太出来(Bitmap的本机内存不统计在内),但你会看到byte[]涨得很快,同时任务管理器里的 GDI 对象数量也在持续上升。
5.4 修复与验证
修复分三步走。第一步,把DeviceConfigViewModel的订阅改成弱事件或者显式退订,同时给页面加IDisposable,在关闭事件里调用;第二步,相机帧处理后立刻释放中间位图,能用using的地方全部改成using;第三步,给订阅对象加了一个上限保护,超过 N 个还没被回收就记一条警告日志。
验证方法也很简单:修复后重复开关配置页 200 次,再取快照对比,DeviceConfigViewModel的计数差降到了 1 到 2 之间(正常波动),byte[]基本不再增长。然后挂机跑了两天两夜,进程内存稳定在 220MB 到 260MB 之间小幅波动,没有再往上爬。
有个细节值得记录:修复后的第一次验证,我们发现
byte[]还有缓慢增长,追下去是相机 SDK 内部的一个帧缓冲池,属于正常行为——它涨到某个上限就停了。这个案例说明,不要追求“内存曲线绝对水平”,要追求“有上限、会回落”,这才是健康状态。
6. 让排查更省力的工程习惯与兜底手段
6.1 调试符号、发布配置和几个容易踩的坑
用内存工具之前,有几个环境层面的坑我要提前说,能省你不少时间。
第一是调试配置和发布配置的差异。Debug 配置下编译器不做优化,局部变量为了便于调试会被延长生命周期到方法结束,这会让你看到一些“假泄漏”——方法都返回了,对象还活着。所以用内存工具的时候要心里有数:在 Release 下复现的泄漏才是真泄漏。但反过来,内存工具本身在 Debug 会话里更好用,符号信息也全。我的做法是先在 Debug 下定位怀疑对象,再切 Release 验证一遍。
第二是符号文件。如果你怀疑泄漏发生在第三方库或者框架内部,一定要在“工具 → 选项 → 调试 → 符号”里配好符号服务器,或者手动加载 pdb。没有符号,引用路径里全是<未知>,等于白看。
第三是别在采集期间做无关操作。取快照本身会让进程暂停一小会儿,如果这时候后台线程还在跑,你看到的就是一个混乱的状态。取完基线快照后,最好让程序静置几秒再开始下一轮操作。
第四,如果你的项目比较大,先排除掉诊断工具自身的开销影响。内存工具在高频分配的场景下会让程序明显变慢,有时候会让一些时序敏感的 bug 消失。遇到这种情况,用后面说的命令行工具去采,干扰会小一些。
6.2 不能挂调试器时,用命令行工具兜底
生产环境、客户现场、无人值守的工控机上,是没法挂 Visual Studio 的。这时候需要一套命令行工具,它们都属于 .NET 的诊断工具集,装上就能用:
dotnet tool install --global dotnet-counters dotnet tool install --global dotnet-gcdump dotnet tool install --global dotnet-dumpdotnet-counters用来看实时指标,判断是不是真在泄漏:
dotnet-counters monitor -p 12345 --counters System.Runtime重点看gc-heap-size(托管堆大小)、gen-2-size、loh-size、alloc-rate和time-in-gc这几个指标。如果gc-heap-size在低负载下持续单调上升、time-in-gc越来越高,那就是典型的泄漏特征。
dotnet-gcdump可以在不影响进程运行的前提下抓一份托管堆的图:
dotnet-gcdump collect -p 12345 -o leak.gcdump抓下来的.gcdump文件能直接用 Visual Studio 2022 打开(双击或者“文件 → 打开 → 文件”),一样能做快照对比和引用路径分析。这是我在客户现场最常用的手段:让对方在出问题的时候抓两份,间隔半小时,发回来我在这边分析。注意gcdump不包含对象的具体内容,只有类型、数量和引用关系,所以查不了“这个字符串里到底是什么”。
如果你需要看对象的实际数据,那得用dotnet-dump抓完整转储:
dotnet-dump collect -p 12345 dotnet-dump analyze core_20240101_120000进去之后,dumpheap -stat列出所有类型和数量,dumpheap -type DeviceConfigViewModel看具体实例地址,gcroot 00007ff8a1b2c3d0查引用路径,跟图形界面里做的事情是一样的,只是换成命令行。这个工具的学习曲线稍微陡一点,但胜在能解决最极端的场景。
6.3 那些被误判成泄漏的正常现象
最后这一节,我要给几个“看起来像泄漏、其实是正常行为”的情况平反。把这些搞明白,能让你少改一堆没必要的代码。
Server GC 的内存预留。在容器或者多核服务器上,Server GC 会为每个逻辑核心分配独立的堆段,进程启动就占掉几百兆非常正常。而且回收后的空间未必立刻归还操作系统,看起来就是“内存下不去”。.NET 5 之后可以通过DOTNET_GCConserveMemory这类配置去调整回收积极性,但别把它当通用解药,先确认是不是真的泄漏再说。
对象池和数组池。ArrayPool<byte>.Shared、线程池、连接池,这些东西的设计目标就是“留着复用”,所以你会在快照里看到一堆byte[]被池子持有。判断标准是:它会不会无限增长。池子有上限,涨到某个值就停了,这不是泄漏。
大对象堆的碎片。LOH 默认不压缩,回收后留下的空洞可能无法复用,导致进程内存不下降但托管堆也不涨。这种情况的真正解法是减少大对象的频繁分配,比如把几兆的序列化缓冲改成分块处理,或者用RecyclableMemoryStream这类可复用流。
一次性初始化带来的增长。第一次调用某个方法时会触发 JIT 编译、反射元数据加载、XmlSerializer动态生成程序集、表达式树编译等等,这些都会让内存有一次台阶式上升。这个台阶在启动后几分钟内出现,之后就平了,属于正常开销。
字符串驻留和静态只读数据。大量重复的短字符串会被驻留,静态只读配置表本身就常驻。它们在快照里数量固定,不随时间增长,忽略即可。
判断的黄金标准就一句话:看它跟操作次数有没有线性关系,看它有没有上限。有线性关系、没有上限,那就是泄漏;涨到某个值就平了,那就是正常的缓存或者池化。这条标准我在无数个案例里验证过,比任何工具指标都好用。
我个人在实际操作中最深的一个体会是:内存泄漏绝大多数不是“忘记写 Dispose”这么简单,而是生命周期管理缺少设计。事件订阅、静态缓存、定时器这些东西,写的时候都只有一行代码,看起来无害,但它们的生命周期天然比业务对象长。所以与其事后用工具一处处抓,不如在架构层面先约定好:谁订阅谁负责退订,长生命周期的容器统一管释放,禁止在业务代码里随手写静态集合。工具是用来验证和兜底的,不是用来替代设计的。