C# 有垃圾回收,很多人以为不会内存泄漏——错。托管世界里照样漏,而且漏法更隐蔽:对象还"活着",只是没有任何业务代码再需要它。上位机场景特殊在要 7×24 小时连续运行,办公软件里重启能解决的问题,在产线上就是停机事故。我们排查过的上位机内存问题,九成集中在五个源头,这篇逐个拆,并给出不用专业工具也能定位的手法。
先分清:是真泄漏,还是 GC 没干活?
任务管理器里内存只涨不降,先别急着重写代码。.NET 的 GC 是惰性的——分配压力不够时它不回收,工作集看起来一直涨。两个动作先排除假象:
- 在疑似泄漏的操作(如关闭一个监控窗口)之后,代码里手动触发
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();连调两次(终结器队列需要两轮),内存回落了说明不是泄漏,只是 GC 时机问题; - 用 perfmon 看"进程\专用字节数"和".NET CLR Memory\# Bytes in all Heaps"两条曲线:专用字节涨但托管堆不涨——是非托管泄漏(GDI、文件句柄);托管堆自己涨——才是托管对象泄漏。
源头一:事件订阅了没取消,排名第一
这是我们见过次数最多的泄漏,没有之一。典型代码:监控窗口里 plcClient.DataChanged += OnData,窗口关闭时没解绑。原理:事件发布者持有订阅者的委托引用,发布者活着,订阅者窗口就永远是 GC Root 可达对象。窗口关了、界面看不见,但它和它上面所有控件、绑定的数据全在内存里,每开关一次泄漏一个。
// 错误:只订阅不解绑,每开一次窗口漏一个 Form 及其全部控件 private void MonitorForm_Load(object s, EventArgs e) => _plc.DataChanged += OnDataChanged; // 正确:FormClosed 中对称解绑;或用弱事件模式 protected override void OnFormClosed(FormClosedEventArgs e) { _plc.DataChanged -= OnDataChanged; // -= 必须与 += 同一个方法引用 base.OnFormClosed(e); }
静态事件最危险:static event 的生命周期等于进程,订阅者永远无法被回收。排查口诀:全局搜 +=,每一处都要能在代码里找到对应的 -=。匿名方法/lambda 订阅基本无法解绑(每次生成新委托),禁止用于长生命周期发布者。
源头二:静态集合与单例缓存,只进不出
静态 List/Dictionary
static List<T> 里 Add 完从不 Remove,工单历史、报警快照、调试数据全往里塞,进程不关它不清。
单例服务持有窗体
通信单例为了"方便回调"把 Form 引用存进字段,窗口关了单例还活着,引用链不断。
缓存无上限
自己写的"缓存"Dictionary 只加不删。要么用 MemoryCache 设过期和容量,要么环形缓冲定长。
绑定的事件源不释放
BindingSource、消息总线(Messenger)注册后不反注册,MVVM/事件聚合器项目里的重灾区。
源头三:Timer 不停,回调链把对象拴住
System.Windows.Forms.Timer、System.Threading.Timer 都通过回调持有目标对象。窗口里 new 了 Timer 只管 Start,窗口关闭不 Stop/Dispose——定时器持续触发,窗体对象持续存活。规矩:Timer 实现 IDisposable,在容器/窗体的 Dispose 链里统一释放;窗体上拖放的 Timer 会随 components 容器自动释放,代码 new 的必须手动管。
源头四:Bitmap、GDI、串口/连接,非托管资源漏了任务管理器不报警
托管堆曲线不涨但进程内存/句柄数涨,往这一类查:
- Bitmap/Image:截图、存图、报表生成场景 new 大量位图不 Dispose。任务管理器"GDI 对象"列过万基本就是它,症状是画着画着报"内存不足"或"通用错误";
- Graphics/Pen/Brush:OnPaint 外面创建的绘图对象不释放;
- SerialPort/TcpClient/文件流:关闭窗口只隐藏不释放,端口一直被占;
- 第三方 SDK 句柄:相机、采集卡的取流 session 不注销(下一篇讲相机时会细说),这类泄漏连 GC 都帮不上忙。
// IDisposable 统一写法:using 或 try/finally,别赌 GC 的终结器 using (var bmp = new Bitmap(width, height)) using (var g = Graphics.FromImage(bmp)) { // ...绘图、保存 } // 出作用域立即释放非托管句柄,不等到终结
源头五:LOH 碎片与大对象高频分配
还有一种情况:没有对象"泄漏",但内存仍持续上涨甚至 OutOfMemory——大对象堆(LOH)碎片。大于等于 85000 字节的对象直接进 LOH,老版本 .NET Framework 里 LOH 不压缩,频繁申请/释放大块(每秒 new 一个大数组存波形、拼报文用不断扩容的 List<byte>)会把堆切成碎片:总空闲够,但没有连续空间。对策:
- 大缓冲池化复用:采集缓冲 ArrayPool<byte>.Shared 租借归还,波形环形缓冲只 new 一次;
- 拼包用 MemoryStream 并提前 SetCapacity,避免内部数组反复翻倍重分配;
- .NET 4.5.1+ 可在确认影响时开启 LOH 压缩(GC.Collect(LOHCompaction)),但这是应急手段,正道仍是减少大块分配。
定位手法:没有 dotMemory 也能查
现场工控机上不一定能装分析器,三招足够定位 80% 的问题:
- WeakReference 探针:怀疑某窗口泄漏,在关闭后保留它的弱引用,手动 GC 后查 IsAlive——还活着就是泄漏,零成本验证:
var weak = new WeakReference(form); form.Close(); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); var leaked = weak.IsAlive; - perfmon 计数器:私有字节、托管堆总字节、Gen2 堆大小、GDI 对象、句柄数,五条曲线跑一夜,涨势和谁对应就能定大方向;
- dotMemory/WinDbg 快照对比:能装工具时直接拍两张快照(操作前/操作后+GC),对比"新增对象"和"保留路径(retention path)"——谁持有引用一目了然,事件委托链在快照里会直接显示成 EventHandler 持有的 Form。
收尾:写进团队规范的四条军规
排查永远是被动的,这四条做成代码评审检查项,泄漏在写出来的当天就会被拦住:① 每个 += 必须有对称的 -=;② 代码 new 的 IDisposable 必须有归宿(using 或本类 Dispose);③ 静态集合必须有删除策略或容量上限;④ 大缓冲必须复用,禁止高频 new。上位机是要连着跑几个月的软件,内存问题没有"现场重启一下"这个选项——把泄漏挡在编码阶段,成本是上线后救火的百分之一。
