CLR垃圾回收和性能

垃圾回收的基本知识

在公共语言运行时 (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)上运行,暂时段的大小发生相应变化。 下表显示了暂时段的默认大小:

工作站/服务器 GC32 位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() 方法。

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

 

自动内存管理

自动内存管理是公共语言运行时在托管执行过程中提供的服务之一。 公共语言运行时的垃圾回收器为应用程序管理内存的分配和释放。 对开发人员而言,这就意味着在开发托管应用程序时不必编写执行内存管理任务的代码。 自动内存管理可解决常见问题,例如,忘记释放对象并导致内存泄漏,或尝试访问已释放对象的内存。 本节描述垃圾回收器如何分配和释放内存。

分配内存

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

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

释放内存

垃圾回收器的优化引擎根据所执行的分配决定执行回收的最佳时间。 垃圾回收器在执行回收时,会释放应用程序不再使用的对象的内存。 它通过检查应用程序的根来确定不再使用的对象。 每个应用程序都有一组根。 每个根或者引用托管堆中的对象,或者设置为空。 应用程序的根包含线程堆栈上的静态字段、局部变量和参数以及 CPU 寄存器。 垃圾回收器可以访问由实时 (JIT) 编译器和运行时维护的活动根的列表。 垃圾回收器对照此列表检查应用程序的根,并在此过程中创建一个图表,在其中包含所有可从这些根中访问的对象。

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

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

级别和性能

为优化垃圾回收器的性能,将托管堆分为三代:第 0 代、第 1 代和第 2 代。 运行时的垃圾回收算法基于以下几个普遍原理,这些垃圾回收方案的原理已在计算机软件业通过实验得到了证实。 首先,压缩托管堆的一部分内存要比压缩整个托管堆速度快。 其次,较新的对象生存期较短,而较旧的对象生存期则较长。 最后,较新的对象趋向于相互关联,并且大致同时由应用程序访问。

运行时的垃圾回收器将新对象存储在第 0 级中。 在应用程序生存期的早期创建的对象如果未被回收,则被升级并存储在第 1 级和第 2 级中。 本主题中稍后介绍了对象升级过程。 因为压缩托管堆的一部分要比压缩整个托管堆速度快,所以此方案允许垃圾回收器在每次执行回收时释放特定级别的内存,而不是整个托管堆的内存。

实际上,垃圾回收器在第 0 级托管堆已满时执行回收。 如果应用程序在第 0 级托管堆已满时尝试新建对象,垃圾回收器将会发现第 0 级托管堆中没有可分配给该对象的剩余地址空间。 垃圾回收器执行回收,尝试为对象释放第 0 级托管堆中的地址空间。 垃圾回收器从检查第 0 级托管堆中的对象(而不是托管堆中的所有对象)开始执行回收。 这是最有效的途径,因为新对象的生存期往往较短,并且期望在执行回收时,应用程序不再使用第 0 级托管堆中的许多对象。 另外,单独回收第 0 级托管堆通常可以回收足够的内存,这样,应用程序便可以继续创建新对象。

垃圾回收器执行第 0 级托管堆的回收后,会压缩可访问对象的内存,如本主题前面的释放内存中所述。 然后,垃圾回收器升级这些对象,并考虑第 1 级托管堆的这一部分。 因为未被回收的对象往往具有较长的生存期,所以将它们升级至更高的级别很有意义。 因此,垃圾回收器在每次执行第 0 级托管堆的回收时,不必重新检查第 1 级和第 2 级托管堆中的对象。

在执行第 0 级托管堆的首次回收并把可访问的对象升级至第 1 级托管堆后,垃圾回收器将考虑第 0 级托管堆的其余部分。 它将继续为第 0 级托管堆中的新对象分配内存,直至第 0 级托管堆已满并需执行另一回收为止。 这时,垃圾回收器的优化引擎会决定是否需要检查较旧的级别中的对象。 例如,如果第 0 级托管堆的回收没有回收足够的内存,不能使应用程序成功完成创建新对象的尝试,垃圾回收器就会先执行第 1 级托管堆的回收,然后再执行第 2 级托管堆的回收。 如果这样仍不能回收足够的内存,垃圾回收器将执行第 2、1 和 0 级托管堆的回收。 每次回收后,垃圾回收器都会压缩第 0 级托管堆中的可访问对象并将它们升级至第 1 级托管堆。 第 1 级托管堆中未被回收的对象将会升级至第 2 级托管堆。 由于垃圾回收器只支持三个级别,因此第 2 级托管堆中未被回收的对象会继续保留在第 2 级托管堆中,直到在将来的回收中确定它们为无法访问为止。

为非托管资源释放内存

对于应用程序创建的大多数对象,可以依赖垃圾回收器自动执行必要的内存管理任务。 但是,非托管资源需要显式清除。 最常用的非托管资源类型是包装操作系统资源的对象,例如,文件句柄、窗口句柄或网络连接。 虽然垃圾回收器可以跟踪封装非托管资源的托管对象的生存期,但却无法具体了解如何清理资源。 创建封装非托管资源的对象时,建议在公共 Dispose 方法中提供必要的代码以清理非托管资源。 通过提供 Dispose 方法,对象的用户可以在使用完对象后显式释放其内存。 使用封装非托管资源的对象时,应该了解 Dispose 并在必要时调用它。 有关清理非托管资源的详细信息和实现 Dispose 的设计模式示例,请参见垃圾回收

请参阅


 

Windows 系统上的大型对象堆

.NET 垃圾回收器 (GC) 将对象分为小型和大型对象。 如果是大型对象,它的某些特性将比对象较小时显得更为重要。 例如,压缩大型对象(也就是在内存中将其复制到堆上的其他地方)的费用相当高。 因此,垃圾回收器将大型对象放置在大型对象堆 (LOH) 上。 本文将讨论符合什么条件的对象才能称之为大型对象,如何回收大型对象,以及大型对象具备哪些性能意义。

 重要

本文仅讨论 .NET Framework 中的大型对象堆和 Windows 系统上运行的 .NET Core。 不包括在其他平台上的 .NET 实现上运行的 LOH。

对象如何在 LOH 上结束

如果对象的大小大于或等于 85,000 字节,将被视为大型对象。 此数字根据性能优化确定。 对象分配请求为 85,000 字节或更大时,运行时会将其分配到大型对象堆。

若要了解其意义,可查看垃圾回收器的部分相关基础知识。

垃圾回收器是分代回收器。 它包含三代:第 0 代、第 1 代和第 2 代。 包含 3 代的原因是,在优化良好的应用中,大部分对象都在第 0 代就清除了。 例如,在服务器应用中,与每个请求相关的分配应在请求完成后清除。 仍存在的分配请求将转到第 1 代,并在那里进行清除。 从本质上讲,第 1 代是新对象区域与生存期较长的对象区域之间的缓冲区。

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

