Windows 系统上的大型对象堆

.NET 垃圾回收器(GC)将对象划分为小型和大型对象。 如果是大型对象,它的某些特性将比对象较小时显得更为重要。 例如,压缩它(即在堆中的其他内存位置复制它)可能会很耗费资源。 因此,垃圾回收器会将大型对象放置在大型对象堆上(LOH)。 本文讨论如何将对象限定为大型对象、收集大型对象的方式,以及大型对象施加的性能影响。

重要

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

对象如何在 LOH 上结束

如果对象的大小大于或等于 85,000 字节,则被视为大型对象。 此数字由性能优化确定。 当对象分配请求为 85,000 个或更多个字节时,运行时将在大型对象堆上分配它。

若要了解这意味着什么,了解关于垃圾回收器的一些基础知识非常有用。

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

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

大型对象属于第 2 代,因为它们仅在第 2 代集合中收集。 回收一代时,同时也会回收它前面的所有代。 例如,当第 1 代 GC 发生时,第 1 代和第 0 代都会被收集。 执行第 2 代 GC 时,将回收整个堆。 因此,第 2 代 GC 也称为 完整 GC。 本文指第 2 代 GC 而不是完整 GC,但术语是可互换的。

代系提供 GC 堆的逻辑视图。 实际上,对象存在于托管堆段中。 托管堆段是 GC 代表托管代码调用 VirtualAlloc 函数从 OS 保留的一块内存。 加载 CLR 后,GC 会分配两个初始堆段:一个用于小型对象(小型对象堆或 SOH),一个用于大型对象(大型对象堆)。

然后,通过在这些托管堆段上放置托管对象来满足分配请求。 如果对象小于 85,000 字节,将其放入 SOH 段中;否则,将其放入 LOH 段中。 随着分配到各段上的对象越来越多,会以较小块的形式提交这些段。 对于 SOH,GC 未处理的对象将提升为下一代。 在第 0 代集合中幸存的对象现在被视为第 1 代对象等。 但是,最后一代回收未处理的对象仍会被视为最后一代中的对象。 换句话说,第 2 代幸存者是第 2 代对象:LOH 的幸存者是 LOH 对象(使用 gen2 收集)。

用户代码只能在第 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 代,其中 Obj1Obj3 被清除;在第一次第 1 代 GC 后形成了第 2 代,其中 Obj2Obj5 被清除。 请注意,此图和下图仅用于说明目的:它们包含很少的对象,以更好地显示堆上发生的情况。 实际上,涉及 GC 的对象通常要多得多。

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

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

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

如果没有足够的可用空间来容纳大型对象分配请求,GC 会首先尝试从 OS 获取更多段。 如果无法成功,它将触发第二代GC,希望能够释放出一些空间。

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

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

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

何时收集大型对象?

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

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

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

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

  • 调用 GC.Collect 方法。

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

  • 系统内存不足。

    当垃圾回收器从 OS 收到高内存通知时,将发生这种情况。 如果垃圾回收器认为执行第 2 代 GC 会有效率,它将触发第 2 代。

LOH 性能意义

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

  • 分配成本。

    CLR 确保清除了它提供的每个新对象的内存。 这意味着大型对象的分配成本主要由内存清理决定(除非它触发 GC)。 要清除一个字节如果需要两个周期,那么清除体积最小的大对象需要 170,000 个周期。 清除 2-GHz 计算机上的 16 MB 对象的内存大约需要 16 毫秒。 这是一个相当大的成本。

  • 收集成本。

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

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

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

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

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

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

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

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

收集 LOH 的性能数据

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

  1. 找到了表明你应该关注此区域的证据。
  2. 检查了你了解的所有其他方面,但仍未找到任何能够解释你所观察到的性能问题的因素。

有关内存和 CPU 基础知识的详细信息,请参阅博客 ,了解问题,然后再尝试查找解决方案

可以使用以下工具收集有关 LOH 性能的数据:

.NET CLR 内存性能计数器

.NET CLR 内存性能计数器通常是调查性能问题的第一步(尽管我们建议使用 ETW 事件)。 查看性能计数器的一种常见方法是使用性能监视器(perfmon.exe)。 选择 “添加 ”(Ctrl + A),为关注的进程添加有趣的计数器。 可以将性能计数器数据保存到日志文件。

.NET CLR 内存类别中的以下两个计数器与 LOH 相关:

  • # 第 2 代集合

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

  • 大型对象堆大小

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

显示在性能监视器中添加计数器的屏幕截图。

还可以使用 PerformanceCounter 类以编程方式查询性能计数器。 对于 LOH,请将 “.NET CLR 内存” 指定为 CategoryName,并将 “大型对象堆大小” 指定为 CounterName

PerformanceCounter performanceCounter = new()
{
    CategoryName = ".NET CLR Memory",
    CounterName = "Large Object Heap size",
    InstanceName = "<instance_name>"
};

Console.WriteLine(performanceCounter.NextValue());

作为例程测试过程的一部分,通常以编程方式收集计数器。 当发现具有非常规值的计数器时,请使用其他方法获取更详细的数据来帮助调查。

注释

建议使用 ETW 事件而不是性能计数器,因为 ETW 提供了更丰富的信息。

ETW 事件

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

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

perfview /GCCollectOnly /AcceptEULA /nogui collect

结果如下所示:

显示 PerfView 中的 ETW 事件的屏幕截图。

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

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

perfview /GCOnly /AcceptEULA /nogui collect

收集 AllocationTick 事件,大约每 10 万次分配就会触发该事件。 换句话说,每次分配大型对象都会触发事件。 然后,可以查看其中一个 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.ObjectSystem.Byte占用 6,663,696 字节,可用空间占用 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 碎片,可以在 VirtualAllocVirtualFree 上设置断点以查看调用它们的人员。 例如,若要查看谁尝试从 OS 分配大于 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 可以找到足够大的段,则 CLR 会使用此备用列表中的一个。

VM 囤积对于希望保留已获取的段的应用程序也很有用,例如,某些服务器应用是系统上运行的主导应用,以避免内存不足异常。

强烈建议在使用此功能时仔细测试应用程序,以确保应用程序内存使用率相当稳定。