.NET 面试必问的 8 道 GC 题,建议收藏
这篇文章整理了 .NET 面试中出现频率最高的 8 道 GC 题。不是背答案——是帮你把 GC 的底层逻辑串一遍,面试时不管怎么追问都能接得住。
01 .NET GC 的分代机制是怎么回事?
这是开胃菜,但答不好直接出局。
.NET GC 把托管堆分成三代:Gen 0、Gen 1、Gen 2。核心假设是——新对象死得快,老对象活得久。
新对象分配在 Gen 0。Gen 0 预算满了,触发 Gen 0 回收。活下来的晋升 Gen 1。Gen 1 预算满了,触发 Gen 1 回收(连带 Gen 0)。活下来的晋升 Gen 2。Gen 2 回收就是 Full GC,代价最大。
为什么这么设计?因为压缩整块堆太贵了。分代让你只频繁回收一小块(Gen 0),大部分垃圾在 Gen 0 就清掉了,不需要动整个堆。
面试加分句:Gen 0 回收通常 < 1ms,Gen 2 回收可能十几毫秒到上百毫秒——这就是为什么我们要避免 short-lived 对象意外晋升到 Gen 2。
02 GC 什么时候触发?
不是只有一个答案。触发条件有好几个:
① Gen 0 预算耗尽——最常见。新对象分配量超过 Gen 0 阈值,自动触发 Gen 0 回收。
② 显式调用——GC.Collect()。生产代码几乎不该手动调。如果你调了,面试官一定会问你为什么。
③ 系统内存压力——OS 层面内存吃紧,GC 会收到通知提前回收。
④ LOH 分配触发——大对象分配本身可能触发 GC(下一条细讲)。
注意:GC 是自动的、异步的、非确定性的。你不知道它什么时候来——这就是为什么 Dispose 模式存在。
03 LOH 是什么?为什么它是个坑?
LOH = Large Object Heap。大于 85,000 字节的对象直接进 LOH,跳过 Gen 0。
坑在哪?LOH 不压缩。
普通堆回收后会压缩(移动对象填补空隙),但 LOH 从 .NET Framework 时代就不压缩,怕移动大对象代价太高。结果就是碎片化——你分配释放了几个大数组,堆里全是空洞,总内存没超但下一个大对象放不进去,OOM 了。
.NET 4.5.1+ 可以手动压缩:GCSettings.LargeObjectHeapCompactionMode = CompactOnce; 然后 GC.Collect()。但这是应急手段,不是日常操作。
真正的解法是池化——用 ArrayPool<T> 租借大数组而不是每次 new。这是面试官想听到的。
⚠️ 实战提醒:注意 string 在 .NET Core 3.0+ 也可能进 LOH(因为 string 内部是 char[]),超 85K 字符的字符串就是 LOH 对象。
04 Server GC 和 Workstation GC 有什么区别?
这是高并发场景的必问题。
Workstation GC:一个 GC 线程,所有 CPU 核共享一个堆。适合客户端应用、低并发场景。
Server GC:每个 CPU 核一个 GC 线程,每个核一个独立的堆。回收时多线程并行,吞吐量高。适合服务端高并发。
.NET Core / .NET 5+ 默认根据运行环境自动选择:ASP.NET Core 默认 Server GC,控制台应用默认 Workstation GC。可以通过 runtimeconfig.json 或 csproj 配置覆盖。
面试加分句:Server GC 不是银弹——它用更多内存换更高吞吐。内存受限的容器环境(比如 256MB 的 pod)反而可能要切回 Workstation GC,否则 GC 线程开销本身就吃掉了不少内存。
05 Finalizer 和 IDisposable 有什么区别?
这两个不是同一个东西,但很多人混着用。
Finalizer(C# 里写 ~MyClass())是 GC 的兜底机制。对象被回收前,GC 会把它放进 freachable 队列,由专门的 finalizer 线程执行。问题是——有 finalizer 的对象至少经历两次 GC 才能被回收(第一次进队列,第二次才释放)。而且执行时机不确定,你不知道什么时候调。
IDisposable 是程序员主动清理。调 Dispose() 立刻释放资源,不用等 GC。
标准模式是两者结合:实现 IDisposable,在 Dispose(true) 里释放托管+非托管资源,在 finalizer 里调 Dispose(false) 只释放非托管资源。这样即使用户忘了调 Dispose,finalizer 还能兜底。
💡 关键判断:如果你只持有托管资源(比如一个 List),不需要 finalizer。Finalizer 只在持有非托管资源(文件句柄、原生内存、数据库连接)时才有意义。
06 什么是 SafeHandle?为什么用它替代 IntPtr?
如果你在 P/Invoke 调用原生 API,这题一定会问到。
老写法是用 IntPtr 持有原生句柄,在 finalizer 里手动调 CloseHandle。问题在于——GC 不知道 IntPtr 持有什么资源,回收时机完全凭运气。更狠的是,如果在 finalizer 里异步调 CloseHandle,进程域冲突(AppDomain unload / 进程退出)时可能直接 crash。
SafeHandle 是 .NET 2.0 引入的抽象类,继承自 CriticalFinalizerObject。它保证:
① finalizer 一定执行(即使进程非正常退出) ② finalizer 在所有用户代码 finalizer 之后执行(保证顺序) ③ 防止句柄被回收后被复用导致的安全漏洞(handle recycling attack)
面试时一句话总结:SafeHandle 把原生资源的生命周期交给了 GC 管理,同时保证了可靠性和安全性,替代了手写 IntPtr + finalizer 的脆弱模式。
07 Pinning 对 GC 有什么影响?
Pinning = 把对象钉在堆上不让 GC 移动。
场景:P/Invoke 传数组给原生代码,或者用 fixed 语句拿指针。GC 压缩堆时会跳过 pinned 对象,在堆里留一个"洞"。
影响:碎片化。短期 pinning(一次 P/Invoke 调用)问题不大,GC 能处理。怕的是长期 pinning——比如把对象钉在 Gen 0,GC 压缩 Gen 0 时发现一大块动不了,碎片就来了。
.NET 5+ 引入了 pinning budget——GC 在 Gen 0 预留一块专门给 pinned 对象的区域,减少对其他对象的影响。但如果你 pin 了一个 Gen 2 对象,那 GC 压缩 Gen 2 时依然得绕开它。
实战建议:尽量缩短 pinning 时间;大对象用 GCHandle.Alloc(obj, GCHandleType.Pinned) 时明确知道在干什么;避免在 hot path 里反复 pin 同一个对象。
08 怎么诊断 GC 问题?
这是实战题,答得出来说明你不止背书。
第一层:性能计数器——dotnet-counters 监控 GC 基础指标:Gen 0/1/2 回收频率、堆大小、分配速率。先看趋势。
第二层:事件追踪——dotnet-trace collect --providers Microsoft-DotNETCore-SampleProfiler 抓 GC 事件,看每次回收耗时、触发原因。
第三层:堆分析——dotnet-gcdump 生成堆快照,用 PerfView 分析对象分布。看什么对象多、什么对象大、谁在持有。
第四层:生产环境——用 dotnet-dump 在容器里抓全量 dump,拉回来用 dotnet-dump analyze 或 WinDbg 分析。
典型排查路径:Gen 2 回收频率高 → 看大对象/长生命周期对象 → 查 LOH 碎片 → 定位到某个缓存/集合无限增长 → 加上限或换弱引用。
GC 这 8 道题,核心就一条线: 分代 → 触发 → 大对象 → 并发模式 → 资源释放 → 安全抽象 → Pinning 碎片 → 诊断工具 把这条线串起来,面试官怎么追问都不怕。
收藏这篇,面试前过一遍。
不是为了背答案——是为了把 GC 的那条线在脑子里走一遍。
关注 CSharp精选营,每周二四 get 能直接抄的 C# 实战。