垃圾回收的基本知识

在公共语言运行时 (CLR) 中,垃圾回收器 (GC) 用作自动内存管理器。 垃圾回收器管理应用程序的内存分配和释放。 因此,使用托管代码的开发人员无需编写执行内存管理任务的代码。 自动内存管理可解决常见问题,例如,忘记释放对象并导致内存泄漏,或尝试访问已释放对象的已释放内存。

本文章介绍垃圾回收的核心概念。

优点

垃圾回收器具有以下优点:

  • 开发人员不必手动释放内存。

  • 有效分配托管堆上的对象。

  • 回收不再使用的对象,清除它们的内存,并保留内存以用于将来分配。 托管对象会自动获取干净的内容来开始,因此,它们的构造函数不必对每个数据字段进行初始化。

  • 通过确保对象不能自己使用分配给另一个对象的内存来提供内存安全。

内存基础知识

下面的列表总结了重要的 CLR 内存概念:

  • 每个进程都有其自己单独的虚拟地址空间。 同一台计算机上的所有进程共享相同的物理内存和页文件(如果有)。

  • 默认情况下,32 位计算机上的每个进程都具有 2 GB 的用户模式虚拟地址空间。

  • 作为一名应用程序开发人员,你只能使用虚拟地址空间,请勿直接操控物理内存。 垃圾回收器为你分配和释放托管堆上的虚拟内存。

    如果你编写的是本机代码,请使用 Windows 函数处理虚拟地址空间。 这些函数为你分配和释放本机堆上的虚拟内存。

  • 虚拟内存有三种状态:

    状态 描述
    Free 该内存块没有引用关系,可用于分配。
    保留 内存块可供你使用,不能用于任何其他分配请求。 但是,在该内存块提交之前,你无法将数据存储到其中。
    已提交 内存块已指派给物理存储。
  • 可能会存在虚拟地址空间碎片,这意味着地址空间中存在一些被称为孔的可用块。 当请求虚拟内存分配时,虚拟内存管理器必须找到满足该分配请求的足够大的单个可用块。 即使有 2 GB 可用空间,2 GB 分配请求也会失败,除非所有这些可用空间都位于一个地址块中。

  • 如果没有足够的可供保留的虚拟地址空间或可供提交的物理空间,则可能会用尽内存。

    即使在物理内存压力(物理内存的需求)较低的情况下也会使用页文件。 首次出现物理内存压力较高的情况时,操作系统必须在物理内存中腾出空间来存储数据,并将物理内存中的部分数据备份到页文件中。 该数据只会在需要时进行分页,所以在物理内存压力较低的情况下也可能会进行分页。

内存分配

初始化新进程时,运行时会为进程保留一个连续的地址空间区域。 这个保留的地址空间被称为托管堆。 托管堆维护着一个指针,用它指向将在堆中分配的下一个对象的地址。 最初,该指针设置为指向托管堆的基址。 托管堆上部署了所有引用类型。 应用程序创建第一个引用类型时,将为托管堆的基址中的类型分配内存。 应用程序创建下一个对象时,垃圾回收器在紧接第一个对象后面的地址空间内为它分配内存。 只要地址空间可用,垃圾回收器就会继续以这种方式为新对象分配空间。

从托管堆中分配内存要比非托管内存分配速度快。 由于运行时通过为指针添加值来为对象分配内存,所以这几乎和从堆栈中分配内存一样快。 另外,由于连续分配的新对象在托管堆中是连续存储,所以应用程序可以快速访问这些对象。

内存释放

垃圾回收器的优化引擎根据所执行的分配决定执行回收的最佳时间。 垃圾回收器在执行回收时,会释放应用程序不再使用的对象的内存。 它通过检查应用程序的根来确定不再使用的对象。 应用程序的根包含线程堆栈上的静态字段、局部变量、CPU 寄存器、GC 句柄和终结队列。 每个根或者引用托管堆中的对象,或者设置为空。 垃圾回收器可以为这些根请求其余运行时。 垃圾回收器使用此列表创建一个图表,其中包含所有可从这些根中访问的对象。

不在该图表中的对象将无法从应用程序的根中访问。 垃圾回收器会考虑无法访问的对象垃圾,并释放为它们分配的内存。 在回收中,垃圾回收器检查托管堆,查找无法访问对象所占据的地址空间块。 发现无法访问的对象时,它就使用内存复制功能来压缩内存中可以访问的对象,释放分配给不可访问对象的地址空间块。 在压缩了可访问对象的内存后,垃圾回收器就会做出必要的指针更正,以便应用程序的根指向新地址中的对象。 它还将托管堆指针定位至最后一个可访问对象之后。

只有在回收发现大量的无法访问的对象时,才会压缩内存。 如果托管堆中的所有对象均未被回收,则不需要压缩内存。

为了改进性能,运行时为单独堆中的大型对象分配内存。 垃圾回收器会自动释放大型对象的内存。 但是,为了避免移动内存中的大型对象,通常不会压缩此内存。