大型对象属于第 2 代,因为只有在第 2 代回收期间才能回收它们。 回收一代时,同时也会回收它前面的所有代。 例如,执行第 1 代 GC 时,将同时回收第 1 代和第 0 代。 执行第 2 代 GC 时,将回收整个堆。 因此,第 2 代 GC 还可称为“完整 GC”。 本文引用第 2 代 GC 而不是完整 GC,但这两个术语是可以互换的。

代可提供 GC 堆的逻辑视图。 实际上,对象存在于托管堆段中。 托管堆段是 GC 通过调用 VirtualAlloc 功能代表托管代码在操作系统上保留的内存块。 加载 CLR 时,GC 分配两个初始堆段:一个用于小型对象(小型对象堆或 SOH),一个用于大型对象(大型对象堆)。

然后,通过将托管对象置于这些托管堆段上来满足分配请求。 如果该对象小于 85,000 字节,则将它置于 SOH 的段上,否则,将它置于 LOH 段。 随着分配到各段上的对象越来越多,会以较小块的形式提交这些段。 对于 SOH,GC 未处理的对象将提升为下一代。 第 0 代回收未处理的对象现在视为第 1 代对象,以此类推。 但是,最后一代回收未处理的对象仍会被视为最后一代中的对象。 也就是说,第 2 代垃圾回收未处理的对象仍是第 2 代对象;LOH 未处理的对象仍是 LOH 对象(由第 2 代回收)。

用户代码只能在第 0 代(小型对象)或 LOH(大型对象)中分配。 只有 GC 可以在第 1 代(通过提升第 0 代回收未处理的对象)和第 2 代(通过提升第 1 代回收未处理的对象)中“分配”对象。

触发垃圾回收后,GC 将寻找存在的对象并将它们压缩。 但是由于压缩费用很高,GC 会扫过 LOH,列出没有被清除的对象列表以供以后重新使用,从而满足大型对象的分配请求。 相邻的被清除对象将组成一个自由对象。

.NET Core 和 .NET Framework(从 .NET Framework 4.5.1 开始)包括 GCSettings.LargeObjectHeapCompactionMode 属性,该属性可让用户指定在下一完整阻止 GC 期间压缩 LOH。 并且在以后,.NET 可能会自动决定压缩 LOH。 这就意味着,如果分配了大型对象并希望确保它们不被移动,则应将其固定起来。

图 1 说明了一种情况,在第一次第 0 代 GC 后 GC 形成了第 1 代,其中 Obj1 和 Obj3 被清除;在第一次第 1 代 GC 后形成了第 2 代,其中 Obj2 和 Obj5 被清除。 请注意此图和下图仅用于说明,它们只包含能更好展示堆上的情况的极少几个对象。 实际上,GC 中通常包含更多的对象。

图 1:第 0 代 GC 和第 1 代 GC
图 1:第 0 代和第 1 代 GC。

图 2 显示了第 2 代 GC 发现 Obj1 和 Obj2 被清除后,GC 在内存中形成了相邻的可用空间,由 Obj1 和 Obj2 占用,然后用于满足 Obj4 的分配要求。 从最后一个对象 Obj3 到此段末尾的空间仍可用于满足分配请求。

图 2:第 2 代 GC 后
图 2:第 2 代 GC 后

如果没有足够的可用空间来容纳大型对象分配请求,GC 首先尝试从操作系统获取更多段。 如果失败了,它将触发第 2 代 GC,试图释放部分空间。

在第 1 代或第 2 代 GC 期间,垃圾回收器会通过调用 VirtualFree 功能将不包含活动对象的段释放回操作系统。 将退回最后一个活动对象到段末尾的空间(第 0 代/第 1 代存在的短暂段上的空间除外,垃圾回收器会在该段上会保存部分提交内容,因为应用程序将在其中立即分配)。 而且,尽管已重置可用空间,但仍会提交它们,这意味着操作系统无需将其中的数据重新写入磁盘。

由于 LOH 仅在第 2 代 GC 期间进行回收,所以 LOH 段仅在此类 GC 期间可用。 图 3 说明了一种情况,在此情况下,垃圾回收器将某段(段 2)释放回操作系统并且退回剩余段上更多的空间。 如果需要使用该段末尾的已退回空间来满足大型对象分配请求,它会再次提交该内存。 (有关提交/退回的解释说明,请参阅 VirtualAlloc 的文档)。

图 3:第 2 代 GC 后的 LOH
图 3:第 2 代 GC 后的 LOH

何时收集大型对象?

通常情况下,出现以下三种情形中的任一情况,都会执行 GC:

  • 分配超出第 0 代或大型对象阈值。

    阈值是某代的属性。 垃圾回收器在其中分配对象时,会为代设置阈值。 超出阈值后,会在该代上触发 GC。 因此,分配小型或大型对象时,需要分别使用第 0 代和 LOH 的阈值。 当垃圾回收器分配到第 1 代和第 2 代中时,将使用它们的阈值。 运行此程序时,会动态调整这些阈值。

    这是典型情况,大部分 GC 执行都因为托管堆上的分配。

  • 调用 GC.Collect 方法。

    如果调用无参数 GC.Collect() 方法,或另一个重载作为参数传递到 GC.MaxGeneration,将会一起收集 LOH 和剩余的托管堆。

  • 系统处于内存不足的状况。

    垃圾回收器收到来自操作系统 的高内存通知时,会发生以上情况。 如果垃圾回收器认为执行第 2 代 GC 会有效率,它将触发第 2 代。

LOH 性能意义

大型对象堆上的分配通过以下几种方式影响性能。

  • 分配成本。

    CLR 确保清除了它提供的每个新对象的内存。 这意味着大型对象的分配成本由清理的内存(除非触发了 GC)决定。 如果需要 2 轮才能清除一个字节,即需要 170,000 轮才能清除最小的大型对象。 清除 2GHz 计算机上 16MB 对象的内存大约需要 16ms。 这些成本相当大。

  • 回收成本。

    因为 LOH 和第 2 代一起回收,如果超出了它们之中任何一个的阈值,则触发第 2 代回收。 如果由于 LOH 触发第 2 代回收,第 2 代没有必要在 GC 后变得更小。 如果第 2 代上数据不多,则影响较小。 但是,如果第 2 代很大,则触发多次第 2 代 GC 可能会产生性能问题。 如果很多大型对象都在短暂的基础上进行分配,并且拥有大型 SOH,则可能会花费太多时间来执行 GC。 除此之外,如果连续分配并且释放真正的大型对象,那么分配成本可能会增加。

  • 具有引用类型的数组元素。

    LOH 上的特大型对象通常是数组(很少会有非常大的实例对象)。 如果数组的元素有丰富的引用,则可能产生成本;如果元素没有丰富的引用,将不会产生此类成本。 如果元素不包含任何引用,则垃圾回收器根本无需处理此数组。 例如,如果使用数组存储二进制树中的节点,一种实现方法是按实际节点引用某个节点的左侧节点和右侧节点:

    C#
    class Node
    {
       Data d;
       Node left;
       Node right;
    };
    
    Node[] binary_tr = new Node [num_nodes];
    

    如果 num_nodes 非常大,则垃圾回收器需要处理每个元素的至少两个引用。 另一种方法是存储左侧节点和右侧节点的索引:

    C#
    class Node
    {
       Data d;
       uint left_index;
       uint right_index;
    } ;
    

    不要将左侧节点的数据引用为 left.d,而是将其引用为 binary_tr[left_index].d。 而垃圾回收器无需查看左侧节点和右侧节点的任何引用。

