应用程序调用 new 操作符创建对象时,可能没有足够的地址空间来分配该对象。发现空间不够,CLR(公共语言运行库,是整个.NET框架的核心,它为.NET应用程序提供了一个托管的代码执行环境。)就会执行垃圾回收。
对于对象生存期的管理,有的系统采用的是某种引用计数算法。事实上,Microsoft自己的“组件对象模型”(Component Object Mode,COM)用的就是引用计数。在这种系统中,堆上的每个对象都维护着一个内存字段来统计程序中哪些“地方”正在适用对象。随着每一个“地方”到达代码中某个不再需要对象的地方,就递减对象的计数字段。计数字段变成0,对象就可以用内存中删除了。许多引用计数系统最大的问题是处理不好循环引用。例如在GUI应用程序中,窗口将容纳对子UI元素的引用,而子UI元素将容纳对父窗口的引用。这种引用会阻止两个对象的计数器达到0,所以两个对象永远不会删除,即使应用程序脚本不再需要窗口了。
鉴于引用计数垃圾回收器算法存在的问题,CLR改为使用一种引用跟踪算法。引用跟踪算法只关心引用类型的变量,因为只有这种变量才能引用堆上的对象;值类型变量直接包含值类型实例。引用类型变量可在许多场合使用,包括类的静态和实例字段,或者方法的参数和局部变量。我们将所有引用类型的变量都成为根。
CLR开始GC时,首先暂停进程中的所有线程。这样可以防止线程在CLR检查期间访问对象并更改其状态,然后,CLR进入GC的标记阶段。在这个阶段,CLR遍历堆中的所有对象,将同步块索引(每个对象在被创建的时候,会被自动的添加两个信息,一是类型对象指针,二是同步块索引)字段中的一位设为0。这表明所有对象都应删除。然后,CLR检查所有活动根,查看他们引用了哪些对象。这正是CLR的GC称为引用跟踪GC的原因。如果一个根包含null,CLR忽略这个根并继续检查下个根。
任何根如果引用了堆上的对象,CLR都会标记那个对象,也就是将该对象的同步块索引中的位设为1。一个对象被标记后,CLR会检查那个对象中的根,标记他们引用的对象,如果发现对象已经呗标记,就不重新检查对象的字段,这就避免了因为循环引用而产生死循环。
下图展示了一个堆,其中包含几个对象。应用程序的根直接引用对象A,C,D和F。所有对象都已标记。标记对象D时,垃圾回收器发现这个对象含有一个引用对象H的字段,造成对象H也被标记。标记过程会持续,直至应用程序的所有根检查完毕。
检查完毕后,堆中的对象要么已标记,要么未标记。已标记的对象不能被垃圾回收,因为至少有一个根在引用他。我们说这种对象是可达(reachable)的,因为应用程序代码可通过仍在引用它的变量来访问他。未标记的对象是不可达(unreachable)的,因为应用程序中不存在使对象能再次被访问的根。
CLR知道哪些对象可以幸存,那些可以删除后,就进入GC的压缩(或者说是碎片整理)阶段。在这个阶段,CLR对堆中的已标记的对象进行“乾坤大挪移”,压缩所有幸存下来的对象,使它们占用连续的内存空间。这样做有许多好处,首先,所有幸存者在内存中紧挨在一起,恢复了引用的“局部化”,减少了应用程序的工作集,从而提升了将来访问这些对象时的性能。其实,可用空间也全部是连续的,所以这个地址空间区段得到了解放,允许其他东西进驻,最后,压缩意味着托管堆解决了本机(原生)堆的空间碎片化问题。
在内存中移动了对象之后有一个问题亟待解决。引用幸存对象的根现在引用的还是对象最初在内存中的位置,而非移动之后的位置。被暂停的线程恢复执行时,将访问旧的内存位置,会造成内存损坏。这显然是不能忍的,所以作为压缩阶段的一部分,CLR还要从每个根减去所引用的对象在内存中偏移的字节数。这样就能保证每个根还是引用和之前一样的对象,只是对象在内存中变换了位置。
压缩好内存后,托管堆的NextObjPtr指针指向最后一个幸存对象之后的位置。下一个分配的对象将放到这个位置。下图展示后压缩之后的托管堆。压缩阶段完成后,CLR恢复应用程序的所有线程。这些线程继续访问对象,就好像GC没发生过一样。
如果CLR在一次GC之后回收不了内存,而且进程中没有空间来分配新的GC区域,就说明该进程的内存已耗尽。此时,试图分配更多内存的new操作符会抛出OutOfMemoryException。应用程序可捕捉该异常并从中恢复。但大多数应用程序都不会这么做;相反,异常会成为未处理异常,Windows将终止进程并回收进程使用的全部内存。
C#的垃圾回收算法到底做了什么
最新推荐文章于 2024-06-18 03:15:50 发布