浅谈C#的GC机制

前言

在诸如原生C++这类语言中,程序员需要手动管理内存,这无疑是十分繁琐的,且常常会因为程序员忘记释放掉不再需要的内存而造成内存泄漏,甚至试图访问已经释放了的内存造成程序错误和安全漏洞,而且这种bug比其他大多数的bug都要严重,因为无法预测其发生时间和后果。

什么是GC

GC,即Garbage Collection,意为垃圾回收,区别于诸如原生C++这种需要程序员手动管理内存的机制,垃圾回收机制可以让程序员不再过于关心内存管理问题,垃圾回收器会自行在合适的时刻收集需要释放掉的对象,进行内存释放。

垃圾回收算法

既然GC可以自行回收对象,那么面临的一个问题是,需要计算出哪些对象是需要被回收的。
.Net中采用的GC算法可以简单地看作标记压缩法(Mark Compact)。除此之外,常见的GC算法有很多种,包括引用计数法(Reference Counting)、标记清除法(Mark Sweep)、复制法(Copy Collection)等。
标记压缩算法可以分为两个阶段

  1. 标记
  2. 压缩

标记

因为引用计数法无法解决循环依赖的问题,CLR改为使用一种引用跟踪算法。引用跟踪算法只关心引用类型的变量,因为只有这种变量才能引用堆上的对象。引用类型变量可在许多场合使用,包括类的静态和实例字段,或者方法的参数和局部变量。我们称所有引用类型的变量为根。

CLR开始GC时,首先暂停进程中所有的线程,这样可以防止线程在CLR检查期间访问底线并更改其状态。然后正式进入标记状态。

在这个阶段,CLR会先遍历堆中的所有对象,并全部设置为可回收状态的,然后检查所有活动根,查看他们引用了哪些对象,如果一个根包含null,CLR会忽略这个根并检查下一个根。任何根如果引用了堆上的对象,CLR都会标记那个对象,并检查这个对象中的根,继续标记它们引用的对象,如果过程中发现对象已标记,则不重新检查,避免了循环引用而造成的死循环。

检查完毕后,堆中的对象要么已标记,要么未标记。已标记的对象不能被垃圾回收,因为至少有一个根在引用它。我们说这种对象是可达的,因为应用程序代码可通过仍在引用它的变量访问它,是不能回收的。未标记的对象是不可达的,因为应用程序中不存在使对象能被再次访问的根,是可以回收的。

压缩

删除掉可回收对象后,便进入压缩阶段。首先要搞清楚的是,这里的压缩并不是指把对象的内存空间压缩变小了,而是把存活下来的对象进行移位,使他们占用连续的内存空间,紧挨在一起,实际上相比起“压缩”,用“碎片整理”这一词应该跟贴近这一阶段的行为。这一行为可以使得引用恢复“局部化”,减少应用程序工作集,并提升了访问这些对象时的性能,并且对于压缩后的可使用空间,也会变成连续的,可容纳更大的对象,解决了空间碎片化问题。

在内存中移动了对象位置后,引用幸存对象的根仍然引用着对象压缩前的位置,压缩阶段最后一步还会把每个根减去所引用的对象在内存中偏移的字节数,来保证每个根引用的还是和之前一样的对象。

压缩好内存后,托管堆的NextObjPtr指针指向最后一个幸存对象之后的位置。下一个分配的对象将放到这个位置。压缩阶段完成后,CLR恢复应用程序的所有线程。这些线程继续访问对象,就好象GC没有发过一样。

如果CLR在一次GC之后回收不了内存,而且进程中没有空间来分配新的GC区域,就说明该进程的内存已耗尽。此时,试图分配更多内存的new操作符会抛出OutOfMemoryException。应用程序可捕捉该异常并从中恢复。但大多数应用程序都不会这么做;相反,异常会成为未处理异常,Windows 将终止进程并回收进程使用的全部内存。

代(Generation)

通过上文,我们可以得到一个观点,就是当堆中没有足够的空间来分配新对象时,CLR便会执行垃圾回收,以释放空间。但实际上为了优化垃圾回收时的性能,情况会更复杂一些。

分代的假设

为了进一步优化垃圾回收的性能,CLR使用了分代算法,而基于代的垃圾回收器,会对代码做出以下假设:

  1. 对象越新,生存期越短。
  2. 对象越老,生存期越长。
  3. 回收堆的一部分,速度快于回收整个堆。