在这三种因素中,前两个通常比第三个更重要。 因此,建议分配重复使用的大型对象池,而不是分配临时大型对象。

收集 LOH 的性能数据

收集特定区域的性能数据之前,应完成以下操作:

  1. 找到应查看此区域的证据。

  2. 排查你知道的其他区域,确保未发现可解释上述性能问题的内容。

有关内存和 CPU 的基础知识的详细信息,请参阅博客尝试找出解决方案之前先了解问题

可使用以下工具来收集 LOH 性能数据:

.NET CLR 内存性能计数器

这些性能计数器通常是调查性能问题的第一步(但是推荐使用 ETW 事件)。 通过添加所需计数器配置性能监视器,如图 4 所示。 与 LOH 相关的是:

  • 第 2 代回收次数

    显示自进程开始起第 2 代 GC 发生的次数。 此计数器在第 2 代回收结束时递增(也称为完整垃圾回收)。 此计数器显示上次观测的值。

  • 大型对象堆大小

    以字节显示当前大小,包括 LOH 的可用空间。 此计数器在垃圾回收结束时更新,不在每次分配时更新。

查看性能计数器的常用方法是使用性能监视器 (perfmon.exe)。 使用“添加计数器”可为关注的进程添加感兴趣的计数器。 可将性能计数器数据保存在日志文件中,如图 4 所示:

屏幕截图显示添加性能计数器。 图 4:第 2 代 GC 后的 LOH

也可以编程方式查询性能计数器。 大部分人在例行测试过程中都采用此方式进行收集。 如果发现计数器显示的值不正常,则可以使用其他方法获得更多详细信息以帮助调查。

 备注

建议使用 ETW 事件代替性能计数,因为 ETW 提供更丰富的信息。

ETW 事件

垃圾回收器提供丰富的 ETW 事件集,帮助了解堆的工作内容和工作原理。 以下博客文章演示了如何使用 ETW 收集和了解 GC 事件:

若要标识由临时 LOH 分配造成的过多第 2 代 GC 次数,请查看 GC 的“触发原因”列。 有关仅分配临时大型对象的简单测试,可使用以下 PerfView 命令行收集 ETW 事件的信息:

控制台
perfview /GCCollectOnly /AcceptEULA /nogui collect

结果类似于以下类容:

屏幕截图显示 PerfView 中的 ETW 事件。 图 5:使用 PerfView 显示的 ETW 事件

如下所示,所有 GC 都是第 2 代 GC,并且都由 AllocLarge 触发,这表示分配大型对象会触发此 GC。 我们知道这些分配是临时的,因为“LOH 未清理率 %”列显示为 1%。

可以收集显示分配这些大写对象的人员的其他 ETW 事件。 以下命令行:

控制台
perfview /GCOnly /AcceptEULA /nogui collect

收集 AllocationTick 事件,大约每 10 万次分配就会触发该事件。 换句话说,每次分配大型对象都会触发事件。 然后可查看某个 GC 堆分配视图,该视图显示分配大型对象的调用堆栈:

屏幕截图显示垃圾回收器堆视图。 图 6:GC 堆分配视图

如图所示,这是从 Main 方法分配大型对象的简单测试。

调试器

如果只有内存转储,则需要查看 LOH 上实际有哪些对象,你可使用 .NET 提供的 SoS 调试器扩展来查看。

 备注

此部分提到的调试命令适用于 Windows 调试器

以下内容显示了分析 LOH 的示例输出:

控制台
0:003> .loadby sos mscorwks
0:003> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x013e35ec
sdgeneration 1 starts at 0x013e1b6c
generation 2 starts at 0x013e1000
ephemeral segment allocation context: none
segment   begin allocated     size
0018f2d0 790d5588 790f4b38 0x0001f5b0(128432)
013e0000 013e1000 013e35f8 0x000025f8(9720)
Large object heap starts at 0x023e1000
segment   begin allocated     size
023e0000 023e1000 033db630 0x00ffa630(16754224)
033e0000 033e1000 043cdf98 0x00fecf98(16699288)
043e0000 043e1000 05368b58 0x00f87b58(16284504)
Total Size 0x2f90cc8(49876168)
------------------------------
GC Heap Size 0x2f90cc8(49876168)
0:003> !dumpheap -stat 023e1000 033db630
total 133 objects
Statistics:
MT   Count   TotalSize Class Name
001521d0       66     2081792     Free
7912273c       63     6663696 System.Byte[]
7912254c       4     8008736 System.Object[]
Total 133 objects

LOH 堆大小为 (16,754,224 + 16,699,288 + 16,284,504) = 49,738,016 字节。 在地址 023e1000 和地址 033db630 之间,8,008,736 字节由 System.Object 对象的数组占用,6,663,696 字节由 System.Byte 对象的数组占用,2,081,792 字节由可用空间占用。

有时,调试器显示 LOH 的总大小少于 85,000 个字节。 这是由于运行时本身使用 LOH 分配某些小于大型对象的对象引起的。

因为不会压缩 LOH,有时会怀疑 LOH 是碎片源。 碎片表示:

  • 托管堆的碎片由托管对象之间的可用空间量来表示。 在 SoS 中,!dumpheap –type Free 命令显示托管对象之间的可用空间量。

  • 虚拟内存 (VM) 地址空间的碎片是标识为 MEM_FREE 的内存。 可在 windbg 中使用各种调试器命令来获取碎片。

    以下示例显示 VM 空间中的碎片:

    控制台
    0:000> !address
    00000000 : 00000000 - 00010000
    Type     00000000
    Protect 00000001 PAGE_NOACCESS
    State   00010000 MEM_FREE
    Usage   RegionUsageFree
    00010000 : 00010000 - 00002000
    Type     00020000 MEM_PRIVATE
    Protect 00000004 PAGE_READWRITE
    State   00001000 MEM_COMMIT
    Usage   RegionUsageEnvironmentBlock
    00012000 : 00012000 - 0000e000
    Type     00000000
    Protect 00000001 PAGE_NOACCESS
    State   00010000 MEM_FREE
    Usage   RegionUsageFree
    … [omitted]
    -------------------- Usage SUMMARY --------------------------
    TotSize (     KB)   Pct(Tots) Pct(Busy)   Usage
    701000 (   7172) : 00.34%   20.69%   : RegionUsageIsVAD
    7de15000 ( 2062420) : 98.35%   00.00%   : RegionUsageFree
    1452000 (   20808) : 00.99%   60.02%   : RegionUsageImage
    300000 (   3072) : 00.15%   08.86%   : RegionUsageStack
    3000 (     12) : 00.00%   00.03%   : RegionUsageTeb
    381000 (   3588) : 00.17%   10.35%   : RegionUsageHeap
    0 (       0) : 00.00%   00.00%   : RegionUsagePageHeap
    1000 (       4) : 00.00%   00.01%   : RegionUsagePeb
    1000 (       4) : 00.00%   00.01%   : RegionUsageProcessParametrs
    2000 (       8) : 00.00%   00.02%   : RegionUsageEnvironmentBlock
    Tot: 7fff0000 (2097088 KB) Busy: 021db000 (34668 KB)
    
    -------------------- Type SUMMARY --------------------------
    TotSize (     KB)   Pct(Tots) Usage
    7de15000 ( 2062420) : 98.35%   : <free>
    1452000 (   20808) : 00.99%   : MEM_IMAGE
    69f000 (   6780) : 00.32%   : MEM_MAPPED
    6ea000 (   7080) : 00.34%   : MEM_PRIVATE
    
    -------------------- State SUMMARY --------------------------
    TotSize (     KB)   Pct(Tots) Usage
    1a58000 (   26976) : 01.29%   : MEM_COMMIT
    7de15000 ( 2062420) : 98.35%   : MEM_FREE
    783000 (   7692) : 00.37%   : MEM_RESERVE
    
    Largest free region: Base 01432000 - Size 707ee000 (1843128 KB)
    

