大对象堆碎片化

99
我正在处理的C#/.NET应用程序存在严重的内存泄漏问题。我已经使用了CDB和SOS来尝试确定发生了什么事情,但是数据似乎没有任何意义,所以我希望你们中的某个人之前曾经遇到过这种情况。
该应用程序在64位框架上运行。它不断地计算和序列化数据发送到远程主机,并频繁触发大对象堆(LOH)操作。然而,我预期大部分的LOH对象都应该是短暂的:一旦计算完成并已发送到远程主机,内存就应该被释放。但是,我看到的却是大量的(活动)对象数组夹杂着空闲的内存块,例如,从LOH获取一个随机段落:
0:000> !DumpHeap 000000005b5b1000  000000006351da10
         Address               MT     Size
...
000000005d4f92e0 0000064280c7c970 16147872
000000005e45f880 00000000001661d0  1901752 Free
000000005e62fd38 00000642788d8ba8     1056       <--
000000005e630158 00000000001661d0  5988848 Free
000000005ebe6348 00000642788d8ba8     1056
000000005ebe6768 00000000001661d0  6481336 Free
000000005f214d20 00000642788d8ba8     1056
000000005f215140 00000000001661d0  7346016 Free
000000005f9168a0 00000642788d8ba8     1056
000000005f916cc0 00000000001661d0  7611648 Free
00000000600591c0 00000642788d8ba8     1056
00000000600595e0 00000000001661d0   264808 Free
...

显然,如果我的应用程序在每个计算过程中创建的是长期存在且较大的对象,那么我会期望出现这种情况。(确实是这样,并且我接受会有一定程度的LOH碎片,但这不是问题所在。)问题在于上面转储中可以看到的非常小(1056字节)的对象数组,在代码中我看不到它们被创建,但它们仍然以某种方式保持根引用。

还要注意的是,在堆段转储时,CDB没有报告类型: 我不确定这是否相关。如果我转储标记(<--)对象,则CDB/SOS会正确报告它:

0:015> !DumpObj 000000005e62fd38
Name: System.Object[]
MethodTable: 00000642788d8ba8
EEClass: 00000642789d7660
Size: 1056(0x420) bytes
Array: Rank 1, Number of elements 128, Type CLASS
Element Type: System.Object
Fields:
None
对象数组的元素均为字符串,并且这些字符串可以被识别为来自我们的应用程序代码。
此外,我无法找到它们的GC根,因为!GCRoot命令挂起并永远不会返回(我甚至尝试过过夜也没有结果)。
因此,如果有人能够解释一下为什么这些小于85k的对象数组最终出现在LOH中,我将非常感激:.NET将在什么情况下将小型对象数组放入其中? 另外,是否有其他方法可以确定这些对象的根?
更新1:
昨天晚上我想出了另一种理论,即这些对象数组最初很大,但已经缩小,留下了在内存转储中明显的空闲内存块。 我怀疑的原因是对象数组始终似乎有1056字节长(128个元素),128 * 8个引用和32字节的开销。
这个想法是,也许库或CLR中的某些不安全的代码正在损坏数组标题中的元素数字段。 我知道这有点牵强...
更新2:
由于Brian Rasmussen(请参见已接受的答案),问题已被确认为由字符串内部表引起的LOH碎片! 我编写了一个快速测试应用程序来确认这一点:
static void Main()
{
    const int ITERATIONS = 100000;

    for (int index = 0; index < ITERATIONS; ++index)
    {
        string str = "NonInterned" + index;
        Console.Out.WriteLine(str);
    }

    Console.Out.WriteLine("Continue.");
    Console.In.ReadLine();

    for (int index = 0; index < ITERATIONS; ++index)
    {
        string str = string.Intern("Interned" + index);
        Console.Out.WriteLine(str);
    }

    Console.Out.WriteLine("Continue?");
    Console.In.ReadLine();
}

该应用程序首先在循环中创建并取消引用唯一的字符串。这只是为了证明在这种情况下内存不会泄漏。显然,内存不应该泄漏,它确实没有。

在第二个循环中,唯一的字符串被创建并国际化。这个操作将它们固定在intern表中。我没有意识到的是intern表是如何表示的。它似乎由一组页面组成——每个页面包含128个字符串元素的对象数组——它们在LOH中创建。这在CDB/SOS中更加明显:

