基础进阶 --- 垃圾回收的基本运作方式

2023-10-22 17:04:54 浏览数 (2)

引言

随着高性能系统越来越普遍地采用.NET环境,垃圾回收器的决策过程正在变得越来越优雅。

本篇介绍一下垃圾回收的基本运作方式。

小对象堆和大对象堆

在托管进程中存在两种内存堆(本机堆和托管堆)。本机内存堆(Native Heap)是由 VirtualAlloc 这个 Windows API 分配的,是由操作系统和CLR使用的,用于非托管代码所需的内存,比如 Windows API 、操作系统数据结构、很多CLR数据等。

CLR在托管堆(Managed Heap)上为所有.NET托管对象分配内存,也被成为GC堆,因为其中的对象均要受到垃圾回收机制的控制。

托管堆又分为两种一「小对象堆」「大对象堆」LOH),两者各自拥有自己的内存段(Segment)。每个内存段的大小视配置和硬件环境而定,对于大型程序可以是几百MB或更大。

小对象堆和 LOH 都可拥有多个内存段。小对象堆的内存段进一步划分为3代,分别是0、1、2代。第0代和第1代总是位于同一个内存段中,而第2代可能跨越多个内存段,LOH 也可以跨越多个内存段。包含第0代和第1代堆的内存段被称为暂时段(Ephemeral Segment)。

一开始内存堆就如下所示,两个内存段分别被标为 A 和 B,内存地址从左到右由小变大。小对象堆由 A 段内存构成,LOH 拥有 B 段内存。第 2 代和第 1 代堆只占有开头的一点内存,因为它们还都是空的。

小对象堆中分配内存的对象的生存期

如果对象小于 85 000 字节,CLR 都会把它分配在小对象堆中的第 0 代,通常紧挨着当前已用内存空间往后分配。因 此,正如本章开头所示,.NET 的内存分配过程非常迅速。如果快速分配失败,对象就可能会被放入第 0 代内存堆中的任意地方,只要能容纳得下就行。

如果没有合适的空闲空间,那么分配器就会扩大第 0 代内存堆,以便能存入新对象。如果扩大内存堆时超越了内存段的边界,则会触发垃圾回收过程。

对象总是诞生于第 0 代内存堆。只要对象保持存活,每当发生垃圾回收时,GC 都会把它提升一代。第 0 代和第 1 代内存堆的垃圾回收有时候被称为瞬时回收(Ephemeral Collection)。

在发生垃圾回收时,可能会进行碎片整理(Compaction),也就是 GC 把对象物理迁移到新的位置中去,以便让内存段中的空闲空间能够连续起来以备使用。如果未发生碎片整理,那就只需要重新调整各块内存的边界即可。在经历了几次未做碎片整理的垃圾回收之后,内存堆的分布可能会如下所示。

对象的位置没有移动过,但各代内存堆的边界已经发生了变化。

每一代内存堆都有可能发生碎片整理。因为 GC 必须修正所有对象的引用,使它们指向新的位置,所以碎片整理的开销相对较大,还有可能需要暂停所有托管线程。正因如此,垃圾回收器只在划算(Productive)时才会进行碎片整理,判断的依据是一些内部指标。

如果对象到达了第 2 代内存堆,它就会一直留在那里直至终结。这并不意味着第 2 代内存堆只会一直变大。

如果第 2 代内存堆中的对象都终结了,整个内存段也没有存活的对象了,垃圾回收器会把整个内存段交还给操作系统,或者作为其他几代内存堆的附加段。

在进行完全垃圾回收(Full Garbage Collection)时,就可能发生这种第 2 代内存堆的回收。

那么“存活”是什么意思呢?如果 GC 能够通过任一已知的 GC 根对象(Root),沿着层层引用访问到某个对象,那它就是存活的。

GC 根对象可以是程序中的静态变量,或者某个线程的堆栈被正在运行的方法占用(用于局部变量),或者是 GC 句柄(比如固定对象的句柄,Pinned Handle),或是终结器队列(Finalizer Queue)。

请注意,有些对象可能没有受GC 根对象的引用,但如果是位于第 2 代内存堆中,那么第 0 代回收是不会清理这些对象的,必须等到完全垃圾回收才会被清理到。

如果第 0 代堆即将占满一个内存段,而且垃圾回收也无法通过碎片整理获取足够的空闲内存,那么 GC 会分配一个新的内存段。新的内存段会用于容纳第 1 代和第 0 代堆,老的内存段将会变为第 2 代堆。老的第 0 代堆中的所有对象都会被放入新的第 1 代堆中,老的第 1代堆同理将提升为第 2 代堆(提升很方便,不必复制数据)。现在的内存段将如下所示。