通常看到的更多是由临时大型对象导致的 VM 碎片,这些对象要求垃圾回收器频繁从操作系统获取新的托管堆段,并将空托管堆段释放回操作系统。

要验证 LOH 是否会生成 VM 碎片,可在 VirtualAlloc 和 VirtualFree 上设置一个断点,查看是谁调用了它们。 例如,如果想知道谁曾尝试从操作系统分配大于 8 MB 的虚拟内存块,可按以下方式设置断点:

控制台
bp kernel32!virtualalloc "j (dwo(@esp+8)>800000) 'kb';'g'"

只有在分配大小大于 8 MB (0x800000) 的情况下调用 VirtualAlloc 时,此命令才会进入调试器并显示调用堆栈。

CLR 2.0 增加了称为“VM 囤积”的功能,用于频繁获取和释放段(包括在大型和小型对象堆上)的情况。 若要指定 VM 囤积,可通过托管 API 指定称为 STARTUP_HOARD_GC_VM 的启动标记。 CLR 退回这些段上的内存并将其添加到备用列表中,而不会将该空段释放回操作系统。 (请注意 CLR 不会针对太大型的段执行此操作。)CLR 稍后将使用这些段来满足新段请求。 下一次应用需要新段时,CLR 将使用此备用列表中的某个足够大的段。

VM 囤积还可用于想要保存已获取段的应用程序(例如属于系统上运行的主要应用的部分服务器应用),以避免内存不足的异常。

强烈建议你在使用此功能时认真测试应用程序,以确保应用程序的内存使用情况比较稳定。

 

垃圾回收和性能

本文介绍与垃圾回收和内存使用情况相关的问题。 它解决了关于托管堆的问题,并解释了如何最小化垃圾回收对应用程序的影响。 每个问题具有访问可用来调查问题的过程的链接。

性能分析工具

以下各节介绍了可用于调查内存使用情况和垃圾回收问题的工具。 本文中稍后提供的过程将引用这些工具。

内存性能计数器

可以使用性能计数器来收集性能数据。 有关说明,请参阅运行时分析。 如 .NET 中的性能计数器中所述,性能计数器的 .NET CLR 内存类别提供有关垃圾回收器的信息。

用 SOS 调试

可以使用 Windows 调试器 (WinDbg) 检查托管堆上的对象。

若要安装 WinDbg,请从下载 Windows 调试工具页安装 Windows 调试工具。

垃圾回收 ETW 事件

Windows 事件跟踪 (ETW) 是一个跟踪系统,对由 .NET 提供的分析和调试支持提供补充。 从 .NET Framework 4 开始,垃圾回收 ETW 事件将捕获有用信息,用于从统计的角度来分析托管堆。 例如,在将要发生垃圾回收时引发的 GCStart_V1 事件提供了以下信息:

  • 正在收集哪一代对象。
  • 是什么触发了垃圾回收。
  • 垃圾回收的类型(并发或非并发)。

ETW 事件日志有效,且不会掩盖与垃圾回收相关的任何性能问题。 一个进程可以通过结合 ETW 事件来提供其自身的事件。 登录后,可以关联应用程序事件和垃圾回收事件,以确定如何以及何时出现堆问题。 例如,服务器应用程序可以在客户端请求开始和结束时提供事件。

分析 API

公共语言运行时 (CLR) 分析接口将提供有关垃圾回收期间受影响对象的详细信息。 垃圾回收开始和结束时,可以通知探查器。 它可以提供有关托管堆上对象的报告,其中包括每一代对象的标识。 有关详细信息,请参阅分析概述

探查器可以提供全面的信息。 但是,复杂的探查器可能会修改应用程序的行为。

应用程序域资源监控

从 .NET Framework 4 开始,应用程序域资源监视 (ARM) 使主机可以通过应用程序域监视 CPU 和内存使用情况。 有关详细信息,请参阅应用程序域资源监控

排查性能问题

第一步是确定问题是否确实为垃圾回收。 如果确定是,则从以下列表进行选择,以解决该问题。

问题:抛出内存不足异常

对于引发的托管 OutOfMemoryException,存在以下两种合理的情况:

  • 虚拟内存不足。

    垃圾回收器按预先确定大小的分段来分配系统内存。 如果分配需要其他段,但在进程的虚拟内存空间中没有剩余的连续可用块了,则托管堆的分配将失败。

  • 没有足够的物理内存来分配。

如果确定异常不合法,请使用以下信息与 Microsoft 客户服务和支持联系:

  • 带有托管内存不足异常的堆栈。
  • 完整内存转储。
  • 证明这不是合法内存不足异常的数据包括显示虚拟或物理内存不是问题的数据。

问题:进程占用过多内存

通常会假设 Windows 任务管理器“性能”选项卡上的内存使用量显示可以指示何时使用了太多内存。 然而,该显示与工作集相关;它不提供有关虚拟内存使用量的信息。

如果确定问题是托管堆引发的,必须测量一段时间的托管堆,以确定模式。

如果确定问题不是托管堆引发的,则必须使用本地调试。

问题:垃圾回收器回收对象的速度不够快

当出现对象好像未按垃圾回收的预期进行回收的情况时,必须确定是否存在任何对这些对象的强引用。

如果没有对包含死对象的一代进行垃圾回收,这表示尚未运行死对象的终结器,你也可能会遇到以上问题。 例如,当正在运行一个单线程单元 (STA) 应用程序并且服务终结器队列的线程不能调用至其中时,可能发生这种问题。

问题:托管堆太零碎

碎片级别将计算为可用空间占这一代已分配的总内存的比率。 对于第 2 代,可接受的碎片级别不能超过 20%。 因为第 2 代可以变得很大,所以碎片的比率比绝对值更重要。