垃圾回收的条件

当满足以下条件之一时将发生垃圾回收:

  • 系统具有低的物理内存。 内存大小是通过操作系统的内存不足通知或主机指示的内存不足检测出来的。

  • 由托管堆上已分配的对象使用的内存超出了可接受的阈值。 随着进程的运行,此阈值会不断地进行调整。

  • 调用 GC.Collect 方法。 几乎在所有情况下,你都不必调用此方法,因为垃圾回收器会持续运行。 此方法主要用于特殊情况和测试。

托管堆

在 CLR 初始化垃圾回收器后,它会分配一段内存用于存储和管理对象。 此内存称为托管堆(与操作系统中的本机堆相对)。

每个托管进程都有一个托管堆。 进程中的所有线程都在同一堆上为对象分配内存。

若要保留内存,垃圾回收器会调用 Windows VirtualAlloc 函数,并且每次为托管应用保留一个内存段。 垃圾回收器还会根据需要保留内存段,并调用 Windows VirtualFree 函数,将内存段释放回操作系统(在清除所有对象的内存段后)。

重要

垃圾回收器分配的段大小特定于实现,并且随时可能更改(包括定期更新)。 应用程序不应假设特定段的大小或依赖于此大小,也不应尝试配置段分配可用的内存量。

堆上分配的对象越少,垃圾回收器必须执行的工作就越少。 分配对象时,请勿使用超出你需求的舍入值,例如在仅需要 15 个字节的情况下分配了 32 个字节的数组。

当触发垃圾回收时,垃圾回收器将回收由非活动对象占用的内存。 回收进程会对活动对象进行压缩,以便将它们一起移动,并移除死空间,从而使堆更小一些。 此进程可确保一起分配的对象全都位于托管堆上,从而保留它们的局部性。

垃圾回收的侵入性(频率和持续时间)是由分配的数量和托管堆上保留的内存数量决定的。

此堆可视为两个堆的累计:大对象堆和小对象堆。 大对象堆包含大小不少于 85,000 个字节的对象,这些对象通常是数组。 非常大的实例对象是很少见的。

提示

可以配置阈值大小,以使对象能够进入大型对象堆。

代数

GC 算法基于几个注意事项:

  • 压缩托管堆的一部分内存要比压缩整个托管堆速度快。
  • 较新的对象生存期较短,而较旧的对象生存期则较长。
  • 较新的对象趋向于相互关联,并且大致同时由应用程序访问。

垃圾回收主要在回收短生存期对象时发生。 为优化垃圾回收器的性能,将托管堆分为三代:第 0 代、第 1 代和第 2 代,因此它可以单独处理长生存期和短生存期对象。 垃圾回收器将新对象存储在第 0 代中。 在应用程序生存期的早期创建的对象如果未被回收,则被升级并存储在第 1 级和第 2 级中。 因为压缩托管堆的一部分要比压缩整个托管堆速度快,所以此方案允许垃圾回收器在每次执行回收时释放特定级别的内存,而不是整个托管堆的内存。

  • 第 0 代:这一代是最年轻的,其中包含短生存期对象。 短生存期对象的一个示例是临时变量。 垃圾回收最常发生在此代中。

    新分配的对象构成新一代对象,并隐式地成为第 0 代集合。 但是,如果它们是大型对象,它们将延续到大型对象堆 (LOH),这有时称为第 3 代。 第 3 代是在第 2 代中逻辑收集的物理生成。

    大多数对象通过第 0 代中的垃圾回收进行回收,不会保留到下一代。

    如果应用程序在第 0 代托管堆已满时尝试创建新对象,垃圾回收器将执行收集,为该对象释放地址空间。 垃圾回收器从检查第 0 级托管堆中的对象(而不是托管堆中的所有对象)开始执行回收。 单独回收第 0 代托管堆通常可以回收足够的内存,这样,应用程序便可以继续创建新对象。

  • 第 1 代:这一代包含短生存期对象并用作短生存期对象和长生存期对象之间的缓冲区。

    垃圾回收器执行第 0 代托管堆的回收后,会压缩可访问对象的内存,并将其升级到第 1 代。 因为未被回收的对象往往具有较长的生存期,所以将它们升级至更高的级别很有意义。 垃圾回收器不必在每次执行第 0 代托管堆的回收时,都重新检查第 1 代和第 2 代托管堆中的对象。

    如果第 0 代托管堆的回收没有回收足够的内存供应用程序创建新对象,垃圾回收器就会先执行第 1 代托管堆的回收,然后再执行第 2 代托管堆的回收。 第 1 级托管堆中未被回收的对象将会升级至第 2 级托管堆。

  • 第 2 代:这一代包含长生存期对象。 长生存期对象的一个示例是服务器应用程序中的一个包含在进程期间处于活动状态的静态数据的对象。

    第 2 代托管堆中未被回收的对象会继续保留在第 2 代托管堆中,直到在将来的回收中确定它们无法访问为止。

    大型对象堆上的对象(有时称为 第 3 代)也在第 2 代中收集。

