C#

C#托管堆和垃圾回收

Posted by LudoArt on June 25, 2023

C#托管堆和垃圾回收

托管堆基础

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

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

从托管堆分配资源

CLR要求所有对象都从托管堆分配。进程初始化时,CLR划出一个地址空间区域作为托管堆。

CLR还要维护一个指针(NextObjPtr,该指针指向下一个对象在堆中的分配位置,刚开始的时候,指针设为地址空间区域的基地址。

C#的new操作符导致CLR执行以下步骤:

  • 计算类型的字段(以及从基类继承的字段)所需的字节数
  • 加上对象的开销所需的字节数。每个对象都有两个开销字段:类型对象指针和同步块索引(对于32位应用程序各需要32位,故每个对象增加8字节,64位则增加16字节)
  • CLR检查区域中是否有分配对象所需的字节数
    • 若有,在NextObjPtr指针指向的地址处放入对象,为对象分配的字节会被清零。接着调用类型的构造器,NextObjPtr指针的值会加上对象占用的字节数来得到一个新值,即下一个对象放入托管堆时的地址,最后new操作符返回对象引用。
    • 若无,CLR执行垃圾回收(事实上,垃圾回收是在第0代满的时候发生的,在后面会详细解释)

垃圾回收算法

CLR使用一种引用跟踪算法,该算法只关心引用类型的变量,因为只有这种变量才能引用堆上的对象;值类型变量直接包含值类型实例。我们将所有引用类型的变量都称为根。

CLR开始GC时:

  • 首先暂停进程中的所有线程。这样可以防止线程在CLR检查期间访问对象并更改其状态;
  • 然后CLR进入GC的标记阶段。在这个阶段,CLR遍历堆中的所有对象,将同步块索引字段中的一位设为0;
  • 然后CLR检查所有活动根,查看它们引用了哪些对象。如果有一个根包含null,CLR忽略这个根并继续检查下个根。任何根如果引用了堆上的对象,CLR都会标记那个对象,也就是将该对象的同步块索引中的位设为1;
  • 检查完毕后,已标记的对象不能被垃圾回收,称这种对象是可达的未标记的对象是不可达的
  • 接下来进入GC的压缩阶段,压缩所有幸存下来的对象,使它们占用连续的内存空间
  • 作为压缩阶段的一部分,CLR还要从每个根减去所引用的对象在内存中偏移的字节数。这样就能保证每个根还是引用和之前一样的对象;
  • 压缩好内存后,托管堆的 NextObjPtr 指针指向最后一个幸存对象之后的位置。下一个分配的对象将放到这个位置;

PS:内存泄漏的一个常见原因就是让静态字段引用某个集合对象,然后不停地向集合添加数据项。

垃圾回收和调试

使用C#编辑器的 /debug 开关编译程序集时,编译器会应用 System.Diagnostics.DebuggableAttribute ,并为结果程序集设置 DebuggingModeDisableOptimizations 标志。运行时编译方法时,JIT编译器看到这个标志,会将所有根的生存期延长至方法结束。

C#编辑器/optimize+ 编译器开关会将 DisableOptimizations 禁止的优化重新恢复。

代:提升性能

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

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

代的工作原理:

  • 托管堆在初始化时不包含对象。托管堆只支持三代:第0代、第1代和第2代。CLR初始化时,会为每一代选择预算。
  • 添加到堆的对象称为第0代对象。
  • 如果分配一个新对象造成第0代超过预算,就必须启动一次垃圾回收。
  • 在垃圾回收中存活的对象现在成为第1代对象。一次垃圾回收后,第0代就不包含任何对象了,新对象会继续分配到第0代中。
  • 如果分配一个新对象再次造成第0代超过预算,并且此时第1代也超过预算了,这次垃圾回收器便会检查第1代和第0代中所有对象。
  • 和之前一样,垃圾回收后,第0代的幸存者被提升至第1代,第1代的幸存者被提升至第2代。

PS1:如果根或对象引用了老一代的某个对象,垃圾回收器就可以忽略老对象内部的所有引用;如果老对象引用了新对象,为了确保对老对象的已更新字段进行检查,垃圾回收器利用JIT编译器内部的一个机制,这个机制在对象引用字段发生变化时,会设置一个对应的位标志。只有字段发生变化的老对象才需要检查是否引用了第0代中的任何新对象。

PS2:CLR初始化时,会为每一代选择预算。然而,CLR的垃圾回收器是自我调节的。如果垃圾回收器发现在回收0代后存活下来的对象很少,就可能减少第0代的预算。另一方面,如果垃圾回收器回收了第0代,发现还有很多对象存活,没有多少内存被回收,就会增大第0代的预算。

垃圾回收器还会用类似的启发式算法调整第1代和第2代的预算。

如果没有回收到足够的内存,垃圾回收器会执行一次完整回收。

垃圾回收触发条件

  • 检测第0代超过预算时触发

  • 代码显示调用 System.GC 的静态 Collect 方法

    详见 “强制垃圾回收”。

  • Windows报告低内存情况

    CLR内部使用 Win32 函数 CreateMemoryResourceNotificationQueryMemoryResourceNotification 监视系统的总体内存使用情况。如果Windows报告低内存,CLR将强制垃圾回收以释放死对象,减小进程工作集。

  • CLR正在卸载 AppDomain

    一个 AppDomain 卸载时,CLR认为其一切都不是根,所以执行涵盖所有代的垃圾回收。

  • CLR正在关闭

    CLR在进程正常终止(相反的是从外部终止,如任务管理器)时关闭。关闭期间,CLR认为进程中一切都不是根。对象有机会进行资源清理,但CLR不会试图压缩或释放内存。整个进程都要终止了,Windows将回收进程的全部内存。

大对象

另一个性能提升的举措是:CLR将对象分为大对象和小对象(目前认为85000字节或更大的对象是大对象* 不确定大小是否有变化)。

CLR以不同方式对待大小对象:

  • 大对象不是在小对象的地址空间分配,而是在进程地址空间的其他地方分配
  • 目前版本的GC不压缩大对象,因为在内存中移动它们代价过高。(但可能造成地址空间碎片化,未来可能会压缩)
  • 大对象总是第2代。大对象一般是大字符串(XML或JSON)或用于I/O操作的字节数组

使用需要特殊清理的类型

包含本机资源的类型被GC时,GC会回收对象在托管堆中使用的内存,但这会造成本机资源的泄漏,故CLR提供了称为终结(finalization)的机制,允许对象在被判定为垃圾之后,但在对象内存被回收之前执行一些代码。

任何包装了本机资源(文件、网络连接、套接字、互斥体)的类型都支持终结。

CLR判定一个对象不可达时,对象将终结它自己,释放它包装的本机资源。之后,GC会从托管堆回收对象。

注意:

  • 被视为垃圾的对象在垃圾回收完毕后才调用 Finalize 方法,所以这些对象的内存不是马上被回收的,可终结对象在回收时必须存活,造成它被提升到另一代,使对象活得比正常时间长,这增大了内存耗用。更糟糕的是,可终结对象被提升时,其字段引用的所有对象也会被提升。
  • Finalize 方法的执行时间是控制不了的,应用程序请求更多内存时才可能发生GC,而只有GC完成后才运行 Finalize
  • CLR不保证多个 Finalize 方法的调用顺序。
  • CLR用一个特殊的、高优先级的专用线程调用 Finalize 方法来避免死锁。

终结的内部工作原理

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

垃圾回收器判断完垃圾后,会扫描终结列表以查找对这些对象的引用。找到一个引用后,该引用会从终结列表中移除,并附加到 freachable 队列。队列中的每个引用都代表其 Finalize 方法已准备好调用的一个对象。

freachable 队列为空时,该线程将睡眠。但一旦队列中有记录项出现,线程就会被唤醒,将每一项都从 freachable 队列中移除,同时调用每个对象的 Finalize 方法。

简单来说:

  • 当一个对象不可达时,垃圾回收器就把它视为垃圾。
  • 当垃圾回收器将对象的引用从终结列表移至 freachable 队列时,对象不再被认为是垃圾,不能回收它的内存,即对象被复活了。
  • 标记 freachable 对象时,将递归标记对象中的引用类型的字段所引用的对象;所有这些对象也必须复活以便在回收过程中存活。
  • 之后,垃圾回收器才结束对垃圾的标识。在这个过程中,一些原本被认为是垃圾的对象复活了。
  • 然后垃圾回收器压缩可回收内存,将复活的对象提升到较老的一代
  • 现在,特殊的终结线程清空 freachable 队列执行每个对象的Finalize 方法
  • 下次对老一代进行垃圾回收时,会发现已终结的对象成为真正的垃圾。