如果第 2 代堆继续变大,就可能会跨越多个内存段。LOH 堆同样也可能跨越多个内存段。无论存在多少个内存段,第 0 代和第 1 代总是位于同一个段中。以后我们想找出内存堆中有哪些对象存活时,这些知识将会派上用场。

大对象堆中分配内存的对象的生存期

LOH 则遵从另一套回收规则。大于 85 000 字节的对象将自动在 LOH 中分配内存,且没有什么“代”的模式。超过这个尺寸的对象通常也就是数组和字符串了。

出于性能考虑,在垃圾回收期间 LOH 不会自动进行碎片整理,但从.NET 4.5.1 开始,必要时你也可以人为发起碎片整理。

与第 2 代内存堆类似,如果 LOH 的内存不再有用了,就可能会被用于其他内存堆。不过我们以后将会看到,理想状态下你根本就不会愿意让 LOH 的内存被回收掉。在 LOH 中,垃圾回收器用一张空闲内存列表来确定对象的存放位置。

友情提示:如果是在调试器中查看位于 LOH 的对象,你会发现有可能整个 LOH 都小于 85 000 字节,而且可能还有对象的大小是小于已分配值的。这些对象通常都是 CLR 分配出去的,可以不予理睬。

垃圾回收是针对某一代及其以下几代内存堆进行的。如果回收了第 1 代,则也会同时回收第 0 代。

如果回收了第 2 代,则所有内存堆都会回收,包括 LOH。如果发生了第 0 代或第1 代垃圾回收,那么程序在回收期间就会暂停运行。对于第 2 代垃圾回收而言,有部分回收是在后台线程中进行的,这要根据配置参数而定。

垃圾回收的四个阶段

垃圾回收包含 4 个阶段。

  1. 挂起(Suspension)—— 在垃圾回收发生之前,所有托管线程都被强行中止。
  2. 标记(Mark)—— 从 GC 根对象开始,垃圾回收器沿着所有对象引用进行遍历并把所见对象记录下来。
  3. 碎片整理(Compact)—— 将对象重新紧挨着存放并更新所有引用,以便减少内存碎片。在小对象堆中,碎片整理会按需进行,无法控制。在 LOH 中,碎片整理不会自动进行,但你可以在必要时通知垃圾回收器来上一次。
  4. 恢复(Resume)——托管线程恢复运行。

在标记阶段并不需要遍历内存堆中的所有对象,只要访问那些需要回收的部分即可。

比如第 0 代回收只涉及到第 0 代内存堆中的对象,第 1 代回收将会标记第 0 代和第 1 代内存堆中的对象。

而第 2 代回收和完全回收,则需遍历内存堆中所有存活的对象,这一过程的开销有可能非常大。

这里有个小问题需要注意,高代内存堆中的对象有可能是低代内存堆对象的根对象。这样就会导致垃圾回收器遍历到一部分高代内存堆的对象,但这样的回收开销还是小于高代内存堆的完全垃圾回收。

结论

由上述讨论可以形成以下几点重要结论。

第一,垃圾回收过程的耗时几乎完全取决于所涉及“代”内存堆中的对象数量,而不是你分配到的对象数量。这就是说,即使你分配了 1 棵包含 100 万个对象的树,只要在下一次垃圾回收之前把根对象的引用解除掉,这 100 万个对象就不会增加垃圾回收的耗时。

第二,垃圾回收的频率取决于所涉及“代”内存堆中已被占用的内存大小。只要已分配内存超过了某个内部阈值,就会发生该“代”垃圾回收。

这个阈值是持续变化的,GC 会根据进程的执行情况进行调整。如果某“代”回收足够划算(提升了很多对象所处的“代”),那垃圾回收就会发生得频繁一些,反之亦然。

另一个触发垃圾回收的因素是所有可用内存,与你的应用程序无关。如果可用内存少于某个阈值,为了减少整个内存堆的大小,垃圾回收可能会更为频繁地发生。

由上所述,貌似垃圾回收是难以控制的,但事实不是这样。通过控制内存分配模式来控制垃圾回收的统计指标,就是一种最容易实现的优化方法。这需要理解垃圾回收的工作机制、可用的配置参数、你的内存分配率,还需要对对象的生存期有很好的控制能力。

❝上述文章引用自 编写高性能的.NET代码/(美)沃森(Ben Watson)著;戴旭译. --北京:人民邮电出版社,2017.8 ❞

0 人点赞