0:000> .loadby sos mscorwks
0:000> !EEHeap -gc
Number of GC Heaps: 1
generation 0 starts at 0x00f7a9b0
generation 1 starts at 0x00e79c3c
generation 2 starts at 0x00b21000
ephemeral segment allocation context: none
 segment    begin allocated     size
00b20000 00b21000  010029bc 0x004e19bc(5118396)
Large object heap starts at 0x01b21000
 segment    begin allocated     size
01b20000 01b21000  01b8ade0 0x00069de0(433632)
Total Size  0x54b79c(5552028)
------------------------------
GC Heap Size  0x54b79c(5552028)

获取 LOH 段的转储显示了我在泄漏应用程序中看到的模式:

0:000> !DumpHeap 01b21000 01b8ade0
...
01b8a120 793040bc      528
01b8a330 00175e88       16 Free
01b8a340 793040bc      528
01b8a550 00175e88       16 Free
01b8a560 793040bc      528
01b8a770 00175e88       16 Free
01b8a780 793040bc      528
01b8a990 00175e88       16 Free
01b8a9a0 793040bc      528
01b8abb0 00175e88       16 Free
01b8abc0 793040bc      528
01b8add0 00175e88       16 Free    total 1568 objects
Statistics:
      MT    Count    TotalSize Class Name
00175e88      784        12544      Free
793040bc      784       421088 System.Object[]
Total 1568 objects

需要注意的是,由于我的工作站是32位的,应用程序服务器是64位的,因此对象数组大小为528(而不是1056)。然而,对象数组仍然有128个元素。

因此,这个故事告诉我们在使用interning时要非常小心。如果你intern的字符串不属于有限集合的成员,那么你的应用程序将因为LOH的碎片化而泄漏内存,至少在CLR版本2中会出现这种情况。

在我们应用程序的情况下,在反序列化代码路径中有一般代码来intern实体标识符:我现在强烈怀疑这就是罪魁祸首。但是,开发人员的意图显然是好的,他们想要确保如果同一个实体被反序列化多次,那么只有一个标识符字符串实例会被维护在内存中。


2
很好的问题 - 我在我的应用程序中也注意到了同样的情况。在大块被清理后,LOH 中留下了小对象,导致了碎片化问题。 - Reed Copsey
7个回答

47

CLR使用LOH预分配一些对象(例如用于interned strings的数组)。其中一些小于85000字节,因此通常不会在LOH上分配。

这是一个实现细节,但我认为这样做的原因是为了避免不必要的垃圾回收,以便实例能够像进程本身一样长时间存活。

另外,由于某种略微晦涩的优化,任何1000个或更多元素的double[]也会在LOH上分配。


1
这可能是用于内部化字符串的内部结构。有关更多详细信息,请查看我对此问题的回答:https://dev59.com/MXRC5IYBdhLWcg3wOOT1#372559 - Brian Rasmussen
1
85000字节还是84*1024=87040字节? - Peter Mortensen
5
这段内容的翻译如下:85000字节。您可以通过创建一个大小为85000-12(长度、MT、同步块的大小)的字节数组,并在该实例上调用GC.GetGeneration来验证它。这将返回Gen2——API不区分Gen2和LOH。使数组小1个字节,该API将返回Gen0。 - Brian Rasmussen
1
@ThomasWeller 我错过了你使用的是x64。再次说明,这是一个旧答案,我的评论是针对32位的。你需要将更大的引用作为实例的一部分加以考虑。这可以解释尺寸上的差异。 - Brian Rasmussen
1
好的,谢谢。我已经写了一个关于LOH碎片化的问题,链接是http://stackoverflow.com/questions/30361184/loh-fragmentation-2015-update。 - Thomas Weller
显示剩余5条评论

14

.NET Framework 4.5.1 可以在垃圾回收期间显式地压缩大对象堆(LOH)。

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

请查看GCSettings.LargeObjectHeapCompactionMode以获取更多信息。


2
阅读垃圾回收工作原理的描述时,关于长寿命对象最终进入第二代的部分以及LOH对象的收集仅在完全收集时发生——与第二代的收集一样——会让人想到一个想法...为什么不将第二代和大对象放在同一堆中,因为它们最终都将一起被收集呢?
如果实际上是这样的话,那么它就可以解释为什么小对象会与LOH处于相同的位置——如果它们足够长寿,最终会进入第二代。
因此,您的问题似乎是对我想到的想法的一个很好的反驳——它将导致LOH的碎片化。
总结:LOH和第二代共享同一堆区域可能会解释您的问题,尽管这并不是这种解释的证据。
更新:!dumpheap -stat命令的输出几乎彻底否定了这个理论!第二代和LOH有自己的区域。

