大型对象堆揭秘
Maoni Stephens
CLR 垃圾回收器 (GC) 将对象分为大型、小型两类。如果是大型对象,与其相关的一些属性将比对象较小时显得更为重要。例如,压缩大型对象(将内存复制到堆上的其他位置)的费用相当高。在本月的专栏中,我将深入探讨大型对象堆。我将讨论符合什么条件的对象才能称之为大型对象,如何回收这些大型对象,以及大型对象具备哪些性能意义。
大型对象堆和 GC
在 Microsoft® .NET Framework 1.1 和 2.0 中,如果对象大于或等于 85,000 字节,将被视为大型对象。此数字根据性能优化的结果确定。当对象分配请求传入后,如果符合该大小阈值,便会将此对象分配给大型对象堆。这究竟是什么意思呢?要理解这些内容,先了解一些关于 .NET 垃圾回收器的基础知识可能会有所帮助。
众所周知,.NET 垃圾回收器是分代回收器。它包含三代:第 0 代、第 1 代和第 2 代。之所以分代,是因为在良好调优的应用程序中,您可以在第 0 代清除大部分对象。例如,在服务器应用程序中,与每个请求关联的分配将在完成请求后清除。仍存在的分配请求将转到第 1 代,并在那里进行清除。从本质上讲,第 1 代是新对象区域与生存期较长的对象区域之间的缓冲区。
从分代的角度来说,大型对象属于第 2 代,因为只有在第 2 代回收过程中才能回收它们。回收一代时,同时也会回收所有前面的代。例如,执行第 1 代垃圾回收时,将同时回收第 1 代和第 0 代。执行第 2 代垃圾回收时,将回收整个堆。因此,第 2 代垃圾回收也称为完整垃圾回收。在本专栏中,我将使用术语“第 2 代垃圾回收”而不是“完整垃圾回收”,但它们可以互换。
垃圾回收器堆的各代是按逻辑划分的。实际上,对象存在于托管堆栈段上。托管堆栈段是垃圾回收器通过调用 VirtualAlloc 代表托管代码在操作系统上保留的内存块。加载 CLR 时,将分配两个初始堆栈段(一个用于小型对象,另一个用于大型对象),我将它们分别称为小型对象堆 (SOH) 和大型对象堆 (LOH)。
然后,通过将托管对象置于任一托管堆栈段上来满足分配请求。如果对象小于 85,000 字节,则将其放在 SOH 段上;否则将其放在 LOH 段上。随着分配到各段上的对象越来越多,会以较小块的形式提交这些段。
对于 SOH,垃圾回收未处理的对象将进入下一代;由此第 0 代回收未处理的对象将被视为第 1 代对象,依此类推。但是,最后一代回收未处理的对象仍会被视为最后一代中的对象。也就是说,第 2 代垃圾回收未处理的对象仍是第 2 代对象;LOH 未处理的对象仍是 LOH 对象(由第 2 代回收)。用户代码只能在第 0 代(小型对象)或 LOH(大型对象)中分配。只有垃圾回收器可以在第 1 代(通过提升第 0 代回收未处理的对象)和第 2 代(通过提升第 1 代和第 2 代回收未处理的对象)中“分配”对象。
触发垃圾回收后,垃圾回收器将寻找存在的对象并将它们压缩。不过对于 LOH,由于压缩费用很高,CLR 团队会选择扫过所有对象,列出没有被清除的对象列表以供以后重新使用,从而满足大型对象的分配请求。相邻的被清除对象将组成一个自由对象。
有一点必须注意,虽然目前我们不会压缩 LOH,但将来可能会进行压缩。因此,如果您分配了大型对象并希望确保它们不被移动,则应将其固定起来。
请注意,下面的图仅用于说明。我使用了很少的对象,只为说明堆上发生的事件。实际上,还存在许多对象。
图 1 说明了一种情况,在第一次第 0 代 GC 后形成了第 1 代,其中 Obj1 和 Obj3 被清除;在第一次第 1 代 GC 后形成了第 2 代,其中 Obj2 和 Obj5 被清除。
图 1 SOH 分配和垃圾回收(单击图像可查看大图)
图 2 说明在第 2 代垃圾回收后,您将看到 Obj1 和 Obj2 被清除,内存中原来存放 Obj1 和 Obj2 的空间将成为一个可用空间,随后可用于满足 Obj4 的分配请求。从最后一个对象 Obj3 到此段末尾的空间仍可用于以后的分配请求。
图 2 LOH 分配和垃圾回收(单击图像可查看大图)
如果没有足够的可用空间来容纳大型对象分配请求,我会先尝试从操作系统获取更多段。如果失败,我将触发第 2 代垃圾回收以便释放一些空间。
在第 2 代垃圾回收期间,我会把握时机将不包含任何活动对象的段释放回操作系统(通过调用 VirtualFree)。从最后一个存在的对象到该段末尾的内存将退回。而且,尽管已重置可用空间,但仍会提交它们,这意味着操作系统无需将其中的数据重新写入磁盘。图 3 说明了一种情况,我将一个段(段 2)释放回操作系统,并在剩下的段中退回了更多空间。如果需要使用该段末尾的已退回空间来满足新的大型对象分配请求,我可以再次提交该内存。
图 3 垃圾回收期间在 LOH 上释放的已消除段(单击图像可查看大图)
有关提交/退回的说明,请参阅有关 VirtualAlloc 的 MSDN® 文档,网址为 go.microsoft.com/fwlink/?LinkId=116041。
何时回收大型对象
要确定何时回收大型对象,我们首先讨论一下通常何时会执行垃圾回收。如果发生下列情况之一,将执行垃圾回收:
分配超出第 0 代或大型对象阈值 大部分 GC 都是由于需在托管堆上进行分配而执行(这是最典型的情况)。
调用 System.GC.Collect 如果对第 2 代调用 GC.Collect(通过不向 GC.Collect 传递参数或将 GC.MaxGeneration 作为参数传递),将立即回收 LOH 及其他托管堆。
系统内存太低 收到来自操作系统的高内存通知时会发生此情况。如果我认为执行第 2 代垃圾回收会有所帮助,就会触发一个垃圾回收。
阈值是各代的属性。将对象分配给某代时,会增加该代的内存量,使之接近该代的阈值。当超出某代的阈值时,便会在该代触发垃圾回收。因此,当您分配小型或大型对象时,需要分别使用第 0 代和 LOH 的阈值。当垃圾回收器分配到第 1 代和第 2 代中时,将使用第 1 代的阈值。运行此程序时,会动态调整这些阈值。
LOH 性能意义
下面,我们来看一下分配成本。CLR 确保清除了我提供的每个新对象的内存。这意味着大型对象的分配成本完全由清理的内存(除非触发了垃圾回收)决定。如果需要两轮才能清除 1 个字节,则意味着需要 170,000 轮才能清除最小的大型对象。这对于分配较大的大型对象的人们来说很平常。对于 2GHz 计算机上的 16MB 对象,大约需要 16ms 才能清除内存。这些成本相当大。
现在我们来看一下回收成本。前面曾提到,LOH 和第 2 代将一起回收。如果超过两者中任何一个的阈值,都会触发第 2 代回收。如果由于第 2 代为 LOH 而触发了第 2 代回收,则第 2 代本身在垃圾回收后不一定会变得更小。因此,如果第 2 代中的数据不多,这将不是问题。但是,如果第 2 代很大,则触发多次第 2 代垃圾回收可能会产生性能问题。如果要临时分配许多大型对象,并且您拥有一个大型 SOH,则运行垃圾回收可能会花费很长时间;毫无疑问,如果仍继续分配和处理真正的大型对象,分配成本肯定会大幅增加。
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; };
这样,您可将左侧节点的数据作为 binary_tr[left_index].d 引用,而非作为 left.d 引用;而垃圾回收器无需查看左侧节点和右侧节点的任何引用。
在这三个回收原因中,通常前两个比第三个出现得多。因此,最好能够分配一个大型对象池并重新使用这些对象,而不是分配临时对象。Yun Jin 在其博客日志 (go.microsoft.com/fwlink/?LinkId=115870) 中介绍了一个此类缓冲池的示例。当然,您可能希望增加缓冲区大小。
回收 LOH 的性能数据
可以通过某些方法来回收与 LOH 相关的性能数据。不过,在介绍它们之前,我们先谈论一下为什么要进行回收。
在开始回收特定区域的性能数据前,希望您已经找到需查看此区域的原因,或您已查看了其他已知区域但未发现任何问题可解释您需要解决的性能问题。
有关详细解释,建议您阅读我的博客日志(请参见 go.microsoft.com/fwlink/?LinkId=116467)。在日志中,我介绍了内存和 CPU 的基础知识。另外,2006 年 11 月期刊中的“CLR 全面透彻解析”针对内存问题进行了调查,介绍了在托管过程中诊断可能与托管堆相关的性能问题涉及的步骤(请参见 msdn2.microsoft.com/magazine/cc163528)。
.NET CLR 内存性能计数器通常是调查性能问题的第一步。与 LOH 相关的计数器显示第 2 代回收的数目和大型对象堆的大小。第 2 代回收的数目显示了自回收过程开始执行第 2 代垃圾回收的次数。计数器会在第 2 代垃圾回收(也称为完整垃圾回收)结束时递增。此计数器显示最后看到的值。
大型对象堆大小指的是大型对象堆的当前大小(以字节为单位,包括可用空间)。此计数器将在垃圾回收结束时更新,而不是在每次分配时更新。
查看性能计数器的常用方法是使用性能监视器 (PerfMon.exe)。使用“添加计数器”可为您关注的过程添加感兴趣的计数器,如图 4 所示。
图 4 在性能监视器中添加计数器(单击图像可查看大图)
您可以将性能计数器数据保存在性能监视器的日志文件中,也可以编程方式查询性能计数器。大部分人在例行测试过程中都采用此方式进行收集。如果发现计数器显示的值不正常,则可以使用其他方法获得更多详细信息以帮助调查。
使用调试器
在开始之前,请注意我此部分提及的调试命令仅适用于 Windows® 调试器。如果需要查看 LOH 上实际存在的对象,您可以使用 CLR 提供的 SoS 调试器扩展,在前面提到的 2006 年 11 月期刊中已对此进行了介绍。图 5 中显示了分析 LOH 的输出示例。
图 5 中的加粗部分显示 LOH 堆的大小为 (16,754,224 + 16,699,288 + 16,284,504 =) 49,738,016 个字节。而在 023e1000 和 033db630 之间,System.Object[] 对象占用了 8,008,736 个字节;System.Byte[] 对象占用了 6,663,696 个字节;可用空间占用了 2,081,792 个字节。
图 5 LOH 输出
0:003> .loadby sos mscorwks 0:003> !eeheap -gc Number of GC Heaps: 1 generation 0 starts at 0x013e35ec generation 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 的总大小少于 85,000 个字节。为什么会这样?这是因为运行时本身实际使用 LOH 分配某些小于大型对象的对象。
由于不会压缩 LOH,有时人们会怀疑 LOH 是碎片源。事实上,在得出这个结论前,您最好先弄清什么是碎片。有一种托管堆碎片,由托管对象之间的可用空间量指示(换句话说,在 SoS 中执行 !dumpheap –type Free 时看到的内容);还有虚拟内存 (VM) 地址空间碎片,即标记为 MEM_FREE 的内存以及在 windbg 中使用各种调试器命令可看到的内容(请参见 go.microsoft.com/fwlink/?LinkId=116470)。图 6 显示了虚拟内存空间中的碎片(请注意图中的加粗文本)。
图 6 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)
前面曾提到,托管堆上的碎片用于分配请求。通常看到的更多是由临时大型对象导致的虚拟内存碎片,需要频繁进行垃圾回收以便从操作系统获取新的托管堆段,并将空托管堆段释放回操作系统。
要验证 LOH 是否会生成 VM 碎片,可在 VirtualAlloc 和 VirtualFree 上设置一个断点,查看是谁调用了它们。例如,如果想知道谁曾尝试从操作系统分配大于 8MB 的 VM 块,可按以下方式设置断点:
bp kernel32!virtualalloc "j (dwo(@esp+8)>800000) 'kb';'g'"
如果调用 VirtualAlloc 时分配大小大于 8MB (0x800000),此代码会中断调试器并显示调用堆栈,否则不会中断调试器。
在 CLR 2.0 中,我们添加了名为 VM Hoarding 的功能,如果需要经常获取和释放段(包括用于大型对象堆和小型对象堆两者的段),则可以使用此功能。要指定 VM Hoarding 功能,请通过宿主 API 指定名为 STARTUP_HOARD_GC_VM 的启动标志(请参见 go.microsoft.com/fwlink/?LinkId=116471)。指定此标志后,只会退回这些段上的内存并将其添加到备用列表中,而不会将该空段释放回操作系统。备用列表上的段以后可用于满足新的段请求。因此,下次需要新段时,如果可以从此备用列表找到足够大的段,便可以使用它。
请注意,对于太大的段,该功能不起作用。此功能还可供某些应用程序用以承载其已获得的段,如一些服务器应用程序,它们会尽可能避免生成 VM 空间碎片以防出现内存不足错误。由于它们通常是计算机上的主应用程序,所以可以执行这些操作。强烈建议您在使用此功能时认真测试您的应用程序,以确保内存使用情况比较稳定。
大型对象费用很高。由于 CLR 需要清除一些新分配大型对象的内存,以满足 CLR 清除所有新分配对象内存的保证,所以分配成本相当高。LOH 将与堆的其余部分一起回收,所以请仔细分析这会对您的应用程序性能造成什么影响。如果可以,建议重新使用大型对象以避免托管堆和 VM 空间中生成碎片。
最后,到目前为止,在回收过程中尚不能压缩 LOH,但不应依赖于此实现详情。因此,要确保某些内容未被 GC 移动,请始终将其固定起来。现在,请利用您刚学到的 LOH 知识对堆进行控制。
请将您的问题和意见发送至 clrinout@microsoft.com。