模块三 · 内存与数据
托管内存的边界:GC 管什么、不管什么
GC 只回收托管对象,不回收你忘记释放的资源。这一节讲清分代、LOH、GC 模式、终结器与常见泄漏,以及怎么用工具定位。
读完这一节,你能做到
- 说清托管堆的分代与 LOH 的回收特点。
- 区分工作站 GC 与服务器 GC 的适用场景。
- 说清 IDisposable、终结器与 SafeHandle 的分工。
- 用 dotnet-counters/dump 定位内存问题。
一、托管堆与分代
.NET 的 GC 是分代回收:
| 代 | 存放 | 回收频率 |
|---|---|---|
| Gen 0 | 新分配的小对象 | 最高 |
| Gen 1 | 从 Gen 0 存活下来的 | 中 |
| Gen 2 | 长期存活的对象 | 最低 |
| LOH | 大于 85KB 的对象 | 随 Gen 2 回收 |
分代假设是"大多数对象朝生夕死":频繁回收 Gen 0,成本低;Gen 2 回收成本高,但次数少。
LOH 默认不压缩,容易产生碎片;GCSettings.LargeObjectHeapCompactionMode 可以强制压缩,但代价高。
二、GC 模式
| 模式 | 特点 | 适用 |
|---|---|---|
| 工作站 GC | 单线程/并发,占用少 | 客户端、小服务 |
| 服务器 GC | 多线程并行,吞吐高 | 多核服务器 |
服务器 GC 会为每个核心分配堆与线程,内存占用更高;容器里要按 CPU limit 配置,否则可能被 OOM Kill。
后台 GC 让 Gen 2 回收在后台线程进行,减少停顿,适合低延迟服务。
三、资源释放的三层
- 托管内存:GC 自动回收,不用管。
- 非托管资源(文件句柄、数据库连接、GDI 对象):实现
IDisposable,用using确保释放。 - 终结器:作为兜底,不能依赖它;有终结器的对象会进入终结队列,至少多存活一轮 GC。
public sealed class ResourceHolder : IDisposable
{
private SafeFileHandle _handle = File.OpenHandle(path);
public void Dispose() => _handle.Dispose();
}
SafeHandle 封装了句柄与释放逻辑,比手写终结器更安全。实现 Dispose 模式时记得 GC.SuppressFinalize(this)。
四、常见泄漏
GC 能回收"没人引用"的对象;泄漏的本质是"还有引用":
- 事件订阅未取消,发布者持有订阅者。
- 静态字段缓存无上限。
Timer/CancellationTokenSource未释放。HttpClient频繁 new(连接耗尽,不是内存泄漏但表现类似)。- 闭包捕获大对象,长生命周期委托持有它。
排查思路:先看内存趋势(是否持续上升),再抓 dump 对比,找增长最快的类型与引用根。
五、诊断工具
| 工具 | 用途 |
|---|---|
| dotnet-counters | 实时看 GC、堆、线程池 |
| dotnet-dump | 抓取与分析 dump |
| dotnet-trace | 采样 CPU 与事件 |
| dotnet-gcdump | 轻量堆快照 |
| Visual Studio 诊断 | 图形化分析 |
先看指标再抓 dump:GC 次数、堆大小、LOH 大小、异常率、线程池队列。指标能告诉你"是不是内存问题",dump 才能告诉你"是谁"。
六、常见坑
- 认为 GC 会自动释放数据库连接。
- 在终结器里访问其他托管对象,可能已被回收。
- 容器里用服务器 GC 但 CPU limit 很低。
- 静态缓存无上限,最终 OOM。
- 事件订阅未取消,页面/服务无法释放。
七、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 用 dotnet-counters 观察一个服务的 Gen 0/Gen 2 与堆大小。
- 写一个事件订阅未取消的例子,用 gcdump 对比释放前后。
- 给一个资源类实现 IDisposable 模式,验证 using 与终结器的执行顺序。
② 让 AI 写同一题
把第 2 题(内存泄漏排查)交给 AI,补一句 给出从监控到 dump 分析的步骤,并说明常见泄漏源。
③ 对比要盯的点
- 它提了大对象堆(LOH)吗?大数组与小对象走的是不同路径。
- 事件订阅未取消、静态集合持有引用,这两类它点名了吗?
IDisposable的释放时机它讲清了吗?using与终结器的分工。
④ 一句话取舍
按它的步骤排查,哪一步需要重启服务才能拿证据?
八、自测
- 分代与 LOH 的回收特点?
- 工作站 GC 与服务器 GC 怎么选?
- IDisposable 与终结器的分工?
- 常见泄漏的五种来源?
- 先看指标还是先抓 dump?
参考答案要点:Gen0 频繁、LOH 随 Gen2;客户端 vs 多核;主动释放 vs 兜底;事件/静态/Timer/HttpClient/闭包;先指标后 dump。
附录:高频追问
问:GC 什么时候触发? Gen 0 预算耗尽、显式 GC.Collect、内存压力、AppDomain 卸载等;不要手动频繁调用。
问:大对象为什么特殊? 大于 85KB 进 LOH,分配与回收成本高,且默认不压缩,容易碎片。
问:using 和 try/finally 的关系? using 编译后就是 try/finally,确保 Dispose 执行。
问:终结器一定执行吗? 不一定,进程退出时可能不执行;所以不能依赖它释放关键资源。
问:容器里怎么配 GC? 设置内存限制,按 CPU limit 选择工作站/服务器 GC,开启 DOTNET_GCHeapHardLimit。
问:什么是 GC 压力? 分配速率过高导致频繁回收;减少临时对象、复用缓冲、用 Span 可以缓解。
问:ArrayPool 有什么用? 复用数组,减少 LOH 分配与 GC 压力;用完要 Return。
问:内存泄漏和内存不足怎么区分? 泄漏是回收后仍增长;不足是峰值超过限制,扩容或降对象数量。
问:WeakReference 适合什么? 缓存大对象但允许被回收;需要时重建。
问:GC.Collect 能解决问题吗? 通常不能,只掩盖症状;先找引用根。
附:内存分配优化
- 复用缓冲区:
ArrayPool<byte>,用完 Return。 - 用
Span<T>/Memory<T>做切片,避免字符串 Substring 分配。 - 避免在热路径用 LINQ(会产生迭代器与闭包)。
- 字符串拼接用 StringBuilder 或插值。
- 大对象尽量不频繁创建,LOH 回收成本高。
优化前后用 dotnet-counters 看分配速率与 Gen 0 次数,不要凭感觉。
终结器与 Dispose 模式
只有持有非托管资源的类才需要终结器;实现模式是:Dispose 释放资源并 GC.SuppressFinalize(this),终结器作为兜底调用 Dispose(false)。
绝大多数业务类不需要终结器,用 IDisposable + using 就够了。
补充:容器内存与 GC 配置
容器里要同时设内存 limit 与 DOTNET_GCHeapHardLimit,否则 GC 可能按宿主内存规划,最终被 OOM Kill。
CPU limit 低时不要用服务器 GC:它会按核心数分配堆与线程,反而增加内存与切换成本。上线前用压测确认 GC 模式与堆上限。
补充:GC 日志
开启 DOTNET_GCLogging 或 ETW,看三件事:Gen2 次数、单次停顿、堆峰值。Gen2 频繁说明晋升过快或缓存无界;停顿长说明堆大或回收器不合适。
内存验收
压测结束后堆能回落、Gen2 次数稳定、无持续增长,才算没有泄漏。只看一次快照没有意义。
三个不要
不要手动 GC.Collect;不要缓存无上限;不要忽略 IDisposable。
一个判断
如果对象只在小方法内使用、生命周期很短,优先用 struct/Span 或复用缓冲,减少堆分配;不要为了“省一次分配”把代码写得难懂。
验收
压测后堆能回落、Gen2 稳定、无持续增长,才算没有泄漏;只看一次快照没有意义。GC 管内存,不管句柄;先看趋势,再抓 dump。别手动 GC;大对象进 LOH;缓存要有上限;用完就 Dispose。先看趋势,再抓 dump;缓存有上限。先量,再调。先量再调。先量。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
- ★★★★☆ .NET 的 GC 是怎么工作的?分代与大对象堆是什么?
- ★★★★★ IDisposable 和 using 解决了什么问题?Dispose 模式怎么写?
- ★★★★☆ 什么是装箱拆箱?它对性能有什么影响?
- ★★★☆☆ 装箱和拆箱是什么?有什么性能影响?
提示:读完可以顺手做几道对应题目,检验是否真的理解