关于 .NET 垃圾回收的超简化解释






4.87/5 (50投票s)
本文进行了超简化,介绍了 .NET 垃圾回收,其中故意省略了大量技术细节。它旨在提供一个典型的 C# 开发者日常工作中实际需要的基线理解水平。
垃圾回收常常是许多性能问题的根源(请原谅这个双关语),这很大程度上是由于误解造成的,所以请在阅读本文后花时间加深您的理解。
本文进行了超简化,介绍了 .NET 垃圾回收,其中故意省略了大量技术细节。它旨在提供一个典型的 C# 开发者日常工作中实际需要的基线理解水平。我当然不会通过提及堆栈、值类型、装箱等来增加复杂性。
免责声明:.NET 垃圾回收器是一个动态的复杂系统,它会适应您的应用程序,并且其实现经常会发生变化。
面试时开发者会说的话
垃圾回收器会自动查找不再使用的对象并释放内存。这有助于避免因程序员错误而导致的内存泄漏。
这足以找到工作,但不足以构建优秀、高性能的 C# 代码,相信我。
托管内存(简而言之)
.NET CLR(公共语言运行时)为您的应用程序预留了一块内存,用于管理应用程序创建的任何对象。当应用程序不再需要这些对象时,它们会被解除分配。这部分由垃圾回收器 (GC) 处理。
GC 可以在需要时扩展段大小,并且确实会这样做,但其首选方法是通过其分代垃圾回收来回收空间……
分代 .NET 垃圾回收器
新的、小的对象进入第 0 代。当发生一次回收时,任何不再使用(没有引用指向它们)的对象将被释放内存(解除分配)。任何仍在使用的对象将**存活**下来并被**提升**到下一代。
长生不老(或永远活着)并繁荣昌盛
问任何 .NET 专家,他们都会告诉你同样的话——一个对象应该短暂存在,否则就永远存在。我不会深入探讨性能细节——这是我希望你唯一能带走的一条规则。
为了理解这一点,我们需要回答:**为什么是分代?**
在一个设计良好的 C# 应用程序中,典型的对象将在不被提升出第 0 代的情况下生与死。我想到的是像这样的操作:
- 局部变量,在运行时间很短的方法中使用
- 为 Web API 调用生命周期而实例化的对象
第 1 代是“中间代”,它会捕获任何未成功从第 0 代晋升的、本应短暂存在于对象,并且回收过程仍然相对快速。
检查哪些对象未被使用会消耗资源并暂停应用程序线程。GC 在代际之间越往上,成本就越高,因为对某一特定代进行回收时,还必须回收其之前的所有代,例如,如果回收第 2 代,那么第 1 代和第 0 代也必须被回收(参见上面的图)。这就是为什么我们经常将第 2 代称为完全 GC。此外,寿命更长的对象通常更复杂清理!
不过不用担心——如果我们知道哪些对象可能会活得更长,我们可以不那么频繁地检查它们。考虑到这一点:
.NET GC 对第 0 代的运行频率最高,对第 1 代次之,对第 2 代的运行频率最低。
如果一个对象到达了第 2 代,那一定是有充分理由的——比如它是一个永久的、可重用的对象。如果对象意外地到达那里,它们会长时间占用内存,并导致更多那些庞大的第 2 代完全回收!
但是代际都只是表面功夫!
在通过性能分析器和调试器探索应用程序,初次查看 GC 时,最大的陷阱是大型对象堆 (LOH) 也被称为第 2 代。
实际上,您的对象将进入托管堆段(在前面提到的分配给 CLR 的内存中)。
小对象将连续添加到小对象堆 (SOH) 的第 0 代,从而避免寻找空闲空间。为了减少对象消亡时的碎片,堆可能会被压缩。
下面是一个简化的第 0 代回收,然后是第 1 代回收,中间有新的对象分配(这是我第一次尝试这样的动画)。
点击动画可打开视频,您可以在视频中暂停等。
大对象存储在大型对象堆上,不会自动压缩,但会尝试重用空间
从 .NET451 开始,您可以指示 GC 在下一次回收时进行压缩。但对于处理 LOH 碎片,最好选择其他选项,例如池化可重用对象。
大对象有多大?
众所周知,>= 85KB 的对象就是大对象(或 1000 个双精度浮点数的数组)。您需要了解更多……
您可能在想您处理过的那个大型 Bitmap 图像——实际上那个对象使用了 24 字节,而 Bitmap 本身是在非托管内存中。真正很少看到非常大的对象。更典型的情况是,大对象会是一个数组。
在下面的示例中,`LargeObjectHeapExample` 中的对象实际上是 16 字节,因为它只由通用类信息和指向 `string` 和字节数组的指针组成。
通过实例化 `LargeObjectHeapExample` 对象,我们实际上在堆上分配了**3 个对象**:其中 2 个在小对象堆上;字节数组在大型对象堆上。
还记得我之前关于大型对象堆中对象的说法吗——注意字节数组如何报告为处于第 2 代!LOH 在逻辑上被包含在第 2 代的一个原因是,大对象通常有更长的生命周期(回想一下我之前关于分代 GC 中寿命较长的对象的说法)。另一个原因是在进行早期代际中发生的压缩时,复制大对象的**成本**。
什么会触发回收?
- 尝试分配的对象超过了某一特定代或大型对象堆的阈值
- 调用 `GC.Collect`(这将在另一篇文章中讨论)
- 操作系统发出低系统内存信号
请记住,第 2 代和 LOH 在逻辑上是同一件事,因此触及其中任何一个的阈值都会触发两个堆的完全(第 2 代)回收。这是需要考虑的性能问题(本文未涉及)。
摘要
- 对某一特定代进行回收也会回收其下方所有代,即回收第 2 代也会回收第 1 代和第 0 代。
- GC 会将回收后仍然存活(因为它们仍在被使用)的对象提升到下一代。尽管如此,请参考上一条——不要指望在第 0 代回收发生时,第 1 代中的对象会移动到第 2 代。
- GC 对第 0 代的运行频率最高,对第 1 代次之,对第 2 代的运行频率最低。考虑到这一点,**对象应该短暂存在(最好在第 0 代或第 1 代中消亡),或者(当然是故意的)永远存在于第 2 代**。
历史
- 20/08/2018 - 修复了导入格式问题,并添加了双精度浮点数数组阈值以保持完整性