.NET的GC机制和GC知识学习

在刚刚完成的一套.net笔试题卷子中,出了这样一道题:

面有关垃圾收集(GC)机制的说法中正确的是:

  • A. 值类型的对象会被分配到Stack上,而引用类型的对象会被分配到Large Object Heap上。
  • B. 使用try/catch/finally在效率上并不优于使用using的方式。
  • C. 可以实现自定义引用类型的Finalize方法,但它不能被重载。
  • D. GC的垃圾回收动作可以在代码中手动调用。

答案是BCD,A中错误在于引用对象会被默认分配到GC Heap的SOH(Small Object Heap)上(update:错误不仅如此,请参考打自己脸——有关.net对象分配),超过85KB对象会被分配到LOH。至于85K这个Magic Number的由来,应该是编写.Net框架的工程师们经过平时各种使用场景和优化推算出来的,我们不必多费心研究。

那么GC Heap为什么要分两类呢?这要从SOH和LOH不同的垃圾清理机制说起。

.Net的GC机制

GC Heap

对于LOH来说,清理垃圾的过程等于“回收+归并”,即不仅要清理掉内存空间,还要将尚存的对象向着一个方向移动直到和之前的对象紧紧贴在一起[1][3]。如下所示,SOH上有四个对象:

[obj1][    obj2     ][  obj3  ][obj4]
↑
0代指针(p0)

经过一次清理后,obj2和obj4没了,留下来的都是1代对象,并将0代指针移动到内存低位的第一个空闲空间处:

[obj1][  obj3  ][obj5]
↑               ↑
p1              p0

2代对象的生成类似,这样的移动保证不会产生大量的内存碎片,最大程度提高了内存使用效率。

可惜对于大对象来说,移动的成本过高,所以会优先采用查找可用剩余碎片空间的方式,将可以容纳新对象的空间重复利用。类似上面的内存情况在0代清理、新分配了obj5后会变成:

[obj1][obj5]         [  obj3  ]

obj5和obj3之间的内存如果小于85K就很可惜,因为再也利用不起来了。

不过需要注意的是,虽然大对象目前不会被归并处理,但不保证未来的CLR版本会不会实现一个LOH也进行存留对象移动的版本,所以希望保持现在这种行为的话,需要在变量前加fixed关键字进行标识。

分代处理

上文提到的0、1代是.Net中采用的分代垃圾回收机制的体现。目前.Net的GC中支持的代数为2(GC.MaxGeneration),一般来说,可以将0-2这三代对象视作:

  • 0代,大多数对象,GC进行回收时候都会被处理掉
  • 2代,少数常驻内存的对象,例如asp.net和整个网站生存期等同的一些全局对象
  • 1代,介于0代和2代之间的一个灰色地带。

在GC动作的时候,0代会首先会被回收,如果需要的内存空间不够用再依次处理1和2代对象。

对于程序中的引用对象,可以通过GC.GetGeneration()获取它的代数,这个方法还有一个重载版本支持WeakReference。

其他细节

对于.Net平台上托管(Managed)类型的语言来说,一切对象的创建都是程序员负责,但回收就不用操心了。CLR中的托管堆上存放的都是引用类型的对象,在没有垃圾回收机制的语言和框架中,这些对象必须自己处理,例如利用C++中的delete语句。

托管堆上的存放已生成的引用对象的具体数据,同时维护一个指针指向最后生成对象的末端,供下一次对象创建的时候确定位置和判断剩余空间是否满足新对象的大小。在GC每一次执行清理的时候,首先会假设所有托管堆上的对象都是可回收的。当然要是这样就坏了因为毕竟有些对象在使用,所以.Net采用了根集合(Root Collection)的方式进行处理,根集合中的包括的对象有静态对象、全局对象、CPU寄存器存储的对象以及其他强引用对象。。

