在 C# 中进行大量、快速和频繁的内存分配期间避免 OutOfMemoryException

Posted

技术标签:

【中文标题】在 C# 中进行大量、快速和频繁的内存分配期间避免 OutOfMemoryException【英文标题】:Avoiding OutOfMemoryException during large, fast and frequent memory allocations in C# 【发布时间】:2012-08-12 01:35:47 【问题描述】:

我们的应用程序不断地为大量数据(例如数十到数百兆字节)分配数组,这些数据在被丢弃之前的生存时间很短。

天真地这样做可能会导致大型对象堆碎片,最终导致应用程序崩溃并出现 OutOfMemoryException,尽管当前活动对象的大小并不过分。

我们过去成功管理此问题的一种方法是将数组分块以确保它们不会最终出现在 LOH 上,其想法是通过允许垃圾收集器压缩内存来避免碎片。

我们最新的应用程序处理的数据比以前更多,并且在托管在单独的 AppDomain 或单独的进程中的加载项之间非常频繁地传递这些序列化数据。我们采用了与以前相同的方法,确保我们的内存始终被分块,并非常小心地避免大型对象堆分配。

但是,我们有一个必须托管在外部 32 位进程中的加载项(因为我们的主应用程序是 64 位的,并且加载项必须使用 32 位库)。在特别重的负载下,当大量 SOH 内存块被快速分配并在不久之后被丢弃时,即使我们的分块方法也不足以保存我们的 32 位加载项,它会因 OutOfMemoryException 而崩溃。

在发生 OutOfMemoryException 时使用 WinDbg,!heapstat -inclUnrooted 显示如下:

Heap             Gen0         Gen1         Gen2          LOH
Heap0           24612      4166452    228499692      9757136

Free space:                                                 Percentage
Heap0              12           12      4636044        12848SOH:  1% LOH:  0%

Unrooted objects:                                           Percentage
Heap0              72            0         5488            0SOH:  0% LOH:  0%

!dumpheap -stat 显示这个:

-- SNIP --

79b56c28     3085       435356 System.Object[]
79b8ebd4        1      1048592 System.UInt16[]
79b9f9ac    26880      1301812 System.String
002f7a60       34      4648916      Free
79ba4944     6128     87366192 System.Byte[]
79b8ef28    17195    145981324 System.Double[]
Total 97166 objects
Fragmented blocks larger than 0.5 MB:
    Addr     Size      Followed by
18c91000    3.7MB         19042c7c System.Threading.OverlappedData

这些告诉我,我们的内存使用并没有过多,而且我们的大对象堆像预期的那样非常小(所以我们这里绝对没有处理大对象堆碎片)。

但是,!eeheap -gc 显示:

Number of GC Heaps: 1
generation 0 starts at 0x7452b504
generation 1 starts at 0x741321d0
generation 2 starts at 0x01f91000
ephemeral segment allocation context: none
 segment     begin allocated  size
01f90000  01f91000  02c578d0  0xcc68d0(13396176)
3cb10000  3cb11000  3d5228b0  0xa118b0(10557616)
3ece0000  3ece1000  3fc2ef48  0xf4df48(16047944)
3db10000  3db11000  3e8fc8f8  0xdeb8f8(14596344)
42e20000  42e21000  4393e1f8  0xb1d1f8(11653624)
18c90000  18c91000  19c53210  0xfc2210(16523792)
14c90000  14c91000  15c85c78  0xff4c78(16731256)
15c90000  15c91000  168b2870  0xc21870(12720240)
16c90000  16c91000  17690744  0x9ff744(10483524)
5c0c0000  5c0c1000  5d05381c  0xf9281c(16328732)
69c80000  69c81000  6a88bc88  0xc0ac88(12627080)
6b2d0000  6b2d1000  6b83e8a0  0x56d8a0(5691552)
6c2d0000  6c2d1000  6d0f2608  0xe21608(14816776)
6d2d0000  6d2d1000  6defc67c  0xc2b67c(12760700)
6e2d0000  6e2d1000  6ee7f304  0xbae304(12247812)
70000000  70001000  70bfb41c  0xbfa41c(12559388)
71ca0000  71ca1000  72893440  0xbf2440(12526656)
73b40000  73b41000  74531528  0x9f0528(10421544)
Large object heap starts at 0x02f91000
 segment     begin allocated  size
