知

模块三 · 内存与数据

第 5 / 11 节20 分钟阅读型

托管内存的边界:GC 管什么、不管什么

GC 只回收托管对象,不回收你忘记释放的资源。这一节讲清分代、LOH、GC 模式、终结器与常见泄漏,以及怎么用工具定位。

读完这一节,你能做到

  1. 说清托管堆的分代与 LOH 的回收特点。
  2. 区分工作站 GC 与服务器 GC 的适用场景。
  3. 说清 IDisposable、终结器与 SafeHandle 的分工。
  4. 用 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 才能告诉你"是谁"。

六、常见坑

  1. 认为 GC 会自动释放数据库连接。
  2. 在终结器里访问其他托管对象,可能已被回收。
  3. 容器里用服务器 GC 但 CPU limit 很低。
  4. 静态缓存无上限,最终 OOM。
  5. 事件订阅未取消,页面/服务无法释放。

七、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 用 dotnet-counters 观察一个服务的 Gen 0/Gen 2 与堆大小。
  2. 写一个事件订阅未取消的例子,用 gcdump 对比释放前后。
  3. 给一个资源类实现 IDisposable 模式,验证 using 与终结器的执行顺序。

② 让 AI 写同一题

把第 2 题(内存泄漏排查)交给 AI,补一句 给出从监控到 dump 分析的步骤,并说明常见泄漏源。

③ 对比要盯的点

  • 它提了大对象堆(LOH)吗?大数组与小对象走的是不同路径。
  • 事件订阅未取消、静态集合持有引用,这两类它点名了吗?
  • IDisposable 的释放时机它讲清了吗?using 与终结器的分工。

④ 一句话取舍

按它的步骤排查,哪一步需要重启服务才能拿证据?

八、自测

  1. 分代与 LOH 的回收特点?
  2. 工作站 GC 与服务器 GC 怎么选?
  3. IDisposable 与终结器的分工?
  4. 常见泄漏的五种来源?
  5. 先看指标还是先抓 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;缓存有上限。先量,再调。先量再调。先量。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解