本文目录
-
- 前言
- 1. GC 管什么?
-
- 1.1 new 一个对象时,背后发生了什么?
- 1.2 时机是什么时候?
- 1.3 晋升机制:Gen0 怎么变到 Gen2?
- 2. 为什么相机资源 GC 管不到
-
- 2.1 我就一个相机,不做这些行不行?
- 3. 泄漏了怎么办?
- 4. 总结
原创声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载需经博主本人同意,并附上原文出处链接。
前言
很多程序员都有过这个疑问:.NET 有自己的 GC 内存回收机制,new 出来的东西不用管,GC 会自动收走。那为什么相机(海康、Basler、LMI……)却要老老实实写 Dispose?不写会怎样?
直接给结论当然简单——“因为内存分托管和非托管”。但如果只记住这句结论,下次换个场景你还是会踩坑。这篇文章我们把底层的账算清楚,讲明白三件事:
1. GC 管什么?
以 .NET 8 为例(其余版本大结构都相似),先定位 GC 的位置:.NET 框架由运行时层、基础类库(BCL)、应用框架(WinForms/WPF/ASP.NET Core 等)组成,而我们说的托管内存,就归运行时层(CoreCLR)里的 GC 管。
1.1 new 一个对象时,背后发生了什么?
当我们 new 一个对象时,CLR 会在托管堆上为其分配一块内存,并返回对象的引用。过程大致如下:
由于托管堆采用顺序分配(Bump Pointer)策略,new 的速度非常快,远快于 C/C++ 查找空闲内存块的过程。但分配的内存不会立即释放,而是由 GC 在合适的时机统一回收。
1.2 时机是什么时候?
GC 的主要触发时机:
- 第 0 代空间不足:分配新对象时 Gen0 装不下,触发回收——最常见的方式;
GC 采用分代回收策略:
- Gen 0:短期对象(方法内的临时变量),回收最频繁、代价最小;
- Gen 1:从 Gen0 晋升来的对象,作缓冲;
- Gen 2:长期存活对象(静态字段、缓存),回收代价最大。
- 系统内存压力大:操作系统报告物理内存紧张时,CLR 主动触发 GC;
- 显式调用 GC.Collect():可以手动请求,但生产环境强烈不建议——它会打乱 GC 的自适应节奏,反而可能降低性能。
1.3 晋升机制:Gen0 怎么变到 Gen2?
基本规则:一次回收中存活下来的对象,晋升到下一代。Gen0 → Gen1 → Gen2,Gen2 是终点,不再晋升。
晋升的本质是用空间换时间:大多数对象"朝生暮死",在 Gen0 就被回收,根本来不及晋升;只有少数长寿对象(静态字段、缓存、单例)才会一路升到 Gen2。这样 GC 每次只需重点处理回收收益最大的 Gen0,不用每次扫全堆,成本大幅降低。
// 演示晋升机制(仅作演示,实际项目不要手动调 GC.Collect)
var obj = new object();
Console.WriteLine(GC.GetGeneration(obj)); // 0
GC.Collect();
Console.WriteLine(GC.GetGeneration(obj)); // 1
GC.Collect();
Console.WriteLine(GC.GetGeneration(obj)); // 2
要警惕的是:如果大量对象被意外地长期持有(事件未退订、静态集合不断膨胀),它们会不断晋升到 Gen2,Gen2 越大回收越慢,每次回收停顿越长。这就是很多桌面软件越跑越卡的原因之一。解法是从源头减少对象的意外长期持有,让它们尽早被回收。
2. 为什么相机资源 GC 管不到
先看一张 .NET 进程的内存分区图:

关键看右侧:非托管内存。以海康相机为例,海康 SDK 是跑在 Windows 上的原生 C++ 代码(native DLL),它创建的相机句柄和图像缓冲区分配在非托管内存里。CLR 对这些资源只持有一个 IntPtr——一个"地址牌",GC 的可达性追踪到 IntPtr 就断了:
- 看不见:GC 没有那块内存的记账信息,不知道它的结构、大小、生命周期;
- 管不了:就算知道,GC 也没有释放权限,释放函数在 SDK 手里。
所以非托管资源的释放,必须由我们显式调用 SDK 提供的配对销毁函数。而 .NET 为"需要手动归还的资源"定了一份标准合同——IDisposable 接口:
public interface IDisposable
{
void Dispose(); // 合同规定:必须有一个名叫 Dispose、无参数的方法
}
public class HikCamera : IDisposable
{
private IntPtr _handle; // SDK 句柄 → 指向非托管资源的"地址牌"
private bool _disposed;
public void Dispose()
{
if (_disposed) return;
// 关流、销毁句柄——把资源真正还给 SDK 和操作系统
SDK.MV_CC_StopGrabbing(_handle);
SDK.MV_CC_DestroyHandle(_handle);
_handle = IntPtr.Zero;
_disposed = true;
}
}
使用时套上 using:
using (var cam = new HikCamera())
{
cam.Open();
cam.Grab();
} // ← 离开作用域,Dispose 自动被调用
2.1 我就一个相机,不做这些行不行?
有人会说:我就一个相机,占不了多大内存,不想搞这些。可以,但有前提——你能保证相机打开后永不关闭吗?能保证只拍一张图吗?
做不到的话,每次开启/采集都占一份不归还的资源,这就是内存泄漏。泄漏的后果是内存持续增长,最终内存爆炸——不是硬件炸,是进程内存耗尽,软件卡死、崩溃。
而且不止泄漏这一击:就算不泄漏,每帧 new 大图像(Bitmap/Mat 动辄几 MB)会直接进 LOH(大对象堆,≥85KB),分配速率一高,GC 频繁启动,而 GC 工作时要冻结你的所有线程——25fps 一帧预算才 40ms,GC 卡你几十毫秒,这一帧就丢了。现场表现就是:偶发掉帧、图像卡一下、难复现。
所以图像处理的经典优化是复用缓冲区:启动时分配一次,每帧往同一个缓冲区覆盖写,别每帧 new。
3. 泄漏了怎么办?
应急:重启软件就行,不用重启电脑。因为操作系统保证:进程一终止,它名下的所有内存——不管托管非托管、不管泄没泄漏——全部强制归还。当然这只是止血,跑一天还会漏,根治得修代码。
排查泄漏在哪,两步走:
分流:用 VS 自带诊断工具(调试 → 窗口 → 显示诊断工具)看托管堆。托管堆涨 → 问题在 C# 代码(静态集合、事件没退订、Bitmap 没 Dispose);托管堆平稳但总内存涨 → 大概率非托管在漏——相机资源没释放。
操作关联(现场没有工具时最实用):把"内存涨速"和"操作"对上号:
- 点"连接相机"涨一截 → 泄漏在打开/关闭路径(大概率句柄没 Destroy);
- 采集时匀速涨,涨速 ≈ 帧率 × 帧大小 → 泄漏在取流路径(缓冲区没归还);
- 停止采集后不降 → 确认没有释放逻辑。
对上号之后,去对应代码段补 Dispose / SDK 的释放调用即可。
4. 总结
| GC | 托管堆内存回收 | 自动,堆不够时 |
| Dispose / using | 非托管资源归还(句柄、缓冲区、文件…) | 你,用 using 保证必调 |
这张表就是全文的核心结论,值得再展开说透:
GC 管的是托管堆。你在 C# 里 new 出来的普通对象(字符串、集合、业务实体),内存都分配在托管堆上,由 GC 自动追踪、自动回收。你不需要、也不应该手动去释放它们——GC 会在 Gen0 空间不足、系统内存紧张等时机自动出手,把不可达的对象清掉。它的优点是省心,缺点是不可控:你无法精确指定"此刻立刻回收某个对象",也无法保证它回收的时机。
Dispose / using 管的是非托管资源。相机句柄、图像缓冲区、文件句柄、数据库连接、GDI 对象……这些资源要么分配在非托管内存里(GC 看不见),要么由操作系统内核管理(GC 没有释放权限)。它们必须由你显式归还,而 IDisposable + using 就是 .NET 给这套"手动归还"定下的标准合同——using 保证无论代码是否抛异常,离开作用域时 Dispose() 一定被调用。
一句话记住分工:托管内存交给 GC,非托管资源交给 using。写代码时养成一个习惯——凡是 new 了实现 IDisposable 的东西(相机、Bitmap、FileStream、SqlConnection),一律用 using 包起来或手动 Dispose(),别指望 GC 替你兜底。这样你的程序才能长期稳定运行,不会越跑越卡、最终内存爆炸。