02f90000  02f91000  038df1d0  0x94e1d0(9757136)
Total Size:              Size: 0xe737614 (242447892) bytes.
------------------------------
GC Heap Size:            Size: 0xe737614 (242447892) bytes.

让我印象深刻的是,我们的最终 SOH 堆段从 0x73b41000 开始,这正好是我们 32 位加载项中可用内存的限制。

所以如果我没看错的话,我们的问题似乎是我们的虚拟内存已经被托管堆段碎片化了。

我想我的问题是:

我的分析正确吗? 我们使用分块来避免 LOH 碎片的方法是否合理? 是否有避免我们现在看到的内存碎片的好策略?

我能想到的最明显的答案是池化和重用我们的内存块。这可能是可行的,但我宁愿避免这样做,因为它涉及到我们自己有效地管理这部分记忆。

【问题讨论】:

就个人而言,我更喜欢“自己动手”;这通常实现起来相当简单,并且会立即消除大多数(不是全部)分配。我为高吞吐量套接字应用程序中的缓冲区执行此操作 - 效果很好。 AFAIK gen0/1 将始终共享一个段,因此其余段都是 gen2。这些可能分配在内存中的任何位置,所以我不确定你可以从 gen0/gen1 的位置。 我在 32 位机器上运行服务时遇到了这个问题。池化对您没有帮助,因为这些块将被序列化以跨越 appdomains 边界,并且新数组将被分配到子域的堆中。 我们在应用程序域之间以块的形式传输数据,以允许池继续工作。有趣的是,我现在在许多地方添加了池(分配大型数据数组、序列化、进程之间的分块传输),但我们仍然很快耗尽了内存,仅 200MB 由 .NET 堆分配。我开始怀疑一个更大的问题,可能与我们必须用来加载数据的 32 位 COM 库的交互有关。我会在调查时更新更多信息。 【参考方案1】:

对于那些感兴趣的人,这里是我发现的关于这个问题的更新:

看来最好的解决方案是实现块池化以减轻垃圾收集器的压力,所以我这样做了。

结果是加载项在其任务中稍微进一步,但不幸的是它仍然很快耗尽了内存。

再次查看 WinDbg,我能看到的唯一真正区别是我们合并的托管堆大小始终较小,约为 200MB,而池化前约为 250MB。

似乎 .NET 可用的内存量随着时间的推移而减少,因此实现池化只是延迟了内存耗尽。

如果这是真的,那么明显的罪魁祸首就是我们用来将数据加载到内存中的 COM 组件。我们对 COM 对象进行了一些缓存,以改善对数据的重复访问时间。我删除了所有缓存,并确保在每次查询数据后释放所有内容。

现在内存方面看起来一切正常,只是速度慢了很多(接下来我必须解决这个问题)。

我想事后看来,COM 组件应该是内存问题的第一个嫌疑人,但是嘿,我学到了一些东西 :) 从好的方面来说,池化对于减少 GC 开销仍然有用,所以值得这样做也是。

感谢大家的cmets。

【讨论】:

以上是关于在 C# 中进行大量、快速和频繁的内存分配期间避免 OutOfMemoryException的主要内容,如果未能解决你的问题,请参考以下文章

如何避免在清除 gc 期间快速增加内存?

GC频繁抖动的主要原因

GC频繁抖动的主要原因

C#笔记

C#中使用Redis不同数据结构的内存占有量的疑问和对比测试

Android App解决卡顿慢之内存抖动及内存泄漏(发现和定位)