C# 基础:托管堆与垃圾回收(part1:原理)

C# 基础:托管堆与垃圾回收(part1:原理)

C# 基础 是《C# CLR via》(第四版)和《框架设计指南》(第三版)的笔记。

·

2 min read

.NET 的垃圾回收器管理应用程序的内存分配和释放。 每当有对象新建时,公共语言运行时都会从托管堆为对象分配内存。 只要托管堆中有地址空间,运行时就会继续为新对象分配空间。

不过,内存并不是无限的,垃圾回收器最终必须执行垃圾回收来释放一些内存。 垃圾回收器的优化引擎会根据所执行的分配来确定执行回收的最佳时机。 执行回收时,垃圾回收器会在托管堆中检查应用程序不再使用的对象,然后执行必要的操作来回收其内存。

本文将讨论托管应用程序如何构造新对象,托管堆如何控制这些对象的生存期,垃圾回收算法和代的原理。

文档结构如下所示:

托管堆

本节讨论 C# 访问内存资源的步骤,以此引出托管堆的概念以及从托管堆分配资源的步骤。

如何访问内存资源

每个程序都需要调用资源,包括文件、内存缓冲区、屏幕空间、网络连接、数据库资源等。在面向对象中,每个类型都代表可供程序使用的一种资源。要调用这些资源,必须为代表资源的类型分配内存。

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

  1. 调用 IL 指令 newobj,为代表资源的类型分配内存(一般是 new 操作符)。

  2. 初始化内存,设置资源的初始状态并使资源可用,类型的实例构造器负责设置初始状态。

  3. 通过访问类型成员使用资源。

  4. 摧毁资源状态以进行清理。

  5. 释放内存,由垃圾回收器处理。

对于需要手动管理内存的情况(比如使用原生 C/C++ 进行开发),容易出现诸如使用了未分配的内存,不释放或只释放了部分内存从而引发内存泄漏,或使用了已经释放的内存之类的问题。

对于 C# 而言,只要写的是可验证,类型安全的代码(不使用 unsafe 关键字),应用程序就不会出现内存被破坏的情况。内存仍然可能被泄露,但不会是默认情况,现在发生内存泄漏一般是因为在集合中存储了对象,但不需要这些对象的时候一直不删除它们。

为了进一步简化编程,开发人员使用的大多数类型不需要摧毁资源状态以进行清理,因此托管堆除了避免手动管理内存引发的bug,还为开发人员提供了一个更简单的操作方式:分配并初始化资源,然后直接使用。大多数类型无需清理,垃圾回收期会自动释放内存。

在特殊情况下需要尽快清理资源,而非等待 GC 介入。可以在这些类中调用一个额外的 Dispose方法,按照自己的节奏清理资源。实现这个方法需要考虑较多问题,一般只有包装了本地资源(文件,Socket,数据库连接等)的类型才需要特殊的清理。

如何从托管堆分配资源

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

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

C# 的 new 操作符会使 CLR 执行以下步骤:

  1. 计算类型的字段所需的字节数。

  2. 加上对象的开销所需的字节数。每个对象都有两个开销字段:类型对象指针和同步块索引。对于32位应用程序,这两个字段各自需要32位,所以每个对象要增加8字节。对于64位应用程序,这两个字段各自需要64位,所以每个对象要增加16字节。

  3. CLR 检查区域中是否有分配对象所需的字节数。如果托管堆中有足够的可用空间,就在 NextObjPtr 指针指向的地址处放入对象,为对象分配的字节会清零。接着调用类型构造器(为 this 参数传递 NextObjPtr),new 操作符返回对象引用。就在返回这个引用前,NextObjPtr 指针的值会加上对象占用的字节数来得到一个新值,即下个对象放入托管堆时的地址。

对于托管堆而言,每次分配对象只需要在指针所指的地址上加上新增对象的字节数。在许多应用程序中,同时分配的对象彼此间有较强的联系,而且经常同时访问。由于托管堆在内存中连续分配了这些对象,所以会因为引用的局部化(locality)而获得性能上的提升。

这意味着进程的工作集会非常小,应用程序只需要使用很少的内存,还意味着使用的对象可以全部驻留在 CPU 的缓存中,这样应用程序就能迅速访问这些对象,而不会因为 cache miss 被迫访问较慢的 RAM。

以上是托管堆的理想状态--内存无限,CLR 总是可以分配新的对象。但内存不可能无限,所以CLR通过垃圾回收(GC)技术删除堆中不再需要的对象。

垃圾回收

本节讨论垃圾回收原理,代的工作原理,垃圾回收触发条件,以及垃圾回收的特殊情况。