大量研究证明,这些假设对于现今大多数应用程序都是成立的。

分代回收

程序在初始化时,托管堆不包含任何对象,这个时候新添加到堆中的对象,我们成为第0代对象。简单地说,第0代对象就是那些新构造的对象,垃圾回收器从来未检查过这些对象。CLR初始化时会为第0代对象选一个预算容量,当分配一个新的对象超出这个预算时,就会启动一次垃圾回收。在一次垃圾回收之后,没被回收的对象会成为第1代对象,此时第0代空间中已经不包含任何对象,原来的对象可能已被回收,可能已被放置到第1代中。

新分配的对象会继续第0代空间中,直到第0代空间不足分配新对象,会再次触发垃圾回收,开始垃圾回收时,垃圾回收器必须决定需要检查哪些代,前文提到,CLR初始化时会为第0代对象选择预算,实际上,CLR还必须为第1代选择预算。这时,垃圾回收器会检查第1代的空间,若远小于预算,则这次回收只会检查第0代,基于“对象越新,生存期越短”的假设,第0代包含更多垃圾的可能性很大,能回收更多的内存。因为忽略了第1代中的对象,尽管第1代中可能存在不可达的垃圾对象,但这加快了垃圾回收的速度。对性能的有效提升,主要是在于,现在不需要遍历堆中的每一个对象,因为如果根或对象引用了老一代的某一个对象, 垃圾回收器会忽略老一代对象内部的所有引用。

当然,也存在老对象会引用了新对象的可能性,为了避免老一代对象引用了新一代对象,垃圾回收时却没检查到这一引用,而把新一代对象回收了的情况发生,垃圾回收器利用了JIT编译器内部的一个机制,这个机制在对象的引用字段发生变化时,会设置一个对应的标记位,这样一来垃圾回收器就会知道自上一次垃圾回收以来,哪些老对象的引用字段发生了变化,这样就算这次回收只回收新生代,也会去检测引用字段发生了变化的老对象,是否引用了新生代对象。

当下一次第0代分配空间超出预算,开始执行垃圾回收,并发现第一代空间也超出预算时,垃圾回收器就会对第0代和第1代都进行回收,垃圾回收后,第0代的存活对象会被提升到第1代,而第1代的对象会被提升到第二代,而第0代空间再次空了出来。

代预算的动态调节

托管堆只支持三代:第0代、第1代、第2代,没有第3代。CLR初始化的时候会为每一代选择预算。而且,CLR的垃圾回收器是会根据实际运行情况动态调节预算的,例如在回收第0代后发现存活下来的对象很少,就可能减少第0代的预算,这意味着会更加频繁地执行垃圾回收,但每次回收需要做的事情少了(若第0代所有对象都是垃圾,垃圾回收就不需要压缩内存,直接让NextObjPtr指针指回第0代的起始处即可,速度上会快很多)。相反,如果回收了第0代后发现还有很多存活的对象,没有多少内存可以回收,就会增大第0代的预算,这样垃圾回收的次数就会减少,但每次进行垃圾回收时,能会收到的内存就会变多。如果没有会收到足够的内存,垃圾回收器会执行一次完整的回收,如果还是不够,就会抛出OutOfMemoryException异常。上述仅对第0代进行垃圾回收后动态调整预算的讨论,但垃圾回收器还会用类似的方法来调整第1代和第2代的预算,最终结果就是,垃圾回收器会根据应用程序要求的内存负载来进行自我优化。

垃圾回收的触发条件

上文说到的检测到第0代超出预算的时候会触发垃圾回收,这是最常见的一种触发条件,除此之外还有以下条件可以触发垃圾回收:

代码显式调用System.GC的静态Collect方法

代码可显式请求CLR进行垃圾回收,但微软强烈反对这种请求,托管语言应该信任它本身的垃圾回收机制。

Windows报告低内存情况

如果Windows报告低内存,CLR会强制执行垃圾回收。

CLR正在卸载AppDomain

当一个AppDomain卸载时,CLR认为其中一切都不是根,会执行涵盖所有代的垃圾回收。

CLR正在关闭

CLR在进程正常终止时关闭,CLR认为其中一切都不是根,对象有机会进行资源清理,但CLR不会试图压缩或释放内存。进程终止时,Windows会回收进程的全部内存。

