大对象堆碎片


97

我正在使用的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

对象数组的元素都是字符串,从我们的应用程序代码中可以识别出这些字符串。

另外,由于!GCRoot命令挂起并且再也没有回来,所以我找不到他们的GC根目录(我什至试图将其放置一夜)。

因此,如果有人能解释为什么这些小的(<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();
}

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

在第二个循环中,创建并插入了唯一的字符串。此操作将它们根植在实习表中。我没有意识到实习表是如何表示的。看起来它由一组页面组成-由LOH创建的128个字符串元素的对象数组。这在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

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

因此,这个故事的寓意是非常小心地进行实习。如果未知您正在实习的字符串是有限集的成员,则您的应用程序将因LOH的碎片而泄漏,至少在CLR的版本2中如此。

在我们的应用程序的情况下,反序列化代码路径中存在通用代码,可在解组期间实习实体标识符:我现在强烈怀疑这是罪魁祸首。但是,开发人员的意图显然是好的,因为他们想确保如果同一实体多次反序列化,那么只有一个标识符字符串实例将保留在内存中。


2
很好的问题-我在应用程序中注意到了同样的事情。清除大块后留在LOH中的小物体,这会导致碎片问题。
Reed Copsey

2
我同意,很好的问题。我将寻找答案。
查理·弗罗里斯2009年

2
很有意思。听起来好像是调试的问题!
马特·乔丹

Answers:


46

CLR使用LOH来预分配一些对象(例如,用于内部字符串的数组)。其中一些少于85000字节,因此通常不会在LOH上分配。

这是一个实现细节,但是我认为这样做的原因是为了避免不必要的实例垃圾回收,这些实例本应一直存在下去,只要其自身就可以生存。

同样由于某种深奥的优化,double[]在LOH上也分配了1000个或更多元素中的任何一个。


有问题的对象是object [],其中包含对我知道由应用程序代码创建的字符串的引用。这意味着应用程序正在创建对象[](我看不到证据),或者CLR的某些部分(例如序列化)正在使用它们来处理应用程序对象。
Paul Ruane

1
那可能是用于内部字符串的内部结构。:请查看我的回答对这个问题的更多细节stackoverflow.com/questions/372547/...
布赖恩拉斯穆森

嗯,这是一个非常有趣的线索,谢谢。完全忘记了实习表。我知道我们的一位开发人员是一位敏锐的合作伙伴,所以我绝对将对此进行研究。
Paul Ruane

1
85000字节还是84 * 1024 = 87040字节?
彼得·莫滕森

5
85000字节 您可以通过创建85000-12(长度大小,MT,同步块的大小)的字节数组并调用GC.GetGeneration实例来验证这一点。这将返回Gen2-API不会区分Gen2和LOH。将数组减小一个字节,API将返回Gen0。
Brian Rasmussen 2013年


2

阅读有关GC工作原理的说明以及有关第2代中寿命长的对象如何结束的部分以及LOH对象的收集仅在完全收集时发生-与第2代的收集一样,这个想法浮现在脑海。 ..为什么不将第二代和大型对象放在同一堆中,因为它们将被收集在一起?

如果这是实际发生的情况,那么它将解释小物体如何最终与LOH并存-如果它们的寿命足够长,可以延续到第二代。

因此,您的问题似乎很好地反驳了我的想法-这将导致LOH分散。

摘要:你的问题可以由LOH和第2代共享同一个堆区进行解释,尽管这绝不是证明,这样的解释。

更新:!dumpheap -stat几乎所有的输出都使这一理论彻底崩溃了!第二代和LOH有自己的区域。


使用!eeheap显示组成每个堆的段。Gen 0和Gen 1生活在一个段(同一段)中,Gen 2和LOH都可以分配多个段,但是每个堆的段保持分离。
Paul Ruane

是的,看到了,谢谢。只是想提及!eeheaps命令,因为它以更清晰的方式显示了此行为。
Paul Ruane

主GC的效率在很大程度上取决于它可以重定位对象的事实,因此主堆上只有少量的可用内存区域。如果在收集期间将主堆上的对象固定,则可能必须分别跟踪固定对象上方和下方的空间,但是由于固定对象的数量通常很小,因此GC必须分开的区域数量也必须如此跟踪。在同一堆中混合可重定位和不可重定位(大)对象会降低性能。
超级猫

一个更有趣的问题是:.NET为什么要double在LOH上放置大于1000个元素的数组,而不是对GC进行调整,以确保它们在8字节边界上对齐。实际上,即使在32位系统上,我也希望由于缓存行为而将8字节对齐方式强加给所有分配大小为8字节倍数的对象,这可能会提高性能。否则,虽然double[]高速缓存对齐的频繁使用的性能要好于高速缓存对齐的性能,但是我不知道为什么大小会与使用情况相关。
超级猫

@supercat此外,两个堆在分配方面的行为也非常不同。(此时)主堆基本上是分配模式下的堆栈-它始终在顶部分配,而忽略任何可用空间-压缩时,可用空间被挤出。这使分配几乎成为无人值守,并有助于数据本地化。另一方面,在LOH上进行分配与malloc的工作方式类似-它会找到第一个可以保留您所分配内容的空闲位置,然后在其中进行分配。由于它是用于大型对象的,因此数据局部性是给定的,分配的代价也不错。
a安

1

如果该格式可识别为您的应用程序,为什么还没有确定生成此字符串格式的代码?如果存在多种可能性,请尝试添加唯一数据以找出导致问题的代码路径。

数组与大型已释放项目交错的事实使我猜测它们最初是配对的,或者至少是相关的。尝试识别释放的对象,以找出产生它们的原因以及相关的字符串。

一旦确定了生成这些字符串的原因,请尝试找出导致它们不被GC的原因。可能出于日志记录目的或类似目的将它们塞入了一个遗忘或未使用的列表中。


编辑:暂时不考虑内存区域和特定的数组大小:只想弄清楚这些字符串在做什么会导致泄漏。当您的程序仅创建或操作了一次或两次这些字符串(当要跟踪的对象较少时),请尝试!GCRoot。


字符串是Guid(我们使用的)和易于识别的字符串键的组合。我可以看到它们的生成位置,但是它们从未(直接)添加到对象数组中,并且我们没有显式创建128个元素数组。这些小数组不应该从LOH开始。
Paul Ruane

1

很好的问题,我通过阅读问题中学到了。

我认为反序列化代码路径的其他部分也正在使用大对象堆,因此会产生碎片。如果所有琴弦都在同一时间被扣押,我想你会没事的。

考虑到.net垃圾收集器的性能如何,仅让反序列化代码路径创建普通的字符串对象就足够了。在证明需求之前,不要做任何更复杂的事情。

我最多只能看看保留您所看到的最后几个字符串的哈希表,然后重新使用它们。通过限制哈希表的大小并在创建表时传递该大小,可以停止大多数碎片。然后,您需要一种方法,从哈希表中删除最近未看到的字符串,以限制其大小。 但是,如果反序列化代码路径创建的字符串寿命很短,那么您将不会获得太多收益。


1

这里有几个方法,以确定确切的调用堆栈LOH分配。

并避免LOH碎片化预分配大量对象并将其固定。在需要时重用这些对象。这是关于LOH碎片的帖子。这样的事情可能有助于避免LOH碎片化。


我不明白为什么在这里固定会有所帮助?无论如何,GC不会移动LOH上的BTW大对象。其实现细节。
user492238 2011年

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.