CLR-21垃圾回收

1 访问资源步骤

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

2 无GC存在的问题

  • 忘记释放内存而造成的内存泄露
  • 访问已释放的内存引起的 bug

3 托管堆分配资源

CLR 要求所有对象都从托管堆分配,进程初始化时,CLR 划出一个地址空间区域作为托管堆,并维护一指针(NextOjbPtr),NextObjPtr 最初指向基地址,主要作用指向下一个对象在堆中的分配位置。一个区域被非垃圾对象填满后,CLR 会分配更多区域,重复直至整个进程地址空间填满,故应用程序的内存受进程的虚拟空间限制。32 位进程最多分配 1.5 GB,64 位进程为 8 TB。

4 new 操作符

  1. 计算类型的字段(及从基类型继承的字段)所需的字节数;
  2. 加上对象的开销所需字节数(每个对象 2 个开销字段:类型对象指针同步块索引,32 位应用程序一个字段需要 32 位即 4 字节,64 位应用程序一个字段需要 64 位即 8 字节);
  3. CLR 检查区域中是否有分配对象所需的字节数,有则在 NextOjbPtr 指针指向的地址存放对象,为对象分配的字节会被清零。调用类型的构造器,new 操作符返回对象引用,并在此之前移动 NextOjbPtr 指针指向下一个对象存放的地址。无则先进行垃圾回收(第 0 代已满情况下),若还没空间分配则抛出 OutOfMemoryException。

5 垃圾回收算法

5.1 引用计数算法(未采用)

堆上每个对象都维护一个内存字段来统计程序中多少“部分”正在使用对象,若“部分”不再需要对象时,则计数递减,直至为零后可删除对象。
存在的问题:循环引用导致对象永远不会删除。

5.2 引用跟踪算法(CLR采用)

引用跟踪算法只计算引用类型的变量,因为只有这种变量(统称为根:类的静态字段、实例字段、方法参数、局部变量)才能引用堆上的对象,值类型直接包含值类型实例。

6 GC流程

  1. 暂停所有线程,防止线程在 GC 检查期间访问对象并更改状态;
  2. CLR 进入 GC 标记阶段:
    2.1. CLR 遍历堆中的所有对象,并将同步块索引字段中的一位设为 0,表明所有对象都应删除;
    2.2. CLR 检查所有活动根,查看它们引用了哪些对象,若为 null 则忽略继续检查下一个根;
    2.3. 任何根若引用堆上对象,则标记那个对象(同步块索引中的一位设为1),若对象被标记,则检查对象的根,标记它们引用的对象,若对象已标记则不检查该对象字段,从而避免循环引用产生的死循环;
  3. 标记阶段结束后,堆中的对象要么已标记(可达 reachable),要么未标记(不可达 unreachable),开始进入 GC 的 压缩(compact,实际上应该称为碎片整理)阶段:
    3.1. CLR 移动堆中已标记对象,使它们占用连续内存空间,恢复引用的“局部化”,减少应用程序工作集,从而提升性能。另外可用空间也是连续的,解决了空间碎片化问题。注意:大对象堆中的对象不会压缩,故还是可能发生地址空间碎片化。
    3.2. CLR 从每个根减去引用对象在内存中便宜的字节数,保证每个根引用的还是之前的对象。
  4. 移动 NextObjPtr 指针指向最后一个幸存对象之后的位置,下一个分配的对象将放到这位置。
  5. CLR 恢复应用程序的所有线程。

注意:

  • 静态字段引用的对象一直存在,直至用于加载类型的 AppDomain 卸载为止。
  • 内存泄露一个常见原因就是静态字段引用了一个集合对象,然后不停向集合添加数据项,导致一直存活。
  • 尽量避免使用静态字段。

7 基于代的垃圾回收器(generational garbage collector)

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

托管堆只支持三代(System.GC.MaxGenerationa() 返回 2):第 0 代、第 1 代和第 2 代。

详细见P454

8 垃圾回收触发条件

  • CLR 检测第 0 代超过预算(最常见);
  • 代码显示调用 System.GC.Collect();
  • Windows 报告低内存情况;
  • CLR 正在卸载 AppDomain;
  • CLR 正在关闭(此时 CLR 不会试图压缩或释放内存,Windows 直接回收进程的全部内存)。

9 大对象

CLR 将对象分为大对象和小对象,目前认为 85000 字节或更大的对象为大对象。

  • 大对象不是在小对象的地址空间分配,而是在进程地址空间的其他地方分配;
  • 目前版本的 GC 不压缩大对象,因为在内存中移动它们代价过高,故在进程中大对象之间造成地址空间的碎片化,可能抛出 OutOfMemoryException;
  • 大对象总是第 2 代,绝不可能是第 0 代或第 1 代,所以只能为需要长时间存活的资源创建大对象,否则会频繁回收第 2 代,损害性能。

10 GC模式

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

  • 工作站(默认):该模式针对客户端应用程序优化GC。
  • 服务器:该模式针对服务器端应用程序优化 GC。

注意

  • 若计算机为单处理器,则总是使用“工作站” GC 模式;

除两种主要模式外,还支持两种子模式:并发(默认)或非并发。

11 特殊清理(Finalize)

  • 包含本机资源(文件、网络连接、套接字、互斥体)的类型被 GC 时,GC 会回收对象在托管堆中使用的内存,但会造成本机资源的泄露,故 CLR 提供了终结(finalization)的机制,允许对象在被判定为垃圾之后,但在对象内存被回收之前执行一些代码。任何包装了本机资源(文件、网络连接、套接字、互斥体)的类型都支持终结。CLR 判定一个对象不可达时,对象将终结它自己,释放它包装的本机资源。之后,GC 会从托管堆回收对象。
  • Object基类定义了受保护的虚方法 Finalize,语法~ClassName
  • Finalize 是为释放本机资源而设计的。

注意:

  • 被视为垃圾的对象在垃圾回收完毕后才调用 Finalize 方法,所以这些对象的内存不是马上被回收,造成它被提升为另一代,而这些对象引用的对象也会被提升,导致对象存活时间延长。故尽量避免为引用类型的字段定义可终结对象。
  • Finalize 方法执行时间不可控,且 GC 不保证多个 Finalize 的调用顺序,故在 Finalize 方法中不要访问其他定义了 Finalize 的类型对象。

12 dispose

  • dispose 模式:实现了 IDisposable 接口即实现了 dispose 模式;
  • 若类定义的一个字段的类型实现了 dispose 模式,则类本身也应实现。如果才能在类上调用 Dispose 来释放对象自身使用的资源。
  • 强烈建议将显式调用 Dispose 方法的代码放到 finally 块中或使用 using 语句块。

参考

  • CLR via C# 第21章
坚持原创技术分享,您的支持将鼓励我继续创作!