
上周三半夜一个做检测设备上位机的老哥甩过来一句话“程序跑两天内存干到 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 Dictionarystring, object Cache只写不删静态字段本身就是 GC Root定时器与 CancellationTokenSourcenew 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 EventHandlerDataEventArgs 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 的最短路径。这条路径读起来是这样的顺序从下往上看最底下是根最上面是你要查的那个对象。我分享一个非常实用的读法——只看路径的最后两三跳。因为中间那些Listobject、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 里控件之间的订阅关系网一复杂漏一个很正常。我更推荐两种更抗造的写法。第一种是事件弱引用模式也就是WeakEventManagerWPF 自带或者自己封装一个弱事件。它的核心思路是让发布者用WeakReference持有订阅者这样订阅者不再被强引用该回收就回收。WPF 里的WeakEventManagerTEventSource, TEventArgs用起来还算顺手适合那种订阅关系天生就是“一对多且生命周期不一致”的场景。第二种是统一在容器层做生命周期管理凡是订阅了长生命周期对象事件的 ViewModel 或服务都注册到一个CompositeDisposable之类的容器里容器随宿主一起销毁。这样即使某个人忘了退订容器销毁时也会兜底。这个习惯一旦养成后面维护成本会低非常多。还有一个隐蔽场景要特别提一句Lambda 捕获变量之后订阅。比如manager.DataReceived (s, e) UpdateUi(this, e);这种写法你没法用-退订因为它每次都是一个新的委托实例-一个不存在的委托是无效操作。要么改成方法组引用要么把委托存到字段里。4.2 静态集合当缓存用涨起来悄无声息static Dictionarystring, 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一起用只设前者不设后者运行时会直接抛异常这是很多人的第一脚坑。第二档用ConditionalWeakTableTKey, 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全是这个原因。再说异步闭包。下面这种写法每次调用都会创建一个闭包对象而这个闭包捕获了thispublic 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 ConditionalWeakTableDeviceNode, LatestReport _reports new ConditionalWeakTableDeviceNode, 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 次之后。差异视图按计数差排序排在最前面的几个类型很扎眼类型计数差大小差判断DeviceConfigViewModel312.1 MB与操作次数吻合高度可疑PropertyChangedEventHandler1868.9 MB委托对象堆积说明订阅没退byte[]4218.6 MB大量中等大小的缓冲区OpcUaSubscription301.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这类配置去调整回收积极性但别把它当通用解药先确认是不是真的泄漏再说。对象池和数组池。ArrayPoolbyte.Shared、线程池、连接池这些东西的设计目标就是“留着复用”所以你会在快照里看到一堆byte[]被池子持有。判断标准是它会不会无限增长。池子有上限涨到某个值就停了这不是泄漏。大对象堆的碎片。LOH 默认不压缩回收后留下的空洞可能无法复用导致进程内存不下降但托管堆也不涨。这种情况的真正解法是减少大对象的频繁分配比如把几兆的序列化缓冲改成分块处理或者用RecyclableMemoryStream这类可复用流。一次性初始化带来的增长。第一次调用某个方法时会触发 JIT 编译、反射元数据加载、XmlSerializer动态生成程序集、表达式树编译等等这些都会让内存有一次台阶式上升。这个台阶在启动后几分钟内出现之后就平了属于正常开销。字符串驻留和静态只读数据。大量重复的短字符串会被驻留静态只读配置表本身就常驻。它们在快照里数量固定不随时间增长忽略即可。判断的黄金标准就一句话看它跟操作次数有没有线性关系看它有没有上限。有线性关系、没有上限那就是泄漏涨到某个值就平了那就是正常的缓存或者池化。这条标准我在无数个案例里验证过比任何工具指标都好用。我个人在实际操作中最深的一个体会是内存泄漏绝大多数不是“忘记写 Dispose”这么简单而是生命周期管理缺少设计。事件订阅、静态缓存、定时器这些东西写的时候都只有一行代码看起来无害但它们的生命周期天然比业务对象长。所以与其事后用工具一处处抓不如在架构层面先约定好谁订阅谁负责退订长生命周期的容器统一管释放禁止在业务代码里随手写静态集合。工具是用来验证和兜底的不是用来替代设计的。