CLR via C#-托管堆和垃圾回收
托管堆基础
访问类型的资源
面向对象的环境中,每个类型都代表可供程序使用的一种资源。要使用这些资源,必须为代表资源的类型分配内存。以下是访问一个资源所需的步骤。
①调用IL指令newobj,为代表资源的类型分配内存,由new操作符来完成。
②初始化内存,设置资源的初始状态并使资源可用,类型的实例构造器负责设置初始状态。
③访问类型的成员来使用资源。
④摧毁资源的状态以进行清理。
⑤释放内存,垃圾回收器独自负责这一步。
托管堆为开发人员提供了一个简化的编程模型,分配并初始化资源并直接使用。
大多数类型都无需资源清理,垃圾回收器会自动释放内存。在类中也可以调用Dispose方法立即进行垃圾回收。
从托管堆分配资源
CLR要求所有对象都从托管堆分配
进程初始化时,CLR划出一个地址空间区域作为托管堆。
NextObjPtr指针
CLR还要维护一个指针,称为NextObjPtr,这个指针指向下一个对象再堆中的分配位置。刚开始时,NextObjPtr设为地址空间区域的基地址。
一个区域被非垃圾对象填满后,CLR会分配更多的区域,这个过程一直重复,直至整个进程地址空间都被填满。
所以你的应用程序的内存受进程的虚拟地址空间的限制,32位进程最多能分配1.5GB,64位进程最多能分配8TB。
new操作符使CLR执行的工作
①计算类型的字段,包括从基类继承的字段所需的字节数。
②加上对象的开销所需的字节数。每个对象都要两个开销字段,类型对象指针和同步块索引。
③CLR检查区域中是否有分配对象所需要的字节数,如果托管堆空间足够,就在NextObjPtr指针指向的地址放入对象,为对象分配的字节会被清零。
④接着调用类型的实例构造器,为this参数传递NextObjPtr,new操作符返回对象引用。返回之前,NextObjPtr指针的值会加上对象占用的字节数来得到一个新值,即下个对象放入托管堆时的地址。
下图展示了包含三个对象的一个托管堆,如果要分配新对象,他将放在NextObjPtr指针指向的位置,紧接在对象C后。
垃圾回收算法
引用计数法
许多系统采用了引用计数法来管理对象的生存期,堆上的每个对象都维护着一个内存字段来统计程序中多少部分在使用对象。
如果有些部分不再需要的对象,就递减对象的计数字段。计数字段变成0,对象就可以从内存中删除了。
但是面对互相持有的情况,计数器就会永远不为0。鉴于引用计数垃圾回收算法存在的问题,CLR改为使用一种引用跟踪法。
引用跟踪法
引用跟踪算法只关心引用类型的变量,因为只有这种变量才能引用堆上的对象,值类型变量直接包含值类型实例。我们将所有的引用类型的变量都称为根。
①暂停所有线程
垃圾回收时首先暂停进程中所有线程,防止检查期间访问对象并改变其状态。
②垃圾回收标记阶段
CLR遍历堆中所有对象,将同步块索引字段中的一位设为0,表示此对象应删除。
③CLR检查所有活动根
查看他们引用了哪些对象。如果一个根包含null,CLR忽略这个根并继续检查下个根。
任何根如果引用了堆上的对象,CLR都会标记那个对象,将该对象的同步块索引中的位设为1。
一个对象被标记后,CLR会再检查那个对象中的根,标记他们引用的对象。
如果发现对象已经被标记,就不重新检查对象的字段,避免了因为循环引用而产生死循环。
检查完毕后堆中对象已标记的对象不能被垃圾回收,这种对象被称为可达的,未标记的对象是不可达的。
④垃圾回收压缩阶段
CLR将堆中已标记的对象压缩,使它们占用连续的内存空间。
压缩后,根引用的还是对象最初在内存中的位置,所以CLR还要从每个根减去所引用的对象在内存中偏移的字节数。
包装根引用的还是之前的对象,只是对象在内存中换了位置。
⑤移动NextObjPtr指针
压缩结束后NextObjPtr指针指向最后一个幸存对象之后的位置。
下一个对象将分配在这个位置。
⑥CLR恢复应用程序的所有线程
如果CLR在一次GC之后回收不了内存,而且进程中没有空间来分配新的GC区域,就说明该进程的内存已耗尽。
代
CLR的垃圾回收是基于代的垃圾回收器,并遵循以下原则
①对象越新,生存期越短。
②对象越老,生存期越长。
③回收堆的一部分,速度快于回收整个堆。
代的原理
①托管堆在初始化时不包含对象,添加到堆的对象称为第0代对象,也就是那些新构造的对象,GC从未检查过他们。
下图展示一个新启动的应用程序,他分配了五个对象,运行一段时间后,对象C和E变得不可达。
CLR初始化时位第0代对象选择一个预算容量,以KB位单位。如果分配一个新对象造成第0代超过预算就必须启动一次垃圾回收。
②假设A到E刚好用完第0代的空间,那么分配对象F就必须启动垃圾回收。
垃圾回收器判断C和E是垃圾,压缩D使之与B相邻,在垃圾回收中存活的对象B和D现在成为第1代对象。
一次垃圾回收后第0代就不包含任何对象了,新对象会分配到第0代中。
③程序继续运行,分配了对象F到K,运行一段时间后B,H和J变得不可达。
④假定现在分配新对象L会造成第0代超出预算,必须进行垃圾回收。垃圾回收器根据预算容量检查代。
对象越新生存期越短。因此第0代包含更多垃圾的可能性更大,能回收更多的内存。
选择忽略第1代中的对象,可以加快垃圾回收速度,不必遍历托管堆中的每个对象。
如果根或对象引用了老一代的某个对象,垃圾回收器就可以忽略老对象内部的所有引用,能在更短的时间内构造好可达对象图。
为了确保对老对象的已更新字段进行检查,垃圾回收器在对象的引用字段发生变化时,会设置一个对应的位标志。
这样就知道自上次垃圾回收以来那些老对象的字段是否发生变化,对变化的老对象检查是否引用了第0代中的任何对象。
所有幸存的第0代对象都成为了第1代的一部分。即使对象B已经不可达,但也没有被垃圾回收。
⑤假设应用程序继续运行,分配对象L到O,此外停止使用对象G、L和M使他们不可达。
⑥假设分配对象P导致第0代超过预算,垃圾回收发生。
第1代中所有的对象占据的内存仍小于预算所以垃圾回收器再次决定只回收第0代。
⑦第1代正在缓慢增长,假定第1代的增长导致他的所有对象占用了全部预算。
这时应用程序继续运行,并分配对象P到S,使第0代对象达到他的预算容量。
⑧应用程序试图分配对象T时,由于第0代已经满了,所以必须开始垃圾回收。
但这一次垃圾回收器发现第1代占用了太多内存,以至于用完了预算。
所以垃圾回收器觉得需要检查第1代和第0代中的所有对象。
和之前一样,第0代的幸存者被提升到第1代,第1代的幸存者被提升至第2代,第0代再次空出来了。
托管堆只支持三代
CLR初始化会为每一代选择预算,但是CLR的垃圾回收是自调节的,垃圾回收器会在执行GC是了解应用程序的行为。
假如应用程序构造了许多对象,但每个对象用的时间都很短,此时就会对第0代垃圾回收。
如果垃圾回收器发现在回收第0代后存活下来的对象很少,就可能减少第0代的预算。
分配空间的减少意味着垃圾回收更加频繁,但垃圾回收器每次工作量也小了。
例如第0代中所有对象都是垃圾,只需让NextObjPtr指针回到第0代起始处即可。
同样的,垃圾回收器发现在回收第0代后存活下来的对象很多,就可能增加第0代的预算。这对第1代和第2代同样适用。
垃圾回收触发条件
①代码显式调用Syste.GC的静态Collect方法
②Windows报告低内存情况
③CLR正在写卸载AppDomain
④CLR正在关闭,进程终止
大对象
CLR将对象分为大对象和小对象。85000字节以上的对象是大对象。
①大对象不是在小对象的地址空间分配,而是在进程地址空间的其他地方分配。
②目前的GC不压缩大对象,在内存中移动他们代价过高。
③大对象总是第2代,不可能是第1代或第0代。所以只能为需要长时间存活的资源创建大对象。
大对象一般是大字符串,比如XML、JSON或者I/O操作的字节数组。
垃圾回收模式
CLR启动时会选择一个GC模式,进程中之前不会改变。
①工作站,针对客户端优化,GC延时低。
②服务器,针对服务器端应用程序。
应用程序默认以工作站GC模式运行。