第 0 代中存在大量可用空间,这不是问题,因为新的对象将在其中进行分配。

碎片始终出现在大型对象堆中,因为它没有进行压缩。 相邻的可用对象会自然地折叠至一个单个的空间,以满足大型对象的分配请求。

在第 1 代和第 2 代中,碎片可能会成为问题。 如果它们在垃圾回收后还有大量的可用空间,则应用程序对象的使用可能需要进行修改,并且应考虑重新评估长期对象的生存期。

固定对象过多可能会增加碎片。 如果碎片太多,则可以固定许多对象。

如果虚拟内存的碎片阻止垃圾回收器添加段,原因可能是下列之一:

  • 频繁加载和卸载许多小的程序集。

  • 与非托管代码互操作时,保留了太多对 COM 对象的引用。

  • 大型暂时性对象的创建会导致大型对象堆频繁分配和释放堆段。

    当承载 CLR 时,应用程序可以请求垃圾回收器保留其片段。 这将减少段分配的频率。 通过使用 STARTUP_FLAGS 枚举中的 STARTUP_HOARD_GC_VM 标志来完成。

如果认为没有出现碎片的合理原因,请与 Microsoft 客户服务和支持联系。

问题:垃圾回收暂停时间太长

由于垃圾回收软实时操作,因此应用程序必须能够容忍暂停。 软实时的一个衡量标准是 95% 的操作必须按时完成。

在并发垃圾回收中,允许托管线程在一个回收过程中运行,这意味着暂停时间会非常短。

短暂的垃圾回收(第 0 代和第 1 代)只会持续几毫秒,所以减少暂停时间通常是不可行的。 然而,你可以通过更改应用程序的分配请求的模式,在第 2 代回收中减少暂停。

另一个更准确的方法是使用垃圾回收 ETW 事件。 可以通过为某个事件序列添加时间戳的差异来查找回收的计时。 整个集合序列包括暂停执行引擎、垃圾回收本身以及恢复执行引擎。

可以使用垃圾回收通知,确定服务器是否将要进行第 2 代回收,以及将请求重新路由到另一个服务器是否可以减轻任何暂停问题。

问题:第 0 代太大

第 0 代可能在 64 位系统上有更多的对象,尤其是当使用服务器垃圾回收而不是工作站垃圾回收时。 这是因为触发 0 代垃圾回收的阈值在这些环境中更高,且 0 代回收可以变得更大。 触发垃圾回收之前,当应用程序分配更多的内存时,性能将会提高。

问题:垃圾回收期间的 CPU 使用率太高

在垃圾回收期间,CPU 的使用率会很高。 如果在垃圾回收中花费大量的处理时间,则回收的数量将过于频繁或回收的持续时间将过长。 托管堆上增加的对象分配率将导致垃圾回收更频繁地发生。 减少分配速率可减少垃圾回收的频率。

可以通过使用 Allocated Bytes/second 性能计数器来监视分配速率。 有关更多信息,请参阅 .NET 中的性能计数器

收集的持续时间是分配后幸存对象数量的主要因素。 如果有许多对象仍需收集,则垃圾回收器必须要检查大量的内存。 压缩幸存对象的工作很耗时。 若要确定回收期间处理对象的数量,请在指定代的垃圾回收结束时,在调试器中设置一个断点。

故障排除指南

本部分介绍在开始调查时应考虑的准则。

工作站或服务器垃圾回收

确定是否正在使用正确的垃圾回收类型。 如果应用程序使用多个线程和对象实例,则使用服务器垃圾回收,而不是工作站垃圾回收。 服务器垃圾回收在多个线程上进行操作,而工作站垃圾回收则需要应用程序的多个实例运行它们自己的垃圾回收线程并争取 CPU 时间。

低负载且不常在后台(如服务)执行任务的应用程序,可以在禁用并发垃圾回收的情况下使用工作站垃圾回收。

何时衡量托管堆的大小

除非使用探查器,否则必须建立一致的测量模式,以有效地诊断性能问题。 若要建立一个计划,请考虑以下几点:

  • 如果在第 2 代垃圾回收后测量,则整个托管的堆将不再存在垃圾(死对象)。
  • 如果在一个 0 代垃圾回收后立即进行测量,则尚不会收集第 1 代和 2 中的对象。
  • 如果在垃圾回收之前立即进行测量,则你将在垃圾回收启动之前,测量尽可能多的分配。
  • 在垃圾回收期间进行测量会出现问题,因为垃圾回收器的数据结构对于遍历是无效状态,可能不能提供完整的结果。 这是设计使然。
  • 当与并发垃圾回收一起使用工作站垃圾回收时,回收的对象不会进行压缩,因此,堆的大小可能会相同或更大(碎片可以使它看起来更大)。
  • 第 2 代上的并发垃圾回收在物理内存加载过高时,将被延迟。

以下过程介绍如何设置一个断点,以便测量托管堆。

若要在垃圾回收结束时设置一个断点

  • 在加载了 SOS 调试器扩展的 WinDbg 中,输入以下命令:

    bp mscorwks!WKS::GCHeap::RestartEE "j (dwo(mscorwks!WKS::GCHeap::GcCondemnedGeneration)==2) 'kb';'g'"

    将 GcCondemnedGeneration 设置为所需的代。 此命令要求私有符号。

    如果在已回收第 2 代对象以进行垃圾回收后执行 RestartEE,则此命令会强制中断。

    在服务器垃圾回收中,只有一个线程会调用 RestartEE,因此在第 2 代垃圾回收期间,此断点只会出现一次。

性能检查过程

本部分将介绍下列过程,以避免造成性能问题的原因:

若要确定问题是否是垃圾回收引起

  • 请检查以下两个内存性能计数器:

    • GC 所占时间百分比。 显示执行最后一个垃圾回收周期后,执行垃圾回收所用运行时间的百分比。 使用此计数器确定垃圾回收器是否花费太多时间来使托管堆空间可用。 如果垃圾回收所用的时间相对较短,这可能表示托管堆之外存在资源问题。 当涉及并发或后台垃圾回收时,此计数器可能不准确。

    • 已提交的字节总数。 显示垃圾回收器当前已提交的虚拟内存量。 使用此计数器确定垃圾回收器所占用的内存是否是应用程序所使用的内存的过多部分。

    大多数的内存性能计数器会在每次垃圾回收结束时进行更新。 因此,它们可能不会反映你希望了解的当前情况。

若要确定是否已托管内存不足异常

  1. 在加载了 SOS 调试器扩展的 WinDbg 或 Visual Studio 调试器中,输入打印异常 (pe) 命令:

    !pe

    如果已托管异常,OutOfMemoryException 将显示为异常类型,如以下示例中所示。

    控制台
    Exception object: 39594518
    Exception type: System.OutOfMemoryException
    Message: <none>
    InnerException: <none>
    StackTrace (generated):
    
  2. 如果输出没有指定异常,则必须确定内存不足异常来自哪个线程。 在调试器中输入以下命令,以显示所有带调用堆栈的线程:

    ~\*kb

    具有存在异常调用的堆栈的线程会由 RaiseTheException 参数进行指示。 这是托管异常对象。

    控制台
    28adfb44 7923918f 5b61f2b4 00000000 5b61f2b4 mscorwks!RaiseTheException+0xa0
    
  3. 可以使用以下命令来转储嵌套的异常。

    !pe -nested

    如果找不到任何异常,则非托管代码将产生内存不足异常。