垃圾回收算法

垃圾回收是在第0代满时发生的,在引入“代”的概念之前,我们先假设堆满就发生垃圾回收。

引用计数器回收的问题

一种被其他部分系统(比如 Microsoft 自己的组件对象模型 COM)使用的回收算法是引用计数回收器,即堆上的每个对象都维护着一个计数字段来统计程序中多少部分正在使用对象。该部分不再需要对象后,就递减对象的计数字段,该字段变为0后,对象即可从内存中删除,引用计数系统会出现循环引用问题--两个对象互相引用,并彼此阻止引用计数器归0,导致两个对象永远不会删除。

引用跟踪算法

CLR 采用一种引用跟踪算法进行垃圾回收,以此避免计数回收器算法存在的问题。

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

CLR 开始 GC 执行以下步骤:

  1. 暂停进程中所有线程,防止线程在 CLR 检查期间访问对象并更改其状态。

  2. CLR 进入 GC 的标记阶段,这个阶段 CLR 遍历堆中所有对象,将同步块索引字段中的一位设为0,这表示所有对象都应该删除。

  3. CLR检查所有活动根,查看它们引用了哪些对象。如果有一个根包含null,CLR忽略这个根并继续检查下个根。任何根如果引用了堆上的对象,CLR都会标记那个对象,也就是将该对象的同步块索引中的位设为1。

  4. 一个对象被标记后,CLR会检查那个对象中的根,标记它们引用的对象,如果发现对象已经标记,就不再重新检查对象字。

  5. 检查完毕后,已标记的对象不能被垃圾回收,称这种对象是可达的(reachable),未标记的对象是不可达的(unreachable)

  6. 接下来进入GC的**压缩(compact)**阶段,压缩所有幸存下来的对象,使它们占用连续的内存空间。

  7. 作为压缩阶段的一部分,CLR 还要从每个根减去所引用的对象在内存中偏移的字节数。这样就能保证每个根还是引用和之前一样的对象。

  8. 压缩好内存后,托管堆的 NextObjPtr 指针指向最后一个幸存对象之后的位置。下一个分配的对象将放到这个位置。

  9. 压缩阶段完成后,CLR恢复应用程序的所有线程,这些线程继续访问对象,就像GC没有发生过。

代:提升性能

CLR 的 GC 是基于代的垃圾回收器(generational garbage collector),它对代码做出如下假设:

  • 对象越新,生存周期越短。

  • 对象越老,生存周期越新。

  • 回收堆的一部分,速度快于回收整个堆。

为优化垃圾回收器的性能,将托管堆分为三代:第0代、第1代和第2代,CLR初始化时,会为每一代选择预算。

代的工作流程

以下是代的工作流程:

  1. 托管堆在初始化时不包含对象,添加到堆的对象称为第0代对象,即垃圾回收器从未检查的对象。

    初始化一个托管堆,添加 A、B、C、D、E 五个对象,过了一段时间后,对象C、E变得不可读。

  2. CLR初始化时为第0代对象选择一个预算容量(KB 级),如果分配一个新对象造成第0代超过预算,就必须启动一次垃圾回收。

  3. 在垃圾回收中存活的对象现在成为第1代对象。一次垃圾回收后,第0代就不包含任何对象了,新对象会继续分配到第0代中。

    垃圾回收器认为 C、E 是垃圾,压缩对象 D,使之与对象 B 相邻,此时 A、B、D 成为第1代对象,第0代对象暂时被置空。

  4. 开始垃圾回收时,垃圾回收器会决定检查哪些代,如果第1代没有超出预算,则垃圾回收器只检查和回收第0代的对象。

    在第1代中产生垃圾B,在第0代中产生垃圾 F、H,在执行回收后,由于第1代的内存占用较少,只回收第0代的F、H。

    Note

    1. 忽略第1代对象能提升垃圾回收器性能。

    2. 更重要的是,不必遍历堆中的每一个对象,直接忽略旧对象中所有引用,能更快构造可达对象图(graph of reachable object)。当然,旧对象的字段也有可能引用新对象。为了确保旧对象的已更新字段进行检查,垃圾回收器利用JIT编译器内部的一个机制,这个机制在对象引用字段发生变化时,会设置一个对应的位标志。只有字段发生变化的旧对象才需要检查是否引用了第0代中的任何新对象。

  5. 如果分配一个新对象再次造成第0代超过预算,并且此时第1代的增长导致它的对象占用了全部预算,这次垃圾回收器便会检查第1代和第0代中所有对象。

  6. 垃圾回收后,第0代的幸存者被提升至第1代,第1代的幸存者被提升至第2代。第2代的对象经历了两次或更多次检查。虽然已经发生了多次垃圾回收,但只有第一代超出对象时才会对第一代中的对象进行检查。

    第1代中缓慢增长导致对象占用全部预算,在分配新对象时,由于内存已满,此时对第0,1两代进行垃圾回收。

