.net 大对象堆碎片

50few1ms  于 2023-06-25  发布在  .NET
关注(0)|答案(7)|浏览(123)

我正在开发的C #/. NET应用程序正在遭受缓慢的内存泄漏。我已经使用CDB与SOS试图确定发生了什么,但数据似乎没有任何意义,所以我希望你们中的一个可能有过这样的经历。
应用程序运行在64位框架上。它不断地计算和串行化数据到远程主机,并击中大型对象堆(LOH)一个公平的位。然而,我预计大多数LOH对象都是 transient 的:一旦计算完成并发送到远程主机,则存储器应当被释放。然而,我看到的是大量的(活的)对象数组与空闲的内存块交错,例如,从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(见接受的答案),问题已被确定为由字符串intern表引起的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表中。我没有意识到的是实习生table是如何呈现的。它似乎由一组页面组成--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

注意,对象数组的大小是528(而不是1056),因为我的工作站是32位的,而应用程序服务器是64位的。对象数组的长度仍然是128个元素。
所以这个故事的寓意是要非常小心的实习。如果不知道您正在实习的字符串是有限集合的成员,那么您的应用程序将由于LOH的碎片而泄漏,至少在CLR的版本2中是这样。
在我们的应用程序中,反序列化代码路径中有一段通用代码,它在解组过程中包含实体标识符:我现在强烈怀疑这就是罪魁祸首。然而,开发人员的意图显然是好的,因为他们希望确保如果同一个实体被反序列化多次,那么只有一个标识符字符串的示例将在内存中维护。

lawou6xi

lawou6xi1#

CLR使用洛来预分配一些对象(比如用于存储字符串的数组)。其中一些小于85000字节,因此通常不会在洛上分配。
这是一个实现细节,但我假设这样做的原因是为了避免不必要的示例垃圾收集,这些示例应该在进程本身存在的时间内一直存在。
同样由于某种深奥的优化,1000个或更多元素的任何double[]也被分配在洛上。

gupuwyp2

gupuwyp22#

.NET Framework 4.5.1能够在垃圾回收期间显式压缩大型对象堆(洛)。

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

查看更多信息GCSettings.LargeObjectHeapCompactionMode

lmvvr0a8

lmvvr0a83#

当阅读关于GC如何工作的描述,以及关于长寿对象如何在第二代中结束的部分,以及洛对象的收集只发生在完全收集时-就像第二代的收集一样,突然想到的想法是......为什么不把第二代对象和大对象放在同一个堆中,因为它们会被收集在一起呢?
如果这是实际发生的情况,那么它就可以解释为什么小物体最终会出现在与洛相同的地方--如果它们的寿命足够长,最终会出现在第二代。
所以你的问题似乎是对我所想到的想法的一个很好的反驳--它会导致洛的分裂。
总结:你的问题 * 可以 * 解释为洛和第二代共享同一个堆区域,尽管这绝不是解释的证据。

更新:!dumpheap -stat的输出几乎把这个理论吹出了水面!第2代和洛有各自的区域。

6qfn3psc

6qfn3psc4#

如果该格式可识别为您的应用程序,那么您为什么没有识别生成该字符串格式的代码呢?如果有几种可能性,请尝试添加唯一数据,以找出哪个代码路径是罪魁祸首。
数组与大的释放项交错的事实使我猜测它们最初是成对的或至少是相关的。尝试识别释放的对象,以找出是什么生成了它们以及关联的字符串。
一旦你确定了是什么产生了这些字符串,试着弄清楚是什么阻止了它们被GCed。也许它们被塞在一个被遗忘或未使用的列表中,用于日志记录或类似的目的。
EDIT:暂时忽略内存区域和特定数组大小:只要弄清楚这些字符串是如何导致泄漏的。当程序只创建或操作了一两次这些字符串,当需要跟踪的对象较少时,尝试! GCRoot。

e4eetjau

e4eetjau5#

很好的问题,我是通过阅读问题来学习的。
我认为反序列化代码路径的其他位也使用了大型对象堆,因此出现了碎片化。如果所有的弦都同时被扣留,我想你会没事的。

考虑到.net垃圾收集器有多好,只要让反序列化代码路径创建正常的字符串对象就足够了。在证明需要之前,不要做任何更复杂的事情。

我最多会考虑保留您看到的最后几个字符串的哈希表并重用它们。通过限制哈希表的大小并在创建表时传入大小,您可以停止大多数碎片。然后,您需要一种方法来从哈希表中删除最近没有看到的字符串,以限制其大小。但如果反序列化代码路径创建的字符串是短暂的,无论如何,您都不会获得太多。

1qczuiv0

1qczuiv06#

以下是确定LOH分配的确切call-stack的几种方法。
为了避免洛碎片,预先分配大的对象数组并固定它们。需要时重用这些对象。这是关于洛片段的post。类似的东西可以帮助避免洛碎片化。

jslywgbw

jslywgbw7#

根据https://web.archive.org/web/20210924094435/https://www.wintellect.com/hey-who-stole-all-my-memory/

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

相关问题