若要确定可保留的虚拟内存量

  • 在加载了 SOS 调试器扩展的 WinDbg 中输入以下命令,以获取最大的可用区域:

    !address -summary

    最大可用区域将如以下输出所示进行显示。

    控制台
    Largest free region: Base 54000000 - Size 0003A980
    

    在此示例中,最大可用区域的大小大约为 24000 KB(按十六进制形式则为 3A980)。 此区域比垃圾回收器对分段所需的大小要小得多。

  • 使用 vmstat 命令:

    !vmstat

    最大可用区域是 MAXIMUM 列中的最大值,如以下输出所示。

    控制台
    TYPE        MINIMUM   MAXIMUM     AVERAGE   BLK COUNT   TOTAL
    ~~~~        ~~~~~~~   ~~~~~~~     ~~~~~~~   ~~~~~~~~~~  ~~~~
    Free:
    Small       8K        64K         46K       36          1,671K
    Medium      80K       864K        349K      3           1,047K
    Large       1,384K    1,278,848K  151,834K  12          1,822,015K
    Summary     8K        1,278,848K  35,779K   51          1,824,735K
    

若要确定是否有足够的物理内存

  1. 则启动 Windows 任务管理器。

  2. 在 Performance 选项卡上,查看已提交的值。 (在 Windows 7 中,查看 System group 中的 Commit (KB)。)

    如果 Total 接近于 Limit,则物理内存不足。

若要确定托管堆的内存提交量

  • 使用 # Total committed bytes 内存性能计数器获取托管堆提交的字节数。 垃圾回收器根据需要在某个段上提交区块,但不会全部在同一时间进行。

     备注

    请不要使用 # Bytes in all Heaps 性能计数器,因为它不表示托管堆的实际内存使用情况。 代的大小包括在此值中,且实际上是其阈值大小,即如果代以对象进行填充,将引发垃圾回收的大小。 因此,此值通常为零。

若要确定托管堆的内存保留量

  • 使用 # Total reserved bytes内存性能计数器。

    垃圾回收器按段保留内存,并可以通过使用 eeheap 命令确定一个段的开始位置。

     重要

    尽管可以确定垃圾回收器为每个段分配的内存量,但是段的大小是特定于实现的,并可能会在任何时间(包括在定期更新中)进行更改。 应用程序不应假设特定段的大小或依赖于此大小,也不应尝试配置段分配可用的内存量。

  • 在加载了 SOS 调试器扩展的 WinDbg 或 Visual Studio 调试器中,输入以下命令:

    !eeheap -gc

    结果如下所示:

    控制台
    Number of GC Heaps: 2
    ------------------------------
    Heap 0 (002db550)
    generation 0 starts at 0x02abe29c
    generation 1 starts at 0x02abdd08
    generation 2 starts at 0x02ab0038
    ephemeral segment allocation context: none
      segment    begin allocated     size
    02ab0000 02ab0038  02aceff4 0x0001efbc(126908)
    Large object heap starts at 0x0aab0038
      segment    begin allocated     size
    0aab0000 0aab0038  0aab2278 0x00002240(8768)
    Heap Size   0x211fc(135676)
    ------------------------------
    Heap 1 (002dc958)
    generation 0 starts at 0x06ab1bd8
    generation 1 starts at 0x06ab1bcc
    generation 2 starts at 0x06ab0038
    ephemeral segment allocation context: none
      segment    begin allocated     size
    06ab0000 06ab0038  06ab3be4 0x00003bac(15276)
    Large object heap starts at 0x0cab0038
      segment    begin allocated     size
    0cab0000 0cab0038  0cab0048 0x00000010(16)
    Heap Size    0x3bbc(15292)
    ------------------------------
    GC Heap Size   0x24db8(150968)
    

    由“段”指示的地址是段的起始地址。

若要确定第 2 代中的大型对象

  • 在加载了 SOS 调试器扩展的 WinDbg 或 Visual Studio 调试器中,输入以下命令:

    !dumpheap –stat

    如果托管堆很大,则 dumpheap 可能需要一段时间才能完成。

    你可以从输出的最后几行开始分析,因为它们列出了占用了大多数空间的对象。 例如:

    控制台
    2c6108d4   173712     14591808 DevExpress.XtraGrid.Views.Grid.ViewInfo.GridCellInfo
    00155f80      533     15216804      Free
    7a747c78   791070     15821400 System.Collections.Specialized.ListDictionary+DictionaryNode
    7a747bac   700930     19626040 System.Collections.Specialized.ListDictionary
    2c64e36c    78644     20762016 DevExpress.XtraEditors.ViewInfo.TextEditViewInfo
    79124228   121143     29064120 System.Object[]
    035f0ee4    81626     35588936 Toolkit.TlkOrder
    00fcae40     6193     44911636 WaveBasedStrategy.Tick_Snap[]
    791242ec    40182     90664128 System.Collections.Hashtable+bucket[]
    790fa3e0  3154024    137881448 System.String
    Total 8454945 objects
    

    所列出的最后一个对象是一个字符串,且占用的空间最多。 可以检查应用程序,以查看如何优化字符串对象。 若要查看 150 到 200 个字节之间的字符串,请输入以下命令:

    !dumpheap -type System.String -min 150 -max 200

    如下所示是结果的一个示例。

    控制台
    Address  MT           Size  Gen
    1875d2c0 790fa3e0      152    2 System.String HighlightNullStyle_Blotter_PendingOrder-11_Blotter_PendingOrder-11
    …
    

    对 ID 使用整数而非字符串,这样可能会更有效。 如果数千次重复相同的字符串,请考虑字符串暂留。 有关字符串暂留的详细信息,请参阅 String.Intern 方法的参考主题。

若要确定对对象的引用

  • 在加载了 SOS 调试器扩展的 WinDbg 中,输入以下命令,以列出对对象的引用:

    !gcroot

  • 若要确定对特定对象的引用,包括地址:

    !gcroot 1c37b2ac

    在堆栈上找到的根可能是误报。 有关详细信息,请参阅命令 !help gcroot

    控制台
    ebx:Root:19011c5c(System.Windows.Forms.Application+ThreadContext)->
    19010b78(DemoApp.FormDemoApp)->
    19011158(System.Windows.Forms.PropertyStore)->
    … [omitted]
    1c3745ec(System.Data.DataTable)->
    1c3747a8(System.Data.DataColumnCollection)->
    1c3747f8(System.Collections.Hashtable)->
    1c376590(System.Collections.Hashtable+bucket[])->
    1c376c98(System.Data.DataColumn)->
    1c37b270(System.Data.Common.DoubleStorage)->
    1c37b2ac(System.Double[])
    Scan Thread 0 OSTHread 99c
    Scan Thread 6 OSTHread 484
    

    gcroot 命令可能需要很长时间才能完成。 任何不通过垃圾回收进行回收的对象是活动对象。 这意味着,某些根是直接或间接地保留于该对象,因此 gcroot 应将路径信息返回到该对象。 应检查返回的关系图,并查看仍然引用这些对象的原因。