当条件得到满足时,垃圾回收将在特定代上发生。 回收某个代意味着回收此代中的对象及其所有更年轻的代。 第 2 代垃圾回收也称为完整垃圾回收,因为它回收所有代中的对象(即,托管堆中的所有对象)。

幸存和提升

垃圾回收中未回收的对象也称为幸存者,并会被提升到下一代:

  • 第 0 代垃圾回收中未被回收的对象将会升级至第 1 代。
  • 第 1 代垃圾回收中未被回收的对象将会升级至第 2 代。
  • 第 2 代垃圾回收中未被回收的对象将仍保留在第 2 代。

当垃圾回收器检测到某个代中的幸存率很高时,它会增加该代的分配阈值。 下次回收将回收非常大的内存。 CLR 持续在以下两个优先级之间进行平衡:不允许通过延迟垃圾回收,让应用程序的工作集获取太大内存,以及不允许垃圾回收过于频繁地运行。

暂时代和暂时段

因为第 0 代和第 1 代中的对象的生存期较短,因此,这些代被称为“暂时代”。

暂时代在称为“暂时段”的内存段中进行分配。 垃圾回收器获取的每个新段将成为新的暂时段,并包含在第 0 代垃圾回收中幸存的对象。 旧的暂时段将成为新的第 2 代段。

根据系统为 32 位还是 64 位以及它正在哪种类型的垃圾回收器(工作站或服务器 GC)上运行,暂时段的大小发生相应变化。 下表显示了暂时段的默认大小:

工作站/服务器 GC 32 位 64 位
工作站 GC 16 MB 256 MB
服务器 GC 64 MB 4 GB
服务器 GC(具有 > 4 个逻辑 CPU) 32 MB 2 GB
服务器 GC(具有 > 8 个逻辑 CPU) 16 MB 1 GB

暂时段可以包含第 2 代对象。 第 2 代对象可使用多个段,只要在进程需要且内存允许的数量范围内即可。

从暂时垃圾回收中释放的内存量限制为暂时段的大小。 释放的内存量与死对象占用的空间成比例。

垃圾回收过程中发生的情况

垃圾回收分为以下几个阶段:

  • 标记阶段,找到并创建所有活动对象的列表。

  • 重定位阶段,用于更新对将要压缩的对象的引用。

  • 压缩阶段,用于回收由死对象占用的空间,并压缩幸存的对象。 压缩阶段将垃圾回收中幸存下来的对象移至段中时间较早的一端。

    因为第 2 代回收可以占用多个段,所以可以将已提升到第 2 代中的对象移动到时间较早的段中。 可以将第 1 代幸存者和第 2 代幸存者都移动到不同的段,因为它们已被提升到第 2 代。

    通常,由于复制大型对象会造成性能下降,因此不会压缩大型对象堆 (LOH)。 但是,在 .NET Core 和 .NET Framework 4.5.1 及更高版本中,可以根据需要使用 GCSettings.LargeObjectHeapCompactionMode 属性按需压缩大型对象堆。 此外,当通过指定以下任一项设置硬限制时,将自动压缩 LOH:

垃圾回收器使用以下信息来确定对象是否为活动对象:

  • 堆栈根:由实时 (JIT) 编译器和堆栈查看器提供的堆栈变量。 JIT 优化可以延长或缩短报告给垃圾回收器的堆栈变量内的代码的区域。

  • 垃圾回收句柄:指向托管对象且可由用户代码或公共语言运行时分配的句柄。

  • 静态数据:应用程序域中可能引用其他对象的静态对象。 每个应用程序域都会跟踪其静态对象。

在垃圾回收启动之前,除了触发垃圾回收的线程以外的所有托管线程均会挂起。

下图演示了触发垃圾回收并导致其他线程挂起的线程:

线程如何触发垃圾回收的屏幕截图。

非托管资源

对于应用程序创建的大多数对象,可以依赖垃圾回收自动执行必要的内存管理任务。 但是,非托管资源需要显式清除。 最常用的非托管资源类型是包装操作系统资源的对象,例如,文件句柄、窗口句柄或网络连接。 虽然垃圾回收器可以跟踪封装非托管资源的托管对象的生存期,但却无法具体了解如何清理资源。

定义封装非托管资源的对象时,建议在公共 Dispose 方法中提供必要的代码以清理非托管资源。 通过提供 Dispose 方法,对象的用户可以在使用完对象后显式释放资源。 使用封装非托管资源的对象时,务必要在需要时调用 Dispose

还必须提供一种释放非托管资源的方法,以防类型使用者忘记调用 Dispose。 可以使用安全句柄来包装非托管资源,也可以重写 Object.Finalize() 方法。

有关清理非托管资源的详细信息,请参阅清理非托管资源

请参阅