代的自我调节

CLR的垃圾回收器是自我调节的,如果垃圾回收器发现在回收0代后存活下来的对象很少,就可能减少第0代的预算,分配的空间减少意味着垃圾回收会更频繁的发生,但是每次做的事情也减少了。

另一方面,如果垃圾回收器回收了第0代,发现还有很多对象存活,没有多少内存被回收,就会增大第0代的预算,这样垃圾回收器的回收次数会减少,但是每次会做更多的事情。

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

如果没有回收到足够的内存,垃圾回收器会执行一次完整回收,如果仍然不够,就会抛出 OutOfMemoryException 异常。

垃圾回收触发条件

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

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

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

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

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

大对象的特殊处理

CLR 将对象分为大对象和小对象(目前认为85000字节或更大的对象是大对象,85000不是常数,可能更改)。

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

  • 大对象不是在小对象的地址空间分配,而是在进程地址空间的其他地方分配。

  • 目前版本的 GC 不压缩大对象,因为在内存中移动它们代价过高。(但可能造成地址空间碎片化,未来可能会压缩)。

  • 大对象总是第2代。大对象一般是大字符串(XML 或 JSON)或用于 I/O 操作的字节数组。

在很大程度上可以忽略大对象,除非出现解释不了的情况时(例如地址空间碎片化)才对其进行处理。

终结(finalization)

使用需要特殊清理的类型

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

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

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

Note

  1. 被视为垃圾的对象在垃圾回收完毕后才调用 Finalize 方法,所以这些对象的内存不是马上被回收的,可终结对象在回收时必须存活,造成它被提升到另一代,使对象活得比正常时间长,这增大了内存耗用。更糟糕的是,可终结对象被提升时,其字段引用的所有对象也会被提升。

  2. Finalize 方法的执行时间是控制不了的,应用程序请求更多内存时才可能发生 GC,而只有 GC 完成后才运行 Finalize

  3. CLR 不保证多个 Finalize 方法的调用顺序,所以 Finalize 方法中不要访问定义了其他类型的变量,它们是否被终结是不确定的。

  4. CLR 用一个特殊的、高优先级的专用线程调用 Finalize 方法来避免死锁,如果该方法阻塞就会导致特殊线程文档无法调用任何更多 Finalize 方法,应用程序将无法回收可终结对象占用的内存--这会导致始终造成内存泄漏。

  5. Finalize 抛出未处理的异常则进程终止,无法捕捉这个异常。

终结(finalization)的内部工作原理

Step 1:将指针放到终结列表

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

终结列表是垃圾回收器控制的一个内部数据结构,其中的每一项都指向一个对象——回收该对象的内存前应调用它的 Finalize 方法。

Note

System.Object 定义了 Finalize 方法,但 CLR 知道忽略它。即构造类型实例时,如果该类型的 Finalize 方法是由 System.Object 继承的,则不会认为这个对象是可终结的。

类型必须重写 Object 的 Finalize 方法,这个类型及其派生类型的对象才被认为是可终结的。

Step 2:终结列表对象放入 freachable 列表

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

在未调用 Finalize 方法前,freachable 队列的对象暂时还不能回收。

Note

freachable 的名称为 f (finalization)和 reachable。

reachable 意味着可以将 freachable 列表看作静态字段一样的根,其中的对象是可达的,不是垃圾。

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

Step 3:再一次回收

标记 freachable 对象时,将递归标记对象中的引用类型的字段所引用的对象;所有这些对象也必须复活以便在回收过程中存活,之后,垃圾回收器才结束对垃圾的标识。

接下来,特殊的终结线程清空 freachable 队列,执行每个对象的 Finalize 方法。

在这个过程中,一些原本被认为是垃圾的对象复活了。然后垃圾回收器压缩可回收内存,将复活的对象提升到较老的一代。

下次对老一代进行垃圾回收时,会发现已终结的对象成为真正的垃圾。这些对象的内存将会被直接回收。

Note

特殊的高优先级 CLR 线程专门负责调用 Finalize 方法,以避免潜在的线程问题。

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

该线程调用方式特殊,Finalize 中的代码不应对执行代码的线程做出任何假设。

Note

可终结对象需要执行两次垃圾回收才能释放它们的内存。由于对象会提升到另一代,所以有可能要求不只两次垃圾回收。