若要确定是否已运行终结器

  • 则运行包含以下代码的测试程序:

    C#
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
    

    如果测试解决了此问题,这意味着垃圾回收器未回收对象,因为这些对象的终结器已被挂起。 GC.WaitForPendingFinalizers 方法将启用这些终结器来完成其任务,并解决问题。

若要确定是否存在等待被终结的对象

  1. 在加载了 SOS 调试器扩展的 WinDbg 或 Visual Studio 调试器中,输入以下命令:

    !finalizequeue

    查看已准备好进行终结的对象的数目。 如果数目很多,则必须检查这些终结器完全没有进展或进展速度不够快的原因。

  2. 若要获取线程的输出,请输入以下命令:

    !threads -special

    此命令提供如下所示的输出。

    控制台
       OSID     Special thread type
    2    cd0    DbgHelper
    3    c18    Finalizer
    4    df0    GC SuspendEE
    

    终结器线程将指示当前正在运行的终结器(如果存在)。 当终结器线程没有运行任何终结器时,则它正在等待一个事件告诉它进行工作。 大多数情况下,你将看到此状态中的终结器线程,因为它在 THREAD_HIGHEST_PRIORITY 处运行,并应快速完成运行终结器(如果存在)。

若要确定托管堆中的可用空间量

  • 在加载了 SOS 调试器扩展的 WinDbg 或 Visual Studio 调试器中,输入以下命令:

    !dumpheap -type Free -stat

    此命令将显示托管堆上所有可用对象的总大小,如以下示例中所示。

    控制台
    total 230 objects
    Statistics:
          MT    Count    TotalSize Class Name
    00152b18      230     40958584      Free
    Total 230 objects
    
  • 若要确定第 0 代中的可用空间,请输入以下命令以获取代的内存使用信息:

    !eeheap -gc

    该命令将显示类似以下所示的输出。 最后一行将显示暂时段。

    控制台
    Heap 0 (0015ad08)
    generation 0 starts at 0x49521f8c
    generation 1 starts at 0x494d7f64
    generation 2 starts at 0x007f0038
    ephemeral segment allocation context: none
    segment  begin     allocated  size
    00178250 7a80d84c  7a82f1cc   0x00021980(137600)
    00161918 78c50e40  78c7056c   0x0001f72c(128812)
    007f0000 007f0038  047eed28   0x03ffecf0(67103984)
    3a120000 3a120038  3a3e84f8   0x002c84c0(2917568)
    46120000 46120038  49e05d04   0x03ce5ccc(63855820)
    
  • 计算 0 代使用的空间:

    ? 49e05d04-0x49521f8c

    结果如下所示: 0 代大约为 9 MB。

    控制台
    Evaluate expression: 9321848 = 008e3d78
    
  • 以下命令将转储 0 代范围内的可用空间:

    !dumpheap -type Free -stat 0x49521f8c 49e05d04

    结果如下所示:

    控制台
    ------------------------------
    Heap 0
    total 409 objects
    ------------------------------
    Heap 1
    total 0 objects
    ------------------------------
    Heap 2
    total 0 objects
    ------------------------------
    Heap 3
    total 0 objects
    ------------------------------
    total 409 objects
    Statistics:
          MT    Count TotalSize Class Name
    0015a498      409   7296540      Free
    Total 409 objects
    

    此输出显示堆的 0 代部分正在对对象使用 9 MB 的空间并且有 7 MB 可用。 此分析显示了 0 代对碎片的贡献程度。 此堆的使用量应从总量中扣除,作为长期对象所产生的碎片的原因。

若要确定固定对象的数目

  • 在加载了 SOS 调试器扩展的 WinDbg 或 Visual Studio 调试器中,输入以下命令:

    !gchandles

    显示的统计信息包括固定句柄的数量,如以下示例所示。

    控制台
    GC Handle Statistics:
    Strong Handles:      29
    Pinned Handles:      10
    

若要确定垃圾回收中的时间

  • 检查 % Time in GC 内存性能计数器。

    通过使用采样间隔时间来计算值。 因为该计数器在每次垃圾回收结束时进行更新,所以如果在间隔期间没有产生任何回收,则当前的示例将具有与之前的示例相同的值。

    回收时间是通过将采样间隔时间乘以百分比值获取的。

    以下数据显示了为时 8 秒的研究的 4 个采样,彼此间隔 2 秒。 Gen0Gen1 和 Gen2 列显示截至间隔结束时为止已为该代完成的垃圾回收总数。

    控制台
    Interval    Gen0    Gen1    Gen2    % Time in GC
            1       9       3       1              10
            2      10       3       1               1
            3      11       3       1               3
            4      11       3       1               3
    

    当垃圾回收发生时,将不会显示此信息,但可以确定时间间隔中发生的垃圾回收数。 假设出现最坏的情况,第 10 个 0 代垃圾回收在第 2 个间隔开始时完成,且第 11 个 0 代垃圾回收在第 3 个间隔结束时完成。 第 10 个和第 11 个垃圾回收结束时之间的时间约为 2 秒钟,并且性能计数器显示为 3%,因此第 11 个 0 代垃圾回收的持续时间为(2 秒 * 3%= 60 毫秒)。

    在下一个示例中,有五个间隔。

    控制台
    Interval    Gen0    Gen1    Gen2     % Time in GC
            1       9       3       1                3
            2      10       3       1                1
            3      11       4       1                1
            4      11       4       1                1
            5      11       4       2               20
    

    第 2 个第 2代垃圾回收在第 4 个间隔期间开始并在第 5 个间隔处完成。 假设最坏情况下,最后一次垃圾回收是针对在第 3 个间隔开始时完成的 0 代回收,且第 2 代垃圾回收在第 5 个间隔结束时完成。 因此,第 0 代垃圾回收结束和第 2 代垃圾回收结束之间的时间是 4 秒。 因为 % Time in GC 计数器为 20%,所以第 2 代垃圾回收可能使用的最长时间为(4 秒 * 20%= 800 毫秒)。

  • 或者,可以通过使用垃圾回收 ETW 事件,确定垃圾回收的时长,并分析此信息以确定垃圾回收的持续时间。

    例如,以下数据显示了一个发生在非并发垃圾回收期间的事件序列。

    控制台
    Timestamp    Event name
    513052        GCSuspendEEBegin_V1
    513078        GCSuspendEEEnd
    513090        GCStart_V1
    517890        GCEnd_V1
    517894        GCHeapStats
    517897        GCRestartEEBegin
    517918        GCRestartEEEnd
    

    挂起托管线程花费了 26us (GCSuspendEEEnd – GCSuspendEEBegin_V1)。

    实际的垃圾回收花费了 4.8 毫秒 (GCEnd_V1 – GCStart_V1)。

    回复执行托管线程花费了 21us (GCRestartEEEnd – GCRestartEEBegin)。

    以下输出为后台垃圾回收提供了一个示例,幷包括进程、线程和事件字段。 (没有显示所有数据。)

    控制台
    timestamp(us)    event name            process    thread    event field
    42504385        GCSuspendEEBegin_V1    Test.exe    4372             1
    42504648        GCSuspendEEEnd         Test.exe    4372
    42504816        GCStart_V1             Test.exe    4372        102019
    42504907        GCStart_V1             Test.exe    4372        102020
    42514170        GCEnd_V1               Test.exe    4372
    42514204        GCHeapStats            Test.exe    4372        102020
    42832052        GCRestartEEBegin       Test.exe    4372
    42832136        GCRestartEEEnd         Test.exe    4372
    63685394        GCSuspendEEBegin_V1    Test.exe    4744             6
    63686347        GCSuspendEEEnd         Test.exe    4744
    63784294        GCRestartEEBegin       Test.exe    4744
    63784407        GCRestartEEEnd         Test.exe    4744
    89931423        GCEnd_V1               Test.exe    4372        102019
    89931464        GCHeapStats            Test.exe    4372
    

    42504816 处的 GCStart_V1 事件指示此为一个后台垃圾回收,因为最后一个字段是 1。 这将变为垃圾回收 No.102019。

    将发生 GCStart 事件,因为在开始后台垃圾回收之前,需要一个暂时垃圾回收。 这将变为垃圾回收 No. 102020。

    在 42514170 处,垃圾回收 No.102020 结束。 此时,将重新启动托管线程。 这将在触发此后台垃圾回收的线程 4372 上完成。

    在线程 4744 上,发生了一个挂起。 这是唯一一次后台垃圾回收不得不挂起托管线程。 此持续时间为大约 99 毫秒 ((63784407-63685394)/1000)。

    后台垃圾回收的 GCEnd 事件位于 89931423。 这意味着后台垃圾回收持续了大约 47 秒 ((89931423-42504816)/1000)。

    托管线程运行时,可以查看发生的任意数量的暂时垃圾回收。