GC机制判断那些对象可用(usable),在.Net中这个术语叫做可达(reachable),即循着跟对象我们能找到某个对象,换句话说它被根对象或者其他动态生成的对象持有一个引用。这样查找过一遍之后,所有“不可达”的对象就是清理的目标。之后的事情在上面提到过,即它们将会被清理+归并,以压缩内存空间。综合这些操作考虑,.Net中GC的效率较高的原因在于:

  • 对托管堆上所有对象遍历进行可达性标记的过程,其复杂度固定为O(n),但由于是分代进行,所以尽量减少了检查及清理的数量
  • 每一次回收后,对象所在内存区域都是连续的

另外,GC上进行各种操作的部分应该是多线程进行的,为了保证对象安全性,在GC运行的时候.Net会直接Stop the World进行操作,一般来说是中断,但也有用到Richter大神那篇文章里面提到的其他机制,例如Hijacking和Safe Points等等。

Finalize和析构

Finalize方法在CLR中的实现形式如同C++中的析构函数,即“~”加上类名。但请注意,托管对象是不能被析构的,它们只能进行Finalize然后等待GC的清理操作。这个方法虽然用起来像析构,但绝对不是析构。.Net的各种大牛都会教导我们:尽量不要覆盖Finalize方法。原因无外乎两点:

  • 空间损耗。Finalize覆盖后,对象会被提升代数,获得更久的生存期,但真的需要这么做吗?这就是问题所在。另外如果引用了其他没有覆盖Finalize方法的对象后,还会造成关联对象的延迟清理。
  • 时间损耗。

所以如果真的覆盖这个方法的话,就让它执行时间尽量短吧。另外编译器也不会自动帮你在子类中补完或自动调用基类中覆盖了的Finalize,一切都要靠自己了。

这么一看Finalize似乎百害而无一利了?并非如此,如果你的托管对象调用了非托管对象的话,.Net不会好心将非托管对象所占用的各种资源一并帮你释放掉,所以你需要自己实现托管对象的Finalize,在这个会在某一时刻调用的方法内进行资源释放。但老实说,如果你的类对于资源敏感,又要提供给别人使用,光留下一个清理资源的Finalize方法也让别人没法下嘴啊(Finalize是不可以被手动调用的),所以一定要覆盖或者提供一个Close类型的方法,可以让使用者手动释放资源。比如.Net框架内的各种Stream类,都实现了IDisposable接口。

那么在CLR内部Finalize的实现又如何呢?对于自己实现了Finalize方法的类来说,除了GC Heap之外,它们还有另外一个去处,叫做Freachable Queue。这个队列配合另外的Finalization Queue(真正的死亡队列……)可以巧妙地实现托管/非托管资源都能完全释放的目的。在GC执行回收动作时,和刚才说的一样,所有不可达的对象都会被清理掉。其中CLR会判断出实现Finalize的类,将其放入Freachable Queue,由另外一个线程遍历其中的对象,一一执行其资源释放的方法。不过进入了Freachable Queue后,对象就变成根对象了,也就是说如果还没来得及执行Finalize也没有问题,下次的垃圾清理对于根对象是不会直接干掉的。

需要注意的是,一般来说对象变成不可达对象后,只会进入到Freachable Queue一次,所以Finalize也只会跑一次。这么看的话在这个方法中执行任何造成强引用的语句是非常不安全的,因为被引用的对象可能无法被释放。为此GC.ReRegisterForFinalize方法可以让对象重新进入Freachable Queue,解决这类问题。但老实说我之前从未覆盖过Finalize,也没有用过GC.ReRegisterForFinalize,所以也提供不了具体的使用场景。

刚刚提到Finalize不可以手动调用,那么最后说说Finalize的(被)调用时机[2]:

  • 0代对象Heap满了,这也是最常见的场景
  • 手动调用了GC.Collect()
  • Windows内存不足。CLR接到了CreateMemoryResourceNotification或者QueueMemoryResourceNotification机制带来的低内存通知
  • CLR卸载一个应用程序域(AppDomain)
  • CLR整体Shutting Down时候。

