1. 先把话说清楚:C# 里的内存泄漏到底长什么样
C# 有 GC,很多人第一反应是"托管代码哪来的内存泄漏"。这个说法只对了一半。GC 能回收的是"没有任何根引用指向的对象",它管不了"你还拿着引用却不打算再用"的对象。所以 C# 里的内存泄漏本质上不是内存丢了,而是引用丢了——你以为对象已经没用了,但某个长生命周期的容器、委托链、静态字段、运行时内部队列还牢牢攥着它。我做上位机和工控软件那几年,跑七天八天不重启是常态,内存泄漏几乎是最常见的现场故障,而且往往不是崩在功能上,是崩在"跑了三天之后开始卡、第五天开始报 OutOfMemoryException"。
还有一个更麻烦的分支:非托管资源泄漏。Bitmap、Graphics、文件句柄、串口、Socket、OPC UA 会话,这些东西在托管堆上只占几十个字节的壳子,真正的内存在 GDI 或者内核里。你用托管内存快照看,曲线平得像一条直线,但任务管理器里的句柄数一路往上走,最后画图直接卡死。所以排查内存泄漏之前,第一件事是分清楚:这次到底是托管堆在涨,还是句柄/原生内存在涨。这两条路的工具链完全不一样,用错工具就是白干一整天。
Visual Studio 2022 在诊断能力上比 2019 强了不少,尤其是对 .NET 6/7/8 的支持,内存快照的差异对比、分配调用堆栈、原生内存视图都做进去了。但工具好不代表能定位,我见过太多人拍了两个快照,看到某个类型数量涨了 5000 个,然后就卡在那里了——他不知道接下来该看哪一列,不知道怎么从实例走到根引用,也不知道"涨了"到底是不是正常的缓存预热。这篇就把这套流程完整走一遍,从判断到底是不是泄漏开始,到工具选型、快照对比、根路径追踪,再到八种高频泄漏模式的逐个拆解。
1.1 托管堆泄漏和原生泄漏是两码事
托管泄漏的典型表现是:进程的 Private Bytes 持续上涨,GC 堆大小(GC Heap Size)在 Gen2 回收之后仍然不回落。你可以在任务管理器里看"提交大小",或者用性能计数器 .NET CLR Memory 下的 # Bytes in all Heaps。如果是 .NET Core 及以上,用 dotnet-counters 看 gc-heap-size 最直观。判断标准不是"涨了",而是"Full GC 之后不降"。GC 回收是有成本的,运行时会根据分配速率调整回收频率,短时间上涨完全正常。
原生泄漏的表现是:GC 堆稳定,但进程的 Working Set 和句柄数一直涨。这时候你要看的是任务管理器里的"句柄数"和"GDI 对象"(需要在任务管理器详细信息里手动加列),或者用 GDIView 这类小工具看 GDI 对象的具体类型分布。典型元凶是 Bitmap 没 Dispose、Graphics 没释放、FileStream 没关、串口对象没 Close。还有一种更隐蔽的:P/Invoke 调用返回的指针用 Marshal.AllocHGlobal 分配了但没 Free,这种在托管堆里完全看不出来。
我一般会先花两分钟做个粗筛:打开任务管理器,跑一次典型业务循环 50 遍,观察三个数字——提交大小、句柄数、GDI 对象数。如果只有提交大小涨,基本是托管问题;如果句柄数或 GDI 对象数涨,先按非托管查;如果两个都涨,那大概率是同一处代码同时持有托管对象和非托管资源,比如自定义控件里缓存了太多 Bitmap。
1.2 三个数字帮你判断是泄漏还是正常缓存
第一个数字是 GC 堆大小在压力测试前后的差值。做法很简单:启动程序,等它跑完预热,手动触发一次 Full GC(VS 诊断工具里有"强制垃圾回收"按钮,或者代码里调 GC.Collect() 加 GC.WaitForPendingFinalizers()),记录堆大小;然后跑 200 次核心业务,再强制回收,再记录。差值超过 5MB 而且随循环次数线性增长,基本可以定性了。这里的关键是"线性"两个字,缓存预热通常是一条快速上升然后走平的曲线,泄漏是一条斜率固定的斜线。
第二个数字是某个具体类型的实例数量差值。这个必须靠快照对比才能拿到,后面第 3 节会详细讲。我自己的经验阈值是:跑 100 次业务,如果某个业务对象的实例数净增超过 100 个(也就是每次都不回收),那就不是缓存了,是泄漏。因为正常缓存不会在同一个 key 上反复产生新实例。
第三个数字是 GC 的 Gen2 回收次数。如果跑 200 次业务触发了 300 次 Gen2 回收,堆却还在涨,说明这些对象是根可达的,GC 想收也收不了。反过来,如果 Gen2 回收次数很少但堆在涨,也可能只是回收没触发,这时候手动 GC.Collect() 再观察一次就行。这个数据用 dotnet-counters 看 gen-2-gc-count 最方便。
提示:在排查之前一定要关闭 VS 的"编辑并继续"和任何动态插桩功能,这些会让托管堆的形态发生偏移,快照对比容易出现假阳性。
2. 工具选型:VS2022 自带的那把刀够不够用
工具选型这件事上我踩过不少坑。早年用第三方 Profiler,功能确实全,但附加到工控机上的长跑进程时,经常因为版本不匹配或者符号加载失败直接卡死目标进程,现场是不允许重启的。后来我把策略改成了"默认用自带工具,只有自带工具搞不定时才上第三方"。VS2022 自带的内存工具覆盖了 80% 的场景,而且因为它和调试器是一套符号体系,附加到进程时稳定性明显更好。
VS2022 的内存诊断能力分三块:调试时自动开启的诊断工具窗口、性能探查器里的内存使用率工具、以及命令行下的 dotnet 系列工具。这三块不是替代关系,是不同阶段用的。前者用于快速判断和交互式排查,中者用于给出分配调用堆栈,后者用于生产环境或者不方便挂调试器的场景。
2.1 诊断工具窗口和性能探查器怎么选
诊断工具窗口(快捷键 Ctrl+Alt+F2)是调试启动后自动出现的那个小窗,里面有"内存使用率"勾选框。它的优点是零成本,你按 F5 启动调试,勾上就能用;缺点是它默认只采样,不跟踪每个对象的分配位置。适合的场景是:你已经知道大概哪段代码有问题,想快速确认某个操作前后对象数量的变化。
性能探查器(菜单:调试 -> 性能探查器,或 Alt+F2)里的内存使用率工具是另一套逻辑。它需要在启动前选择目标,然后它会记录完整的内存分配事件,可以给出每个类型是从哪个方法分配出来的调用堆栈。代价是性能开销大,通常会让程序慢 2 到 10 倍,所以不要在有实时性要求的场景下用。但排查阶段慢一点没关系,能定位到方法名才是关键。
我自己的用法是:先在诊断工具窗口里拍快照,确认有泄漏并且锁定到具体类型;然后重新用性能探查器跑一遍,专门看这个类型的分配调用堆栈,直接定位到代码行。两步走比一上来就开性能探查器要省事得多。
2.2 命令行三件套:counters、gcdump、dump
现场排查的时候,很多情况是不能挂 VS 调试器的——机器上没有 VS、进程是 Windows 服务、或者你不希望暂停进程。这时候命令行工具就是唯一选择。
dotnet-counters 是实时监控的,它不做快照,只打点。用法:
dotnet-counters monitor --process-id 12345 --counters System.Runtime输出里重点看四行:
System.Runtime cpu-usage (%) gc-heap-size (MB) gen-2-gc-count working-set (MB)把 gc-heap-size 和 gen-2-gc-count 两条曲线放一起看,如果 Gen2 回收之后堆大小回落幅度很小,而业务还在持续跑,那就是典型的根可达问题。这个工具开销极低,可以长期挂着,甚至可以在生产环境跑一整天看趋势。
dotnet-gcdump 是采集托管堆图的,体积小(通常几 MB 到几十 MB),只包含托管对象和引用关系,不含原生内存。用法:
dotnet-gcdump collect -p 12345采下来是个 .gcdump 文件,可以直接在 VS2022 里打开,做法是文件 -> 打开 -> 文件,选中它,VS 会以只读的堆视图展示。这个能力很多人不知道,其实非常好用,因为它不需要你在现场安装完整的调试环境。
dotnet-dump 是采集完整转储的,体积大(可能几百 MB 到几 GB),但信息最全,包含原生堆、线程栈、句柄。用法:
dotnet-dump collect -p 12345 dotnet-dump analyze core_20250101_120000.dmp进入分析会话后就是 SOS 命令的世界,后面 3.4 节会讲具体命令。
2.3 什么时候该掏 PerfView 和第三方工具
PerfView 我一般在这几种情况下用:需要看 GC 的详细事件(比如 LOH 分配、Gen2 压缩、GC 暂停时间分布),或者需要跨多个进程对比,或者需要把内存数据和 CPU 数据放一起看。它的 GC Heap Net Mem 视图和 Take Heap Snapshot 的差异对比,在处理 LOH 碎片化问题上比 VS 更清楚。缺点是界面反人类,第一次用基本找不到东西。
第三方工具里我用得比较多的是 dotMemory 和 SciTech 的 .NET Memory Profiler。它们的优势是快照对比做得更细,比如能按"分配调用堆栈"分组、能自动识别常见的泄漏模式、能给出"这个对象为什么还活着"的自然语言解释。代价是要钱,而且在某些受限环境里附加会失败。我的建议是:如果团队里有预算并且经常做性能优化,值得买;如果只是偶尔排查一次,自带工具加 dotnet-dump 完全够。
注意:不要在生产环境用需要暂停进程的 Profiler。dotnet-gcdump 默认会短暂暂停进程(通常在几百毫秒内),dotnet-counters 则完全不影响运行。选工具之前先想清楚"能不能暂停"。
3. 一次完整的抓漏实操:从快照到根路径
这一节我把整个流程按真实顺序走一遍。假设场景是一个 C# 上位机程序,负责从若干台设备采集温度数据并刷新界面,跑一晚上之后内存涨到 2GB 然后崩掉。这种场景在工控、MES、检测设备里极其常见,套路也基本通用。
3.1 复现脚本的设计决定后面所有事
很多人排查失败不是因为工具不会用,是因为复现没做好。我见过有人一边点界面一边等,十分钟才做三遍操作,拍出来的快照全是噪声。正确的做法是先写一个能自动跑 N 遍的复现脚本,把业务循环剥离出来。
以上位机为例,我会在程序里加一段临时代码:
// 仅用于排查,发布前必须删除 private async void btnLeakTest_Click(object sender, EventArgs e) { // 先做一次全量回收,把之前的垃圾清干净,作为基线 GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); for (int i = 0; i < 100; i++) { await _deviceManager.PollOnceAsync(); // 采集一轮 _mainForm.RefreshDashboard(); // 刷新界面 await Task.Delay(50); // 留出渲染时间 } GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); }这里有两个细节很关键。第一,循环前的强制回收必须做,否则你拍的第一张快照里包含了之前所有的历史垃圾,基线就废了。第二,循环里要留出界面渲染时间,因为 WPF 和 WinForms 的部分泄漏是发生在渲染管线里的,跑得太快反而不复现。我一般用 50 到 100 毫秒的间隔。
复现脚本跑完之后不要急着拍快照,先等两三秒,让 UI 线程把排队的渲染任务处理完,否则会有大量中间状态对象被算进快照里。
3.2 拍两张快照,重点看哪几列
VS 里拍快照的两种入口:调试状态下在诊断工具窗口点"拍摄快照",或者用性能探查器启动后点"拍摄快照"。两者拍出来的东西一样,但性能探查器那个能额外记录分配堆栈。
流程是这样:启动调试,等程序完全初始化完毕,拍第一张快照,我习惯给它改名叫"基线"。然后点复现按钮,等循环跑完,再拍第二张快照,叫"复现后"。然后在快照列表里选中第二张,点"对象类型"表,把视图切到"差异"。
这时候表格里会出现几列,我逐个说清楚含义:
| 列名 | 含义 | 排查时怎么用 |
|---|---|---|
| 计数 | 当前快照里该类型的实例总数 | 绝对值大不代表有问题,缓存本来就会有几万个 |
| 计数差异 | 相对上一张快照的实例数变化 | 核心指标,正增长且接近循环次数就是嫌疑对象 |
| 大小 | 该类型所有实例占用的字节数 | 判断影响面,字节数组要看这个 |
| 大小差异 | 相对上一张快照的字节变化 | 用于算"每次循环泄漏多少字节" |
| 非独占大小 | 包含它引用的所有对象 | 判断泄漏子树有多大 |
排序我一般按"计数差异"降序。重点看两类:一是差异值接近 100 的(正好等于循环次数),二是差异值为 0 但"大小差异"很大的。前者说明每轮都新建一个不回收,后者说明某几个大对象在原地膨胀,比如一个 Dictionary 或 List 在不断 Add。
这里有个坑:很多类型都会出现"计数差异为正",比如各种反射缓存、JIT 产生的类型、字符串字面量。判断的关键是看这个类型是不是你自己代码里的业务类型,以及它持有的大小。泛型集合如 List 、Dictionary<K,V> 会显示为具体实例化类型,比如 List ,这种一眼就能看出来。
3.3 Paths to Root:把引用链读到最上面一层
锁定嫌疑类型之后,在类型上右键选"查看实例",会列出该类型的所有活着的实例。随便挑一个,右键选"根路径"(Paths to Root),VS 会给你一棵树,展示从 GC 根(静态字段、线程栈、GC 句柄、终结器队列)到这个对象的完整引用链。
这棵树怎么读?从叶往上看,每一层都会标出字段名或者集合元素索引。你要找的是那条"不应该存在"的路径。比如:
[Static] AppEvents.OrderSaved → EventHandler._invokeList → OrderView.OnOrderSaved (target) → OrderView看到这条链基本就定性了:一个静态事件 AppEvents.OrderSaved 的委托链里挂着 OrderView 实例。静态字段是 GC 根,它的生命周期等于整个 AppDomain,只要不退订,OrderView 永远不会被回收。每次打开界面新建一个 OrderView,就多挂一个,这就是典型的静态事件泄漏。
再举一个更隐蔽的:
[Finalizer Queue] TimerHolder → TimerQueueTimer._timer → Heartbeat.Send (target) → Heartbeat终结器队列也是 GC 根。System.Threading.Timer 在没有 Dispose 的情况下,会被 TimerQueue 内部的静态链表持有,回调委托指向你的实例方法,于是你的整个对象图都被拖住了。这条链特别容易被忽略,因为 Timer 对象本身很小,你在类型列表里根本不会注意到它。
我通常会在根路径树里做一件事:把每一条路径复制到记事本里,然后把它们分组。如果十条路径里有八条都经过同一个字段,那这个字段就是主犯;如果分散在七八个不同字段,说明是"多点泄漏",得逐个改。
3.4 命令行下用 SOS 追踪:dumpheap 加 gcroot
没有 VS 的时候就得靠 dotnet-dump。完整流程:
# 采集 dotnet-dump collect -p 12345 -o /tmp/core.dmp # 进入分析会话 dotnet-dump analyze /tmp/core.dmp进来之后先加载 SOS(.NET Core 的 dotnet-dump 会自动加载),然后跑统计:
> dumpheap -stat Statistics: MT Count TotalSize Class Name 00007ff9... 1 1234 98765 System.Byte[] 00007ff9... 2 5432 456789 MyApp.DeviceReading 00007ff9... 3 981 123456 System.Collections.Generic.List<MyApp.DeviceReading>dumpheap -stat 给出的是按类型聚合的结果,按 TotalSize 排序。但统计单独的 dump 有个问题:你只有一张,没法做差值。所以我在生产环境排查时习惯采两张 dump,一张是稳态运行一段时间后,一张是再跑 N 轮之后,然后在两次分析会话里手工对比 Count 列。
锁定类型之后,拿方法表地址去列实例:
> dumpheap -mt 00007ff9ABCD1234 -min 1000-mt 后面跟的是上一步查到的 Method Table 地址,-min 1000 表示只列大于 1000 字节的实例,避免刷屏。输出里每一行是一个对象地址,随便挑一个:
> gcroot 0000020a12345678gcroot 会打印出所有到达这个对象的根路径,格式和 VS 里那棵树差不多:
Found 1 unique root(s): 00007ff9abcd0000 MyApp.AppEvents static var OrderSaved ...要判断对象本身多大,用 objsize:
> objsize 0000020a12345678要看字段内容,用 dumpobj,能看到各个字段的当前值,这在确认"这个集合里到底存了什么 key"的时候特别有用:
> dumpobj 0000020a12345678我一般会 dumpobj 一个 Dictionary 实例,看看里面的 key 是不是带时间戳或者 GUID,这一招几乎能瞬间确认"无界缓存"型泄漏。
提示:dotnet-dump 采集的完整转储可能非常大,工控机磁盘空间紧张的话先用 dotnet-gcdump,确认是托管问题再采完整 dump。
4. 八种高频泄漏模式,逐个拆开看
工具会用了,接下来是"见过才能认出来"。我在实际项目里遇到的泄漏,八成以上能归到下面这八类里。每一类我都会给一段反例代码和对应的修法,代码都比较短,但你可以在自己的项目里搜相似结构。
4.1 事件订阅没退订
这是头号杀手,尤其是跨模块通信和静态事件。看代码:
public sealed class PriceFeed { public event EventHandler<decimal>? PriceChanged; public void Publish(decimal price) => PriceChanged?.Invoke(this, price); } public sealed class PricePanel { private readonly PriceFeed _feed; private readonly byte[] _renderBuffer = new byte[1024 * 200]; public PricePanel(PriceFeed feed) { _feed = feed; _feed.PriceChanged += OnPriceChanged; // 订阅了,但从来没退 } private void OnPriceChanged(object? sender, decimal price) { // 刷新界面 } }PriceFeed 在这里是长生命周期的(通常是单例或者 Module 级对象),它的 PriceChanged 委托链里持有 PricePanel 的实例方法引用,而委托是强引用。每次新建 PricePanel 就在链上多挂一个,旧的永远不回收,连带着 200KB 的 _renderBuffer 一起泄漏。
修法有三种。最直接的是实现 IDisposable 并在销毁时退订:
public sealed class PricePanel : IDisposable { private readonly PriceFeed _feed; public PricePanel(PriceFeed feed) { _feed = feed; _feed.PriceChanged += OnPriceChanged; } private void OnPriceChanged(object? sender, decimal price) { } public void Dispose() => _feed.PriceChanged -= OnPriceChanged; }第二种是发布端主动用弱引用。WPF 里的 WeakEventManager 就是干这个的:
WeakEventManager<PriceFeed, decimal>.AddHandler( _feed, nameof(PriceFeed.PriceChanged), OnPriceChanged);第三种是改架构,让发布者和订阅者一起生一起死,不搞跨生命周期的订阅。我个人偏好第一种,因为它显式、可控,review 的时候一眼能看出来;弱事件虽然优雅,但会让"为什么我的回调没执行"变成新的排查难题。
4.2 静态集合当缓存用,没有上限也没有过期
public static class SnapshotCache { private static readonly ConcurrentDictionary<string, byte[]> _cache = new(); public static byte[] Get(string key) => _cache.GetOrAdd(key, k => LoadFromDisk(k)); }这段代码本身写得挺漂亮,问题在调用方:
var key = $"{deviceId}_{DateTime.Now:yyyyMMddHHmmssfff}"; var data = SnapshotCache.Get(key);key 里带了毫秒级时间戳,每次调用都是一个新 key,字典无限增长。而且因为 ConcurrentDictionary 是静态的,它是 GC 根,里面所有 byte[] 全部根可达。这种泄漏增长曲线非常"标准"——斜率完全线性,跑多久涨多久。
修法不是简单加个 Remove,而是要给缓存加上限和过期策略。简单场景可以用 Microsoft.Extensions.Caching.Memory 里的 MemoryCache:
private static readonly MemoryCache _cache = new(new MemoryCacheOptions { SizeLimit = 512, // 最多 512 个"单位" CompactionPercentage = 0.25 // 满了之后压缩 25% }); public static byte[] Get(string key) { return _cache.GetOrCreate(key, entry => { entry.Size = 1; entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10); return LoadFromDisk(key); })!; }注意:MemoryCache 的 SizeLimit 配了但 entry.Size 不设的话,限制不会生效,这个坑我踩过。另外 ConcurrencyLimit 默认是 CPU 核数,工控机上核数少的话记得调。
4.3 Timer 忘了 Dispose,对象跟着一起挂
public sealed class Heartbeat { private readonly System.Threading.Timer _timer; public Heartbeat() { _timer = new System.Threading.Timer(_ => Send(), null, 0, 5000); } private void Send() { } }System.Threading.Timer 的内部实现是把自己挂到 TimerQueue 的静态链表上,只要没 Dispose,这个 Timer 就一直是根可达的。更麻烦的是回调委托_ => Send()捕获了 this,所以整个 Heartbeat 实例也被拖住。如果 Heartbeat 里还持有设备连接对象,那连接对象的 Socket 也不会释放。
正确写法就是老老实实实现 IDisposable:
public sealed class Heartbeat : IDisposable { private readonly System.Threading.Timer _timer; private int _disposed; public Heartbeat() { _timer = new System.Threading.Timer( _ => Send(), null, TimeSpan.Zero, TimeSpan.FromSeconds(5)); } private void Send() { if (Volatile.Read(ref _disposed) == 1) return; } public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 1) return; _timer.Dispose(); } }这里加了 _disposed 检查,是因为 Timer.Dispose() 不会中断已经在执行的回调,如果不检查,回调可能在对象已经被释放之后还在跑,去访问已经关闭的连接,抛 ObjectDisposedException。这个坑在排查阶段很难看出来,因为异常被吞在一个空 catch 里。
同类的还有 System.Timers.Timer,它多了一个 AutoReset 和事件订阅,泄漏路径类似但它还会额外持有订阅者。DispatcherTimer 是另一个典型,WPF 里没 Stop 的 DispatcherTimer 会被 Dispatcher 的定时器列表持有,而且它是 UI 线程上的,泄漏起来连界面都会被拖慢。
4.4 异步状态机与闭包捕获
public void StartMonitor() { var bigBuffer = new byte[1024 * 1024 * 50]; // 50MB _ = Task.Run(async () => { while (true) { await Task.Delay(1000); Console.WriteLine(bigBuffer.Length); } }); }这段代码的问题在于:闭包会把 bigBuffer 提升为编译器生成的状态机的字段,而这个状态机挂在一个永不完成的 Task 上,Task 又被线程池的 continuation 链和定时器队列引用着。结果就是 50MB 的数组跟着这个死循环一起活着,而且没有任何办法能停掉它。
这类泄漏的识别特征是:快照里出现很多名字带<>c__DisplayClass或者d__的编译器生成类型。这些名字一眼就能认出来是闭包和状态机,看到它们数量飙升就要警惕。
修法其实很简单,用 CancellationToken 把生命周期管起来:
public sealed class Monitor : IDisposable { private readonly CancellationTokenSource _cts = new(); public void StartMonitor() { var buffer = new byte[1024 * 1024 * 50]; var token = _cts.Token; _ = Task.Run(async () => { while (!token.IsCancellationRequested) { await Task.Delay(1000, token).ConfigureAwait(false); Console.WriteLine(buffer.Length); } }, token); } public void Dispose() => _cts.Cancel(); }另一个高频场景是 async void。async void 方法抛出的异常无法被捕获,而且调用方拿不到 Task,也就没法知道它什么时候结束。如果里面挂着一个长循环,同样会泄漏。我现在的原则是:除了事件处理器,业务代码一律不用 async void。
4.5 WPF 和 WinForms 里的绑定与静态事件
WPF 的泄漏有几个独有的来源。第一个是绑定到不实现 INotifyPropertyChanged 的普通对象。老版本 .NET Framework 下,WPF 的绑定引擎会通过 PropertyDescriptor 建立对源对象的强引用,导致源对象被 Binding 持有。.NET 4.5 之后对实现了 INPC 的源改用了弱引用,但如果你的 ViewModel 是 POCO 且绑定用了完全限定路径,还是可能出问题。我现在的习惯是所有 ViewModel 一律实现 INotifyPropertyChanged,不给自己留隐患。
第二个是静态事件和全局消息总线:
public partial class OrderView : UserControl { public OrderView(OrderViewModel vm) { InitializeComponent(); DataContext = vm; AppEvents.OrderSaved += OnOrderSaved; // AppEvents.OrderSaved 是 static event } private void OnOrderSaved(object? sender, OrderSavedEventArgs e) { } }每次导航到这个页面就新建一个 OrderView,静态事件上就多挂一个。修法是在 Unloaded 事件里退订,或者改用弱事件。WinForms 里对应的是各种静态事件,比如 Application.Idle、SystemEvents.UserPreferenceChanged,这些特别阴,因为它们藏在框架内部,你看自己的代码根本看不出来。
第三个是 WPF 的容器虚拟化。ItemsControl 如果没有开启 VirtualizingStackPanel,或者外层套了 ScrollViewer 破坏了虚拟化,一千条数据就会生成一千个容器。虽然这些容器在你滚动离开视野后理论上可以回收,但如果数据模板里有绑定到静态资源的转换器,或者用了自定义的附加行为,它们就可能被静态引用拖住。判断方法是看快照里 ContentPresenter 和你的数据模板根元素的数量。
4.6 HttpClient 与 IDisposable 资源的正确姿势
// 反例一:每次都 new,socket 耗尽 public async Task<string> GetAsync(string url) { using var client = new HttpClient(); return await client.GetStringAsync(url); } // 反例二:单例但从来不用,配置不刷新 private static readonly HttpClient _client = new HttpClient();第一种写法会导致 TIME_WAIT 状态的 socket 大量堆积,虽然严格说不是内存泄漏,但在上位机里表现是一模一样的——跑久了就连接不上,句柄数暴涨。第二种写法没泄漏但失去了 DNS 刷新能力,服务端切 IP 之后你会一直连旧地址。
正确姿势是用 IHttpClientFactory,或者至少做一个带过期时间的单例管理器:
services.AddHttpClient("device", c => { c.Timeout = TimeSpan.FromSeconds(10); c.DefaultRequestHeaders.ConnectionClose = false; }) .SetHandlerLifetime(TimeSpan.FromMinutes(5)); // 5 分钟轮换一次 handler除了 HttpClient,其他 IDisposable 资源也要注意。我见过最多的三个是:FileStream 在异常路径上没走到 Dispose(用 using 就不会有这个问题)、SerialPort 没 Close 导致端口被占、SqlConnection 在连接池满的时候被误判为泄漏。这里有个通用的判断方法:在快照里看类型名,如果出现大量 XXXStream、SafeHandle 子类、Connection 子类的实例,优先怀疑资源没释放。
4.7 非托管句柄与 GDI 对象的排查
当托管堆快照显示一切正常但内存还在涨,就该换赛道了。先看句柄数:任务管理器 -> 详细信息 -> 右键列头 -> 选择列 -> 勾上"句柄"和"GDI 对象"。跑 N 轮业务,句柄数从 800 涨到 5000,那就是句柄泄漏。
GDI 对象泄漏的经典代码:
private void UpdatePreview(string path) { var bmp = new Bitmap(path); pictureBox.Image = bmp; // 上一张图从来没 Dispose }每次调用都新建 Bitmap,赋给 PictureBox 之后旧的 Bitmap 引用被覆盖,理论上会被 GC 回收,但 Bitmap 的 Dispose 会释放底层的 GDI+ 句柄,而 GC 回收托管对象后还要等终结器线程跑 Finalize,终结器队列一堆积,GDI 句柄就下不去。修法:
private void UpdatePreview(string path) { var old = pictureBox.Image; pictureBox.Image = new Bitmap(path); old?.Dispose(); }GDI 对象的具体类型分布,用 GDIView 能看到 Bitmap、Pen、Brush、Font 各占多少。这个工具很小,下载解压就能跑,比写代码排查快多了。
非托管内存还有一种情况:C++/CLI 或者 P/Invoke 里的手动分配。这方面 VS2022 提供了原生内存视图(需要 .NET 5 及以上),可以在诊断工具里勾选"内存使用率"后切到"原生内存"标签,能看到原生分配的调用堆栈。这个功能挺新的,处理混合模式程序特别有用。
4.8 被冤枉的 LOH 碎片化
有一种情况是"看起来像泄漏,其实是碎片"。大于 85000 字节的对象走 LOH(大对象堆),LOH 默认不压缩,只在 Gen2 回收时做标记清除。如果你反复分配和释放大小接近但不完全相同的大数组,LOH 上会留下大量空洞,虽然存活对象不多,但堆的提交大小一直很高。
判断方法是在性能探查器的内存工具里看 LOH 的碎片率,或者在 dumpheap -stat 里看 Free 对象的 TotalSize。如果 System.Free 类型的 TotalSize 很大而 Count 不多,那就是碎片。修法有两条:一是把大对象拆成小块(比如把 50MB 的数组改成 5 个 10MB 的),二是显式触发 LOH 压缩:
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; GC.Collect();注意:LOH 压缩会暂停所有线程,几百 MB 的 LOH 压缩可能要一两秒。在实时性要求高的场景里,只能作为应急手段,不能定时调用。
5. 容易误判的几种情况与排查速查表
排查过程中最容易出的问题不是找不到原因,而是找错原因。下面几种情况我都实际遇到过,写出来供参考。
5.1 GC 没跑不代表泄漏
第一个常见误判:只看任务管理器的内存数字,看到涨就以为泄漏。实际上 GC 的回收时机是运行时根据分配速率动态决定的,如果一个程序分配得很少,GC 可能几分钟才跑一次,这段时间内内存当然在涨。判断标准必须是"强制 Full GC 之后不降"。
第二个误判:.NET Core 的 Server GC 模式。Server GC 会为每个 CPU 核分配独立的堆段,而且默认的段大小是 64MB 到几 GB 不等(取决于配置),所以一个 8 核机器上,进程的提交大小天然就会比 Workstation GC 高出几百 MB。这不是泄漏,是设计使然。要不要关掉 Server GC,取决于你的程序是吞吐优先还是内存优先。上位机一般建议用 Workstation GC,可以在 csproj 里配:
<PropertyGroup> <ServerGarbageCollection>false</ServerGarbageCollection> <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection> </PropertyGroup>第三个误判:把缓存预热当成泄漏。第一张快照是在预热前拍的,第二张是在预热后拍的,中间跑了一次业务循环,结果所有缓存相关的类型都显示正增长。避免方法就是我在 3.1 节强调的:跑业务循环之前先做一轮完整的预热,把缓存填满,再拍基线。
5.2 常见问题速查表
| 现象 | 可能原因 | 优先使用的工具 | 定位要点 |
|---|---|---|---|
| GC 堆强回收后仍持续上涨 | 托管对象被根引用 | VS 快照对比 + 根路径 | 看计数差异接近循环次数的类型 |
| 提交大小涨但 GC 堆稳定 | 非托管资源或 LOH 碎片 | 任务管理器 + GDIView | 看句柄数和 GDI 对象数 |
| 句柄数持续增长 | 文件/串口/Socket 未释放 | dotnet-dump | 搜 SafeHandle 系列的实例数 |
| GDI 对象数增长 | Bitmap/Pen/Brush 未释放 | GDIView | 按类型看分布 |
| 快照里出现大量编译器生成类型 | 闭包或状态机泄漏 | VS 快照对比 | 搜<>c__DisplayClass和d__ |
| 静态字段引用链始终存在 | 静态事件或静态集合 | 根路径视图 | 看 Static 类型的根 |
| 终结器队列里有大量对象 | IDisposable 未及时释放 | dotnet-dump | 看 Finalizer Queue 根 |
| LOH 提交大但存活对象少 | 大对象堆碎片 | PerfView | 看 Free 对象的占比 |
| 界面卡顿且内存缓升 | WPF 绑定或容器泄漏 | VS 内存工具 | 看 ContentPresenter 数量 |
5.3 我自己用的几条排查习惯
第一条习惯:永远先改动最少的那个方案。比如怀疑是事件泄漏,先在卸载事件里加一行退订,跑 100 遍验证;不要一上来就重构整个通信架构。我见过太多人花了三天做完重构,结果问题还在,因为根因在另一个地方。
第二条习惯:每次只验证一个假设。如果内存曲线下降了,你要能说清楚是哪一行代码改的。如果同时改了五处,曲线降了你也不知道哪处有用,下次遇到同样问题还是不会。
第三条习惯:给关键路径加上对象计数的探针。比如在设备管理器的构造函数里加Interlocked.Increment(ref _instanceCount),析构或者 Dispose 里减一,然后用性能计数器或者定时日志打出来。这个方法土,但在不能挂调试器的现场特别有用,而且它能给你一个长期的监控指标,下次出问题你能第一时间发现。
第四条习惯:把复现脚本留成一个隐藏的调试入口,发布前用条件编译排除:
#if DEBUG // 泄漏复现脚本 #endif这样下次出问题不用重新写,直接打开就能跑。
最后分享一个我踩过最深的坑。有一次排查一个上位机程序的内存泄漏,快照对比显示 DeviceReading 对象每次采集都净增一个,根路径指向一个叫_latest的字段。我改了半天,最后发现那个字段是用来给界面显示"最新一条数据"的,本身设计就是要留一个,问题出在另一处:每次采集都在一个静态的 ObservableCollection 上 Add,而界面绑定没有用虚拟化。真正的泄漏源是那个集合,_latest只是恰好也指向了最新那条数据,误导了我两个小时。这件事之后我养成了一个习惯:根路径树至少要读三条,如果三条都指向同一个集合,那才是真凶;只有一条指向某个字段,很可能是巧合。