大对象

CLR把对象分为大对象和小对象,上文都是对小对象的讨论,目前认为85000字节以上的对象为大对象,CLR会以不同的方式对待大小对象。

  • 大对象不在小对象的地址空间中分配,而是在进程地址空间的其他地方分配
  • 目前版本GC不压缩大对象,因为在内存中移动大对象的代价太高,这可能会导致空间碎片化。(CLR有可能在未来版本压缩大对象)
  • 大对象总是第2代,不会在其他代,所以为了性能考虑,只能为需要长时间存活的资源创建大对象,分配短时间存活的大对象会导致第2代被更频繁地回收,损害性能。大对象一般是大字符串或用于IO操作的直接数组。

一般情况下可以忽视大对象的存在,仅出现如空间碎片化等情况时才对大对象进行特殊处理。

需要特殊清理的类型

Finalize方法

有些类型除了内存外,还需要一些本机资源(也就是非托管资源)才能正常工作,例如System.IO.FileStream类型需要打开一个文件并保存文件的句柄。在包含非托管资源的类型被GC时,GC会回收对象在托管堆中的内存,如果直接进行回收,本机资源未释放,会导致本机资源的泄漏,为了解决这一问题CLR提供了终结机制,允许对象在被判定为垃圾后,在被回收之前执行一些代码,之后再从托管堆中回收对象。我们称这种对象为可终结的

System.Object定义了受保护的虚方法Finalize,如果类型重写了这个方法,垃圾回收器会在判定对象是垃圾后调用对象的Finalize方法。C#要求要用特殊的语法来定义Finalize方法,如下所示:

1
2
3
4
5
6
7
class SomeType
{
~SomeType()
{
//....
}
}

虽然System.Object定义了Finalize方法,但CLR会忽略它,只有类型重写了这个方法,才会被认为是“可终结的”

C#的Finalize方法在语法上与C++的析构函数非常相似,但实际上工作方式完全不同,与C++中类型实例会被确定性析构不一样,面向CLR的语言,C#无法提供确定性析构这种机制

对于类型定义了Finalize方法的对象的回收机制

应用程序在创建新的对象时,new操作符会在堆中分配内存,如果对象的类型定义了Finalize方法,那么这个实例在被构造之前,会将一个指向该对象的指针放到一个终结列表中(finalization list),终结列表是由垃圾回收器控制的一个内部数据结构,列表中的每一个项都指向一个类型定义了Finalize方法的对象。

垃圾回收器在标记完垃圾对象后,会在终结列表中查找是否有包含垃圾对象的引用,即检查这些垃圾对象是否定义了Finalize方法,若检测到有,则会把这个引用从终结列表中移除,并加到freachable队列中。freachable队列也是垃圾回收器的一种内部数据结构,队列中每一个引用都代表准备要调用Finalize方法的对象。垃圾回收完毕后,没有定义Finalize方法的对象已被回收,但定义了Finalize方法的对象(即此时在freachable队列中的对象)会存活下来,因为此时他们的Finalize方法还没被调用。

简单地说,当一个对象不可达时,垃圾回收器就把它视为垃圾。但是,当垃圾回收器把对象的引用从终结列表移到freachable队列时,对象不再被认为是垃圾,我们可以说对象被“复活了”,相应的,标记freachable队列中的对象时,还会递归对象中的引用字段所引用的对象,这些对象都会“复活”,最后在回收过程中存活下来。然后垃圾回收器会压缩内存,并把“复活”的对象提升到老一代,之后CLR会用特殊的终结线程去调用freachable队列中每个对象的Finalize方法,并清空队列。

也就是说,这些定义了Finalize方法的“可终结”的对象,由于在第一次回收时,会被“复活”以执行Finalize方法,并可能会被提升到老一代,所以至少需要执行两次垃圾回收才能释放掉它们占用的托管堆内存,更需要注意到的是,这些对象中的引用字段所引用的对象也会存活下来并提升到老一代,会造成更大的性能负担。所以,要尽量避免为引用类型的字段定义为“可终结”对象。

最后要注意,Finalize方法的执行时间和执行顺序是无法保证的,所以在Finalize方法中不要访问定义了Finalize方法的其他类型的对象,那些对象可能已经终结了,但是访问值类型实例或者没有定义Finalize方法类型的对象是安全的


参考

  • 《CLR via C#》第四版