C#精要 - GC篇
什么是GC?为什么需要GC?
GC,即Garbage Collection,意为垃圾回收。
.Net不同于原生C++这种需要程序员手动管理内存的机制,存在自动释放内存的一套机制,这就叫GC。
GC可以让程序员不必关心资源的管理,也就是一个对象的生命流程中的4:
- 调用IL指令newobj,为代表资源的类型分配内存(C#中用new操作符完成)。
- 初始化内存,设置资源的初始状态并使资源可用。类型的实例构造器负责设置初始状态。
- 访问类型的成员来使用资源(有必要可以重复)。
- 摧毁资源的状态以进行清理。
- 释放内存。垃圾回收器独自负责这一步。
当然可以自己重写Finalize方法或手动调用GC.Collect。
简单列举GC的优势(对比手动管理)
1.让程序员可以不必关心资源的管理。
2.避免手动管理时,顺序搞错,先销毁后调用导致空引用抛错。
3.避免手动管理时,由于标记引用未清空导致的内存泄露。
讲讲GC算法
三个方向上讲:从总体流程上来说,它有标记 -> 压缩两个阶段;从确定是否销毁的方式上来说,它是从根遍历的;从销毁处理方式上来说,它是分代的。它解决了引用计数法循环引用的问题。
先说下“引用计数法”是什么?为什么不行?
引用计数是COM(Component Object Model)使用的办法,GC并不是用这个,而是用的从根遍历。说的是堆上的每个对象都维护着一个内存字段来统计程序中多少“部分”正在使用对象。随着每一“部分”到达代码某个不再需要对象的地方,就递减这个计数,直到0就可以删除了。
不行的理由很简单,它没有办法处理循环引用的关系,也就是A、B两个对象互相引用,各自引用计数为1,就没法销毁对方。
1.先从流程上说:标记 -> 压缩
标记:这个阶段,其实就是判断对象是否可达的过程。当所有的根都检查完毕后,堆中将包含**可达(已标记)与不可达(未标记)**对象。
标记完成后,进入压缩阶段。
压缩:在这个阶段中,垃圾回收器线性的遍历堆、并对其进行内存碎片整理。让幸存对象都紧挨在一起,使内存的地址空间得到释放。操作完了之后,对引用了这些堆内对象的指针进行偏移,保持引用与之前一致。
压缩结束后,如果本次GC并没有分出足够的内存给接下来的new操作,意味着该进程的内存已耗尽,会抛出OutOfMemoryException。
2.再从确定是否销毁上说:从根遍历
根(Root)
我们将所有引用类型的变量都成为根,类中定义的任何静态字段,方法的参数,局部变量(仅限引用类型变量)等一系列”引用者”,都是根,根是CLR在堆之外可以找到的各种入口点。
可达和不可达(Objects reachable and unreachable)
如果一个根引用了堆中的一个对象,则该对象为“可达”,否则即是“不可达”。不可达的对象是本次销毁的目标。
引用跟踪
也就是标记阶段是怎么标记的。
在这个阶段,CLR会先遍历堆中的所有对象,并全部设置为可回收状态的,然后检查所有活动根,查看他们引用了哪些对象,
如果一个根包含null,CLR会忽略这个根并检查下一个根;
如果根引用了堆上的对象,CLR会标记那个对象,并检查这个对象中的根,继续标记它们引用的对象。如果过程中发现对象已标记,则不重新检查,避免了循环引用而造成的死循环。
检查完毕后,堆中的对象要么已标记,要么未标记。已标记对象的叫做可达的,未标记的对象叫做不可达的。
3.最后说分代回收
CLR一共有3代:0、1、2代。这是在对可达对象进行处理。
分代回收
开始时:CLR初始化会为每一代选择一个预算容量。当有对象新分配时,会塞到第0代。
触发时机:当第0代满了,就会触发GC,没被回收的对象就会成为第1代对象。此时第0代空间中已经不包含任何对象,原来的对象可能已被回收,可能已被放置到第1代中。
触发特点1:当触发了GC时,第1代内的对象远小于它的预算,那么就不会对第1代进行检查。
触发特点2:当触发了GC时,可能存在老对象引用了新对象的可能性(比如1代引用0代)。这时新代回收时没有检查到来自老代的引用就会出现错误的回收。为了解决这个问题,垃圾回收器利用了JIT编译器内部的一个机制,这个机制在对象的引用字段发生变化时,会设置一个对应的标记位,这样一来垃圾回收器就会知道自上一次垃圾回收以来,哪些老对象的引用字段发生了变化,这样就算这次回收只回收新生代,也会去检测引用字段发生了变化的老对象,是否引用了新生代对象。
分代回收的意义
为什么明明是不打算丢弃的可达对象,还要用分代去推出1代、2代呢?全部放0代不就完了?
事实上是,CLR做出了预设:回收堆的一部分,速度快于回收整个堆。事实一般也是如此。
所以只检查一部分,会比每次都检查全部快很多。
代预算的动态调节
在回收第0代后发现存活下来的对象很少,就可能减少第0代的预算,这意味着会更加频繁地执行垃圾回收,但每次回收需要检查的范围更小了。相反,如果回收了第0代后发现还有很多存活的对象,没有多少内存可以回收,就会增大第0代的预算,这样垃圾回收的次数就会减少。
GC的触发时间点
除了上文说到的检测到第0代超出预算的时候会触发垃圾回收,还有以下的:
代码显式调用System.GC的静态Collect方法
代码可显式请求CLR进行垃圾回收,但微软强烈反对这种请求,托管语言应该信任它本身的垃圾回收机制。
我日常会在印刷操作的时候调用它。
Windows报告低内存情况
如果Windows报告低内存,CLR会强制执行垃圾回收。
CLR正在卸载AppDomain
当一个AppDomain卸载时,CLR认为其中一切都不是根,会执行涵盖所有代的垃圾回收。
CLR正在关闭
CLR在进程正常终止时关闭,CLR认为其中一切都不是根,对象有机会进行资源清理,但CLR不会试图压缩或释放内存。进程终止时,Windows会回收进程的全部内存。
大对象
前面讨论的都是小对象,对于大对象(书中说是85000字节以上),CLR会区分对待:
内存不是在小对象的地址空间分配,而是进程地址空间的其他地方分配;总是第2代;目前不支持压缩。
所以GC一般不处理他们。
Finalize方法
System.Object定义了受保护的虚方法Finalize,如果类型重写了这个方法,对象在被GC判定销毁时会调用它。重写它一般是为了清理非托管资源。
1.new时
如果对象的类型定义了Finalize方法,那么这个实例在被构造之前,会将一个指向该对象的指针放到一个终结列表中(finalization list)。
2.被回收时
在被GC回收时,如果发现这个对象在终结列表中,就先不销毁,而是把指向它的指针扔到freachable队列中。
3.在freachable队列时
freachable队列也是垃圾回收器的一种内部数据结构,队列中每一个引用都代表准备要调用Finalize方法的对象。当垃圾回收器把对象的引用从终结列表移到freachable队列时,对象不再被认为是垃圾,我们可以说对象被复活了。同时,该对象内引用的对象也会被标记、复活。
4.复活
说是复活,其实也就是本该销毁的对象延迟销毁了,没有在本该销毁的一次GC下被销毁。
这些复活的对象会被提升到老一代,之后CLR会用特殊的终结线程去调用freachable队列中每个对象的Finalize方法,并清空队列。之后他们就真正被销毁了。
5.复活的思考
由3、4可知,这些定义了Finalize方法的“可终结”的对象,由于在第一次回收时,会被“复活”以执行Finalize方法,并可能会被提升到老一代,所以至少需要执行两次垃圾回收才能释放掉它们占用的托管堆内存,更需要注意到的是,这些对象中的引用字段所引用的对象也会存活下来并提升到老一代,会造成更大的性能负担。所以,要尽量避免为引用类型的字段定义为“可终结”对象。