《CLR via C#》 托管堆和垃圾回收

托管堆基础

访问一个资源所需要的步骤:

  1. 调用IL指定newobj,为代表资源的类型分配内存(一般用C#new操作符来完成)。
  2. 初始化内存,设置资源的初始状态并使资源可用。类型的实例构造器负责设置初始化状态。
  3. 访问类型的成员来使用资源(有必要可以重复)。
  4. 摧毁资源的状态以进行清理。
  5. 释放内存。垃圾回收器独自负责这一步骤。

提示

只要是写可验证的、类型安全的代码(不使用C#unsafe),应用程序就不可能会出现内存被破坏的情况。但内存仍有可能泄露,但不像C/C++那样是默认行为。现在内存泄漏一般是因为在集合中存储了对象,但不需要对象的时候一直不删除

从托管堆中分配资源

CLR要求所有对象都从托管堆分配。进程初始化时,CLR划出一个地址空间区域作为托管堆。CLR还要维护一个指针NextObjPtr,该指针指向下一个对象在堆中的分配位置。刚开始的时候NextObjPtr设为地址空间区域的基地址。

一个区域被非垃圾对象填满后,CLR会分配更到的区域。这个过程一直重复,直至整个进程地址空间都被填满。所以,应用程序的内存受进程的虚拟地址空间的限制。32位进程最多分配1.5GB,64位进程最多分配8TB。

new操作符的执行细节:

  1. 计算类型的字段(以及从基类型继承的字段)所需的字节数。
  2. 加上对象的开销所需的字节数。每个对象都有两个开销字段:类型对象指针和同步块索引。
  3. CLR检查区域中是否有分配对象所需的字节数。如果托管堆有足够的空间,就在NextObjPtr指针指向的地址处放入对象,为对象分配的字节没被清零。接着调用类型的构造器(为this参数传递NextObjPtr),new操作符返回对象引用。在返回引用前,NextObjPtr指针的值会加上对象占用的字节数来得到一个新值,即下个对象放入托管堆时的地址。

托管堆的优点

对于托管堆,分配对象只需要在指针上加一个值(速度非常快)。在许多应用程序中,差不多同时分配的对象彼此间有较强的联系,而且经常差不多在同一时间访问。由于托管堆在内存中连续分配对象,所以因为引用“局部化”(locality)而获得性能上的提升。具体来说,这意味着工作集会非常小,应用程序只需要使用很少的内存,从而提高了速度。还意味着代码使用的对象可以全部驻留在CPU的缓存中。结果是应用程序能以非常快的速度访问这些对象,因为CPU在执行大多数操作时,不会因为“缓存未命中”(cache miss)而被迫访问较慢的RAM。

发挥此优点的前提是内存无限,但内存不可能无限,所以CLR用“垃圾回收”(GC)的技术“删除”堆中应用程序不再需要的对象。

垃圾回收算法

应用程序调用new操作符创建对象时,可能没有足够的空间来分配该对象。发现空间不够,CLR就执行垃圾回收(实际上,垃圾回收是在第0代满的时候发生的)。

引用计数算法

在引用计数算法中,堆上的每个对象都维护着一个内存字段来统计程序中有多少正在使用的对象。随着每一个对象到达代码中某个不再需要对象的地方,就递减对象的计数字段。计数字段变成0,对象就可以从内存中删除了。引用计数系统最大的问题就是处理不好循环引用

引用跟踪算法

引用跟踪算法只关心引用类型的变量,因为只有这种变量才能引用堆上的对象;值类型变量直接包含值类型实例。引用类型变量可以在许多场合使用,包括类的静态和实例字段,或者方法的参数和局部变量。将所有引用类型的变量都称为

引用跟踪算法步骤:

  1. CLR开始GC时,首先暂停进程中所有线程(防止线程在CLR检查期间修改对象的状态)。
  2. CLR进入GC的标记阶段。CLR遍历堆中的所有对象,将同步块索引字段中的一位设为0,这编码所有对象都应删除。
  3. CLR检查所有活动根,查看它们引用了哪些对象。如果一个根包含null,CLR忽略这个根并继续检查下一个根。任何根如果引用了堆上的对象,CLR都会标记那个对象,也就是将该对象的同步块索引中的位设置为1.一个对象被标记后,CLR会检查那个对象中的跟,标记它们引用的对象。如果发现对象已经标记,就不重新检查对象的字段。这就避免了应为循环引用而产生的死循环。

回收之前的托管堆

图1 回收之前的托管堆

标记过后的托管堆

图2 标记过后的托管堆
  1. 检查完毕后,堆中的对象要么已标记,要么未标记。已标记的对象不能被垃圾回收,因为至少有一个根在引用它。这种状态被称为可达的(reachable)。未标记的对象是不可达(unreachable)的。
  2. CLR进入GC的压缩(compact)接端。这个接端,CLR对堆中已标记的对象进行移动,压缩幸存的对象,使它们占用连续的内存空间。但根所引用的位置还未改变,所以CLR需要从每个根减去所引用的对象在内存中偏移的字节数。这样就能保证每个根还是引用和之前一样的对象;只是对象在内存中变化了位置。

压缩的好处

首先,所有幸存对象在内存中紧挨在一起,恢复了引用的“局部化”,减小了应用程序的工作集,从而提升了将来访问这些对象时的性能。其次,可用空间也全部时连续的,允许其他东西进驻。最后,压缩意味着托管堆解决了本机(原生)堆的空间碎片化问题。

6. 压缩好内存后,托管堆的`NextObjPtr`指针指向最后一个幸存对象之后的位置。

垃圾回收后的托管堆

图3 垃圾回收后的托管堆

注意

如果CLR在一个GC之后回收不了内存,而且进程中没有空间来分配新的GC区域,就说明进程的内存已耗尽。此时,试图分配更多的内存的new操作符就会抛出OutOfMemoryException

注意

静态字段引用的对象一直存在,直到用于加载类型的AppDomain卸载为止。内存泄漏的一个常见原因就是让静态字段引用某个合集对象。静态对象会使合集对象一直存存活,而合集对象使所有的数据项一直存活。从而导致内存泄漏。

代:提升性能

CLR的GC是基于代的垃圾回收器(generational garbage collector),它对代码做出了几点假设。

  • 对象越新,生存期越短。
  • 对象越老,生存期越长。
  • 回收堆的一部分,速度快于回收整个堆。

托管堆在初始化时不包含对象。添加到堆的对象称为第0代对象。简单地说,第0代对象就是那些新构造的对象,垃圾回收器从未检查过它们。

新初始化的堆,所有对象都是第0代,垃圾回收尚未发生

图4 新初始化的堆,所有对象都是第0代,垃圾回收尚未发生

CLR初始化时为第0代对象选择一个预算容量(KB单位)。如果分配一个新对象造成第0代超过预算,就必须启动一次垃圾回收。假设上图A到E正好使用完第0代空间,分配对象F时就必须启动垃圾回收。垃圾回收器会判断对象C和E时垃圾,所以会压缩D,使之与对象B相邻。在垃圾回收中存活的对象(A,B和D)现在称为第1代对象。第1代对象已经经历了一个垃圾回收器的检查。

经历一次垃圾回收,第0代的幸存者被提升至第1代

图5 经历一次垃圾回收,第0代的幸存者被提升至第1代

一次垃圾回收后,第0代就不包含任何对象了。和之前一样,新对象会分配到第0代中。

第0代分配了新对象;第1代又垃圾产生

图5 第0代分配了新对象;第1代又垃圾产生

假定分配新对象L会造成第0代超出预算,造成垃圾回收。CLR需要决定检查哪些代。CLR初始化时不仅会给第0代对象选择预算,第1代对象也得又预算。假如开启垃圾回收时,第1代占用内存少于预算,垃圾回收只检查第0代对象。由于忽略了第1代对象,所以加快了垃圾回收速度。

性能的优势和对应的问题

显然,忽略第1代对象能提升性能。对性能提升的部分更主要是不必变量托管堆中的每个对象。如果根或对象引用了第1代的某个对象,垃圾回收器就会忽略老对象内部的所有引用,能更短的构造好可达对象图。当然,老对象字段也有可能引用新对象。为了确保对老对象的已更新字段进行检查,垃圾回收器利用了JIT编译器内部的一个机制。这个机制在对象的引用字段发生变化时,会设置一个对应的位标志。这样,垃圾回收器就知道自上一次垃圾回收以来,哪些老对象已被写入。只有字段发生变化的老对象才需要检查是否引用了第0代中的任何新对象

基于对象越老,生存期越长的假设。第1代对象在应用中很可能是继续可达的。因此对第1代垃圾回收很可能是浪费时间。如果有垃圾在第1代中,那就留在那里。

经过两次垃圾回收后,第0代的幸存者被提升到第1代(第1代的大小增加);第0代又空出来了

图6 经过两次垃圾回收后,第0代的幸存者被提升到第1代(第1代的大小增加);第0代又空出来了

多次第0代回收后,第1代超出预算。两代一起进行垃圾回收

图7 多次第0代回收后,第1代超出预算。两代一起进行垃圾回收

第1代幸存者提升至第2代,第0代幸存者提升至第1代,第0代空出

图8 第1代幸存者提升至第2代,第0代幸存者提升至第1代,第0代空出

注意

托管堆只支持三代。CLR初始化时会为每一代选择预算。然而,CLR的垃圾回收器是自调节的。这意味着垃圾回收器会在执行垃圾回收过程中了解应用程序的行为。对预算做响应调整。

垃圾回收的触发条件

  • 代码显式调用System.GC的静态Collect方法。
  • Windows报告低内存情况
  • CLR正在卸载AppDomain
  • CLR正在关闭

大对象

CLR将对象分为大对象和小对象。大于85000字节的对象是大对象。

  • 大对象根小对象不在一起分配。
  • GC不压缩大对象,因为移动代价过高。
  • 大对象总是第2代,只能为需要长时间存活的资源创建大对象。分配短时间存活的大对象会导致第2代被更频繁的回收,影响性能。

垃圾回收模式

CLR启动时会选择一个GC模式,进程终止前该模式不会改变。基本有两个GC模式。

  • 工作站:对客户端程序进行GC优化。GC延迟低,挂起时间短,避免用户感到焦虑。GC假定机器上运行的其他程序都不会消耗太多CPU资源
  • 服务器:针对服务器进行GC优化。被优化的主要时吞吐量和资源利用率。GC加油机器上没有运行其他应用程序。该模式造成托管堆拆分成几个区域,每个CPU一个。开始垃圾回收时,垃圾回收器在每个CPU上都运行一个特殊线程;每个线程和其他线程并发回收它自己的区域。

除了这两种主要模式,还有两种子模式:并发(默认)或非并发。在并发方式中,垃圾回收器有一个额外的后台线程,它能在应用程序运行时并发标记对象。应用程序线程运行时,垃圾回收器运行一个普通优先级的后台线程来查找不可达对象。找到后,垃圾回收器再次挂起所有线程,判断是否要压缩内存(垃圾回收器倾向于不压缩内存。可用内存多,垃圾回收器便不会压缩堆;这有利于增强性能,但会增大应用程序工作集)。

可使用GCSettings类的GCLatencyMode对垃圾回收进行控制。

符号名称 说明
Batch(“服务器"GC默认值) 关闭并发GC
Interactive(“工作站"GC默认值) 打开并发GC
LowLatency 在短期的、时间敏感的操作中使用这个延迟模式。此操作不适合对第2代进行回收
SustainedLowLatency 这个模式下,应用程序的大多数操作都不会发生长的GC暂停。只有有足够的内存,它将禁止所有会造成阻塞的第2代回收动作。

强制垃圾回收

GC.Collect方法可强制垃圾回收。

1// generation: 最多回收几代
2// mode: 
3//   Default: 等同于Forced
4//   Forced: 强制回收指定的代(以及低于它的所有代)
5//   Optimized: 只有在能释放大量内存或者能减少碎片化的前提下,才执行回收。
6// blocking: 阻塞(非并发)或后台(并发)
7GC.Collect(Int32 generation, GCCollectionMode mode, Boolean blocking)

执行强制垃圾回收的策略

  • 大多数时候都要避免调用任何Collect方法。
  • Default和Forced模式一般用于调试、测试和查找内存泄漏。
  • 发送某个非重复性的事件,并导致大量旧对象死亡,就可以考虑手动调用一次Collect方法。由于是非重复性事件,垃圾回收器基于历史的预测可能变的不准确。

需要特殊清理的类型

包含本机资源的类型被GC时,GC会回收对象在托管堆中使用的内存。但这样会造成本机资源的泄漏。CLR提供了称为终结(finalization)的机制,允许对象在被判定为垃圾后,但在对象内存被回收前执行一些代码。任何包装了本机资源(文件、网络连接、套接字、互斥体)的类型都支持终结。

1internal sealed class SomeType {
2    // Finalize方法
3    ~SomeType() {
4        // 这里的代码会进入Finalize方法
5    }
6}

C#编译器实际是生成了名为Finalizeprotected override方法。方法的主体代码被放到一个try块中,并在finally块中放入了base.Finzlize的调用。

注意

此方法的特殊语法非常类似于C++的析构器。但Finalize的工作方式与C++析构器完全不同。

Finalize方法在垃圾回收后才调用,所以这些对象的内存不是马上被回收的。因为Finalize方法可能要执行访问字段的代码。可终结对象在回收时必须存活,造成它被提升到另一代,使对象活得比正常时间长。这增大了内存消耗,所以应尽量避免终结。更糟的是,可终结对象被提升时,其字段引用的所有对象也会被提升,因为它们也必须存活。所以要尽量避免为引用类型的字段定义可终结对象。

Finalize方法的执行时间是不可控的。CLR也不保证多个Finalize方法的调用顺序。所以在Finalized中不要访问定义了Finalize方法的其他对象;那些对象可能已经终结了。但可以安全的访问值类型的实例,或者访问没有定义Finalize方法的引用类型对象。

终结方法的问题

CLR用一个特殊的、高优先级的专用线程调用Finalize方法来避免死锁。如果Finalize方法阻塞(例如进入死循环,或者等待一个永远不发送的信号),该特殊线程就调用不了任何更多的Finalize方法。应用程序永远回收不了可终结对象占用的内存,持续内存泄漏。如果Finalize方法抛出未处理的异常,则进程终止,没办法捕捉该异常。

终结的内部工作原理

应用程序创建新对象时,new操作符会从堆中分配内存。如果对象的类型定义了Finalize方法,那么在该类型的实例构造器被调用之前,会将指向该对象的指针放到一个终结列表(finalization list)中。终结列表是由垃圾回收器控制的一个内部数据结构。列表中的每一项都指向一个对象,回收该对象的内存前应调用它的Finalize方法。

托管堆的终结列表包含了指向对象的指针

图9 托管堆的终结列表包含了指向对象的指针

注意

虽然System.Object定义了Finalize方法,但CLR知道忽略它。也就是说,构造类型的实例时,如果该类型的Finalize方法时从System.Object继承的,就不会认为这个对象是可终结的。类型必须重写ObjectFinalize方法,这个类型及其派生类型的对象才被认为是可终究的。

垃圾回收开始时,不可达的对象被标记未垃圾。垃圾回收器扫描终结列表以查找对这些对象的引用。找到一个引用后,该引用会从终结列表中移除,并附加到freachable队列。此队列也是垃圾回收器的一种内部数据结构。队列中的每个引用都代码器Finalize方法已经准备好调用。

在托管堆中,一些指针从终结列表移动到freachable队列

图9 在托管堆中,一些指针从终结列表移动到freachable队列

一个特殊的高优先级CLR线程专门调用Finalize方法。专用线程可以避免潜在的线程同步问题。freachable队列未空时,该线程将睡眠。但一旦队列中有记录项出现,线程就会被唤醒,将每一项都从freachable队列中移除,并同时调用每个对象的Finalize方法。

freachable名称的由来

freachable的“f”代表“终结”(finalization);因为freachable队列中的每项都是对托管堆中应调用Finalize方法对象的引用。“reachable”意味着对象是可达的。所以,freachable队列中的引用使它指向的对象保持可达,不是垃圾

当一个对象不可达时,垃圾回收器就把它视为垃圾。但是,当垃圾回收器将对象的引用从终结列表移至freachable队列时,对象不再被认为时垃圾,不能回收它的内存。对象被复活了。

标记freachable对象时,将递归标记对象中的引用类型的字段所引用的对象;所有这些对象也必须复活以便在回收过程中存活。之后,垃圾回收器才结束对垃圾的标识。在这个过程中,一些原本被认为是垃圾的对象复活了。然后垃圾回收器压缩可回收内存,将复活的对象提升到较老的一代。

下次对老一代垃圾回收时,会发现已终结的对象称为真正的垃圾,因为没有应用程序的根指向它们,freachaable队列也不再指向它们。所以,这些对象的内存会直接回收。整个过程中,注意,可中介对象需要执行两次垃圾回收才能释放它们占用的内存

第二次垃圾回收后托管堆的情况

图10 第二次垃圾回收后托管堆的情况