[转载].net框架读书笔记—CLR内存管理\垃圾收集 – 恒星的恒心 – 博客园.
一、垃圾收集平台基本原理解析
在C#中程序访问一个资源需要以下步骤:
- 调用中间语言(IL)中的newobj指令,为表示某个特定资源的类型实例分配一定的内存空间。
- 初始化上一步所得的内存,设置资源的初始状态,从而使其可以为程序所用。一个类型的实例构造器负责这样的初始化工作。
- 通过访问类型成员来使用资源。
- 销毁资源状态,执行清理工作。
- 释放托管堆上面的内存,该步骤由垃圾收集器全权负责,值类型实例所占的内存位于当前运行线程的堆栈上,垃圾收集器并不负责这些资源的回收,当值类 型实例变量所在的方法执行结束时,他们的内存将随着堆栈空间的消亡而自动消亡,无所谓回收。对于一些表示非托管的类型,在其对象被销毁时,就必须执行一些 清理代码。
当应用程序完成初始化后,CLR将保留(reserve)一块连续的地址空间,这段空间最初并不对应任何的物理内存(backing storage)(该地址是一段虚拟地址空间,所以要真正使用它,它必须为其“提交”物理内存),该地址空间即为托管堆。托管堆上维护着一个指针,暂且称 之为NextObjPtr。该指针标识着下一个新建对象分配时在托管堆中所处的位置。刚开始的时候,NextObjPtr被设为CLR保留地址空间的基地 址。
中间语言指令newObj负责创建新的对象。在代码运行时,newobj指令将导致CLR执行以下操作:
- 计算类型的所有实例字段(以及其基类型所有的字段)所需要的字节总数。
- 在前面所的字节总数的基础上面再加上对象额为的附加成员所需的字节数:一个方法指针和一个SyncBlockIndex。
- CLR检查保留区域中的空间是否满足分配新对象所需的字节数—–如果需要则提交物理内存。如果满足,对象将被分配在NextObjPtr指 针所指的地方。接着类型的实例构造器被调用(NextObjPtr会被传递给this参数),IL指令 newobj(或者说new操作符)返回其分配内存地址。就在newobj指令返回新对象的地址之前,NextObjPtr指针会越过新对象所处的内存区 域,并指示出下一个新建对象在托管堆中的地址。
下图演示了包含A,B,C三个对象的托管堆,如果再分配对象将会被放在NextObjPtr指针所演示的位置(紧跟C之后)
在C语言中堆分配内存时,首先需要遍历一个链表数据结构,一旦找到一个足够大的内存块,该内存块就会被拆开来,同时链表相应节点上的指针会得到 适当的调整。但是对于托管堆来说,分配内存仅仅意味着在指针上增加一个数值—显然要比操作链表的做法快许多,C语言都是在找到自由空间为其对象分配内 存,因此连续创建几个对象,他们将很有可能被分散在地址空间的各个角落。但是在托管堆中,连续分配的对象可以保证它们在内存中也是连续的。
就目前来看托管堆在实现的简单性和速度方面要远优于C语言的运行时中的堆。之所以这样是因为CLR做了大胆的假设—那就是应用程序的地址空 间和存储空间是无限的,显然这是不可能的。托管堆必须应用某种机制来允许这种假设。这种机制就是垃圾回收器。
当应用程序调用new创建对象时,托管堆可能没有足够的地址空间来分配该对象。托管堆通过将对象所需要的字节总数添加到NextObjPtr指 针表示的地址上来检测这种情况。如果得到的结果超出了托管堆的地址空间范围,那么托管堆将被认为已满,这时就需要垃圾收集器。,其实这种描述是过于简单 的,垃圾回收与对象的代龄有着密切的关系,还需继续学习垃圾收集。
二、垃圾收集算法
垃圾收集器通过检查托管堆中是否有应用程序不再使用的对象来回收内存。如果有这样的对象,它们的内存将被回收。那么垃圾收集器是这样知道应用程 序是否正在使用一个对象呢??还得继续学习。
每一个应用程序都有一组根(root),一个根是一个存储位置,其中包含着一个指向引用类型的内存指针。该指针或者指向一个托管堆中的对象,或 者被设置为null。例如所有的全局引用类型变量或静态引用类型都被认为是根。另外,一个线程堆栈上所有引用类型的本地变量或者参数变量也被认为是一个 根。最后,在一个方法内,指向引用类型对象的CPU寄存器也被认为是一个根。
当垃圾收集器开始执行时,它首先假设托管堆中的所有对象都是可收集的垃圾。换句话,垃圾收集器假设应用程序中没有一个根引用着托管堆中的对象。 然后垃圾收集器便利所有的根,构造出一个包含所有可达对象的图。例如,垃圾收集器可能会定位出一个引用托管对象的全局变量。下图展示了分配有几个对象的托 管堆,其中对象A,C,D,F为应用程序的根所直接引用。所有这些对象都是可达对象图的一部分。当对象D被添加到该图中时,垃圾收集器注意到它还引用着对 象H,于是对象H被添加到该图,垃圾回收器就这样子以递归的方式来遍历应用程序中所有的可达对象。
一旦该部分的可达对象完成后,垃圾回收器将检查下一个根,并遍历其引用的对象。当垃圾回收器在对象之间进行遍历时,如果发现某对象已经添加到可 达对象图中时(比如上图中的H,在检查D的时候已经将其添加到了可达对象图),它会停止沿着该对象标识的路径方向上遍历的活动。两个目的:
- 可以避免垃圾收集器对一些对象的多次遍历,可高性能。
- 如果两个对象之间出现了循环引用,可以避免遍历陷入无限循环(比如上图中D引用着H,而H又引用着D)。
垃圾收集器一旦检查完所有的根,其得到的可达对象将包含所有从应用程序的根可以访问的对象。任何不在该图中的对象将是应用程序 不可访问的对象,不可达的对象,因此也是可以被执行垃圾收集器的对象。垃圾收集器接着线性地遍历托管堆以寻找包含可收集垃圾对象的连续区域。
PS:CLR的垃圾收集机制对我来说有点非主流,在此之前,我一直认为是垃圾收集器直接去寻找不可达的对象,现在看来垃圾收集 器使用了逆向思维,通过找到可达对象来找到不可达的对象(这个原因还得继续思考)。
如果找到了较大的连续区域,垃圾收集器将会把内存中的一些非垃圾对象搬移到这些连续区块中以压缩堆栈,显然搬移内存中的对象将 使所有这些指向对象的指针变的无效。所以垃圾收集器必须修改应用程序的根以使它们指向这些对象更新后的位置。另外,如果任何对象包含有指向这些对象的指 针,那么垃圾收集器也会负责矫正它们。托管堆被压缩以后,NextObjPtr指针将被设为指向最后一个非垃圾对象之后。下图展示了对于上面图执行垃圾收 集器后的托管堆。
可见垃圾回收器对于应用程序的性能有不小的影响,CLR采用了代龄等措施来优化了性能(以后学习)。
因为任何不从应用程序的根中访问的对象都会在某个时刻被收集,所以应用程序将不可能发生内存泄漏,另外应用程序也不可能再访问已经被释放的对 象。因为如果对象可达,它将不可能被释放;而如果对象不可达,应用程序必将无法访问到它。
下面代码演示了垃圾收集器是这样分配管理对象的:
class Program { static void Main(string[] args) { //在托管堆上ArrayList对象,a现在就是一个根 ArrayList a = new ArrayList(); //在托管堆上创建10000个对象 for (int i = 0; i < 10000; i++) { a.Add(new Object());//对象被创建在托管堆上 } //现在a是一个根(位于线程堆栈上)。所以a是一个可达对象 //,a引用的10000个对象也是可达对象 Console.WriteLine(a.Count); //在a.Count返回后,a便不再被Main中的代码所引用, //因此也就不再是一个根。如果另外一个线程在a.Count的结果被 //传递给WirteLine之前启动了垃圾收集,那么a以及它所引用的10000个对象将会被回收。 //上面for里面的变量i虽然在后面的代码中不再被引用,但由于它是一个值类型,并不存在于 //托管堆中,所以它不受垃圾收集器的管理,它在Main方法执行完毕后会随着堆栈的消失而自动 //被系统回收 Console.WriteLine("End of method"); } }
CLR之所以能够使用垃圾回收机制,有一个原因是因为托管堆总是能知道一个对象的实际类型,从而使用其元数据信息来判断一个对象的那些成员引用 着其他对象。