GC实现方式

上面是对.Net内GC机制一些细节的总结,这一节则是学习有关GC知识的笔记。之前面试中问过一些有关GC的问题,例如“C/C++上为什么不自带GC机制?”“如果希望实现一个的话采用什么思路?”“是否了解.Net或者Java平台上的GC机制”等等,但很可惜,大家都跟我一样不太了解这块的知识。对于C/C++程序员来说不了解GC机制正常,但应该具有设计一个自动化资源管理框架(不一定是GC)的能力;而靠托管平台、拥有GC的平台吃饭的程序员应该了解一些底层的知识。

分代

分代GC应该是最新的、得到系统论证的GC方式,在资源有限的情况下其效率普遍比其他方法要高。具体细节请参考上一节。

引用计数

引用计数(RC)是智能指针的实现基础之一,也是最经典的自动管理内存方式。引用计数的实现简单,但问题在于,进行计数的话我们只知道对象被引用了多少次,但不能知道谁引用了这个对象。所以就造成了最经典的问题,RC中出现循环引用导致内存泄露。目前一些脚本语言采用RC方式进行自动化资源管理,例如Python和Javascript[5],前者的实现用RC确保对象的快速清理,但也用到了标记清理循环引用的对象;后者则是完全基于RC的管理机制,结果我们也看到了,在一些实现有问题的引擎上,Javascript可能会出现内存泄漏,而其最经典的特性之一——闭包——也有可能造成内存泄露。

Objective C语言和iOS开发框架内,似乎也用的是RC方式进行内存管理,自己实现的类要确保引用数和引用释放数量的对等。当然可能这么说不准确,毕竟只写过几百行代码,没有实际应用。

标记(染色)

标记也是现代GC机制中必不可少的一环。例如Lua语言的GC实现就充分利用标记,……以下总结自参考资料里面云风大牛的系列文章[4],其中也有具体的代码层次Lua GC实现机制:

它采用三色方式(黑白灰)分别对各个对象进行染色,白色表示无引用,黑色表示正在用,而灰色是未检查的对象。
当然其中还会掺杂强弱引用的概念,导致实际的“颜色”远大于3种。
Lua中实际的清理也是分布进行的,先标记再下手清除。

这一点上和.Net上的GC对于每一代对象判断是否可达再清除别无二致。一般来说这两个步骤的复杂度都为O(n),应该算是比较安定的操作。

拷贝

拷贝的内存清理机制是将内存分为两块,其中一块永远保持空闲。一次清理后会将存活对象顺序拷贝到空闲区域。好处在于省下了内存压缩的时间,但空间消耗的代价太大了。

参考资料

  1. Garbage Collection—Part 2: Automatic Memory Management in the Microsoft .NET Framework, Jeffrey Richter
  2. CLR via C#, Jeffrey Richter
  3. .Net 垃圾回收机制原理(一), yukaizhao
  4. 引用计数与垃圾收集之比较, 云风
  5. Javascript: The Definitive Guide, 4th Edition
  6. Why Java and Python garbage collection methods are different?

2 Responses to “.NET的GC机制和GC知识学习”

  1. […] 将近一年前的.NET的GC机制和GC知识学习提到过: 另外,GC上进行各种操作的部分应该是多线程进行的,为了保证对象安全性,在GC运行的时候.Net会直接Stop the World进行操作,一般来说是中断,但也有用到Richter大神那篇文章里面提到的其他机制,例如Hijacking和Safe Point等等。 […]

  2. […] 在Blog上记录过两篇和.NET中GC机制相关的学习笔记(这里和这里),但都是从概念上泛泛而谈,所学习、参考和引用的资料也都是从概念上进行介绍,并没有涉及到代码层面的实现。由于工作中也用不到,所以没有继续关注下去。 […]