若要确定触发垃圾回收的原因

  • 在加载了 SOS 调试器扩展的 WinDbg 或 Visual Studio 调试器中,输入以下命令,以显示所有带调用堆栈的线程:

    ~*kb

    该命令将显示类似以下所示的输出。

    控制台
    0012f3b0 79ff0bf8 mscorwks!WKS::GCHeap::GarbageCollect
    0012f454 30002894 mscorwks!GCInterface::CollectGeneration+0xa4
    0012f490 79fa22bd fragment_ni!request.Main(System.String[])+0x48
    

    如果垃圾回收是操作系统的内存不足通知引起的,则调用堆栈会非常相似,除了线程是终结器线程之外。 终结器线程将获取异步内存不足的通知,并引发垃圾回收。

    如果垃圾回收是内存分配引起的,则堆栈显示如下:

    控制台
    0012f230 7a07c551 mscorwks!WKS::GCHeap::GarbageCollectGeneration
    0012f2b8 7a07cba8 mscorwks!WKS::gc_heap::try_allocate_more_space+0x1a1
    0012f2d4 7a07cefb mscorwks!WKS::gc_heap::allocate_more_space+0x18
    0012f2f4 7a02a51b mscorwks!WKS::GCHeap::Alloc+0x4b
    0012f310 7a02ae4c mscorwks!Alloc+0x60
    0012f364 7a030e46 mscorwks!FastAllocatePrimitiveArray+0xbd
    0012f424 300027f4 mscorwks!JIT_NewArr1+0x148
    000af70f 3000299f fragment_ni!request..ctor(Int32, Single)+0x20c
    0000002a 79fa22bd fragment_ni!request.Main(System.String[])+0x153
    

    实时帮助程序 (JIT_New*) 最终调用 GCHeap::GarbageCollectGeneration。 如果确定第 2 代垃圾回收是分配引起的,则必须确定第 2 代垃圾回收所分配的对象以及如何避免它们。 也就是说,想要确定第 2 代垃圾回收的开始和结束之间的差异,以及引发第 2 代回收的对象。

    例如,在调试器中输入以下命令,以显示第 2 代回收的开始:

    !dumpheap –stat

    输出示例(经过删减以显示使用的最多空间的对象):

    控制台
    79124228    31857      9862328 System.Object[]
    035f0384    25668     11601936 Toolkit.TlkPosition
    00155f80    21248     12256296      Free
    79103b6c   297003     13068132 System.Threading.ReaderWriterLock
    7a747ad4   708732     14174640 System.Collections.Specialized.HybridDictionary
    7a747c78   786498     15729960 System.Collections.Specialized.ListDictionary+DictionaryNode
    7a747bac   700298     19608344 System.Collections.Specialized.ListDictionary
    035f0ee4    89192     38887712 Toolkit.TlkOrder
    00fcae40     6193     44911636 WaveBasedStrategy.Tick_Snap[]
    7912c444    91616     71887080 System.Double[]
    791242ec    32451     82462728 System.Collections.Hashtable+bucket[]
    790fa3e0  2459154    112128436 System.String
    Total 6471774 objects
    

    在第 2 代结束时,重复该命令:

    !dumpheap –stat

    输出示例(经过删减以显示使用的最多空间的对象):

    控制台
    79124228    26648      9314256 System.Object[]
    035f0384    25668     11601936 Toolkit.TlkPosition
    79103b6c   296770     13057880 System.Threading.ReaderWriterLock
    7a747ad4   708730     14174600 System.Collections.Specialized.HybridDictionary
    7a747c78   786497     15729940 System.Collections.Specialized.ListDictionary+DictionaryNode
    7a747bac   700298     19608344 System.Collections.Specialized.ListDictionary
    00155f80    13806     34007212      Free
    035f0ee4    89187     38885532 Toolkit.TlkOrder
    00fcae40     6193     44911636 WaveBasedStrategy.Tick_Snap[]
    791242ec    32370     82359768 System.Collections.Hashtable+bucket[]
    790fa3e0  2440020    111341808 System.String
    Total 6417525 objects
    

    double[] 对象从输出的末尾消失,这意味着它们被回收了。 这些对象大约占 70 MB。 剩余的对象没有太多变化。 因此,这些 double[] 对象是第 2 代垃圾回收发生的原因。 下一步是确定 double[] 对象存在以及他们最后死亡的原因。 可以询问代码开发人员这些对象的来源,或使用 gcroot 命令。

若要确定 CPU 的使用率高是否是垃圾回收引起的

  • 将 % Time in GC 内存性能计数器的值与处理时间相关联。

    如果 % Time in GC 值在与处理时间同时达到峰值,则垃圾回收将造成 CPU 使用率过高。 否则,配置应用程序,以查找发生使用率过高的位置。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章