@supercat 另外,这两个堆在分配方面的行为也非常不同。主要堆(此时)基本上是分配模式中的栈 - 它总是在顶部分配,忽略任何空闲空间 - 当压缩时,空闲空间被挤出。这使得分配几乎成为无操作,并帮助数据局部性。另一方面,在 LOH 上分配类似于 malloc 的工作方式 - 它会找到可以容纳您要分配的内容的第一个空闲位置,并在那里进行分配。由于它是针对大对象的,数据局部性是确定的,并且分配的惩罚并不太严重。 - Luaan
@Luaan:如果LOH仅用于大对象,将分配舍入到下一个4K的倍数是有意义的(对于超过85K的对象最多只有5%的惩罚)。通过操作页面映射,与操作系统的协作,这可能使得避免对这些对象进行物理重定位成为可能。 - supercat
@Luaan:正如所指出的,我不理解关于double[]规则的目的。.NET规则对你有意义吗? - supercat
@supercat 可能是因为支持这样的功能所需的工作不值得提高(如果有的话-很难说)。在多线程环境中操作活动页面远非易事。至于 double [],您已经自己给出了原因-将 double 对齐在 8 字节边界上。由于 LOH 没有类似堆栈分配(不像普通堆),并且由于当时它没有压缩,因此很容易确保您获得 8 字节对齐并保持它。但这也有成本 - 这就是我们首先拥有两个堆的原因。 - Luaan
@Luaan:许多类型的对齐方式可能很有用,这取决于它们的使用方式;许多被放置在LOH上的double[]实例比许多其他较小的对象实例获得更少的对齐受益。拥有4K和64K对齐的大型和巨大对象堆将有助于避免碎片化,在相对较小的成本下,无论是否使用页面重定位,但将double[1024]扩展到12K可能会产生令人烦恼的成本。 - supercat
显示剩余5条评论

1
以下是确定LOH分配的确切调用栈的几种方法。
为了避免LOH碎片化,预先分配大量对象数组并将它们固定。在需要时重复使用这些对象。这是关于LOH碎片化的文章。像这样的东西可以帮助避免LOH碎片化。

1
我看不出为什么在这里固定会有帮助?顺便说一下,LOH上的大对象无论如何都不会被GC移动。虽然这是一个实现细节。 - user492238

1

好问题,我是通过阅读问题来学习的。

我认为反序列化代码路径的其他部分也在使用大对象堆,因此会出现碎片。如果所有字符串同时被interned,那么应该就没问题了。

考虑到.NET垃圾收集器的优秀表现,让反序列化代码路径创建普通字符串对象可能已经足够好了。在有必要之前不要做更复杂的事情。

最多可以考虑保留最近几个字符串的哈希表并重用它们。通过限制哈希表的大小并在创建表时传递大小,可以避免大部分碎片。然后需要一种方法从哈希表中删除最近未使用的字符串以限制其大小。但是,如果反序列化代码路径创建的字符串本来就很短暂,那么你不会获得太多好处。


1
如果该格式可被识别为您的应用程序,为什么您还没有确定生成此字符串格式的代码?如果有几种可能性,请尝试添加唯一数据以确定是哪个代码路径是罪魁祸首。
事实上,数组与大型释放项交错的事实使我猜测它们最初是成对或至少相关的。尝试识别已释放的对象以确定生成它们及其关联字符串的内容。
一旦您确定了生成这些字符串的内容,请尝试弄清楚是什么阻止它们被GCed。也许它们被塞进了一个被遗忘或未使用的列表中,用于记录目的或类似的东西。

编辑: 暂时忽略内存区域和特定数组大小:只需弄清楚这些字符串是如何导致泄漏的。当您的程序仅创建或操作这些字符串一两次,对象较少时,请尝试使用!GCRoot进行跟踪。


这些字符串是Guids(我们使用)和易于识别的字符串键的混合。我可以看到它们是如何生成的,但它们从未(直接)添加到对象数组中,我们也不会显式地创建128个元素的数组。然而,这些小数组本来就不应该出现在LOH中。 - Paul Ruane

1

链接中的答案显示“错误404(未找到)”。 - Pang

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接