大对象堆浪费

13
我发现我的应用程序比预期更快地耗尽内存。它创建了许多大小为几兆字节的字节数组。然而,当我使用vmmap查看内存使用情况时,.NET为每个缓冲区分配的空间比实际需要的要多得多。确切地说,当分配一个9兆字节的缓冲区时,.NET会创建一个16兆字节的堆。剩下的7兆字节不能用来创建另一个9兆字节的缓冲区,因此.NET会再次创建一个16兆字节的堆。所以每个9MB的缓冲区浪费了7MB的地址空间!
下面是一个示例程序,在32位.NET 4中分配了106个缓冲区后抛出OutOfMemoryException异常:
using System.Collections.Generic;

namespace CSharpMemoryAllocationTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var buffers = new List<byte[]>();
            for (int i = 0; i < 130; ++i)
            {
                buffers.Add(new byte[9 * 1000 * 1024]);
            }

        }
    }
}

请注意,您可以将数组的大小增加到16 * 1000 * 1024,而仍然可以在耗尽内存之前分配相同数量的缓冲区。

VMMap显示如下:

enter image description here

还要注意托管堆的总大小和已提交大小之间几乎有100%的差异。(1737MB vs 946MB)。

在.NET上是否有可靠的方法来解决这个问题?即,我可以强制运行时分配不超过我实际需要的数量,或者可以用于多个连续缓冲区的更大的托管堆吗?


你对精确符合需求的内存分配的渴望被更重要的问题所取代,那就是需要避免地址空间碎片化。这种行为完全是按设计来的,没有任何可以调整它的选项。这是一个老式的问题,对于64位操作系统来说已经不再是问题了。 - Hans Passant
@HansPassant 很不幸,这个过程必须是32位的。所以你的意思是我需要编写自己的分配器? - Asik
2
好的,为什么不呢。或者你可以编写更智能的代码,需要更少的内存。重复使用那些数组。 - Hans Passant
@HansPassant。它们都包含实际需要的数据,除了将它们转储到磁盘上,没有太多其他的事情可做。 - Asik
2
你可以将缓冲区分配为7个块(即new byte[7 * 9 * 1000 * 1024]),这相当接近64M。 - beerboy
显示剩余6条评论
3个回答

6
内部CLR分配内存以段为单位。从您的描述中,似乎16 MB的分配是段,您的数组是在其中分配的。剩余的空间将被保留,通常情况下不会浪费,因为它将用于其他分配。如果没有任何适合剩余块大小的分配,那么这些就是开销。
由于您的数组使用连续的内存进行分配,因此在一个段内只能容纳一个该数组,因此在这种情况下存在开销。
默认的段大小为16 MB,但如果您的分配大于该大小,则CLR会分配更大的段。我不知道详细信息,但例如,如果我分配20 MB,则Wmmap会显示24 MB的段。
减少开销的一种方法是尽可能地进行与段大小相符的分配。但请记住,这些都是实现细节,并且可能会随CLR的任何更新而发生更改。

3

CLR 一次从操作系统中保留了一个16MB的块,但只占用了其中的9MB。

我相信您期望这9MB和9MB在同一个堆中。问题是该变量现在分布在两个堆中。

 Heap 1 = 9MB + 7MB
 Heap 2 = 2MB

我们现在遇到的问题是,如果原始的9MB被删除,我们现在有2个堆无法整理,因为内容在堆之间共享。
为了提高性能,解决方法是将它们放在单一堆中。
如果你担心内存使用,不要担心。在.NET中,内存使用并不是坏事,因为如果没有人使用它,那么问题在哪里呢?GC最终会启动,并且内存将被整理。GC只会在以下情况下启动:
1.当CLR认为有必要时
2.当操作系统告诉CLR要归还内存时
3.当代码强制执行时
但是,在这个例子中,内存使用量不应该成为一个问题。内存使用会阻止CPU周期的发生。否则,如果它不断地整理内存,你的CPU将会很高,你的进程(以及机器上的所有其他进程)将运行得更慢。

1
值得注意的是,这些分配都去了大对象堆,这意味着垃圾回收器不会对其进行太多整理。在.NET应用程序中,LOH碎片化并不罕见。 - Brian Rasmussen
@BrianRasmussen: 当然可以!任何大于85,000字节的内容都将排到垃圾回收队列的最后。 - Dominic Zukiewicz
@Asik:你正在填充内存并保留所有变量。由于该方法中的每个变量都需要保留,因此它无法进行垃圾回收。 - Dominic Zukiewicz
我知道,那就是我刚才说的。 - Asik
是的,那个有效。不幸的是,我的应用程序经常需要9或10MB的数组。 - Asik
显示剩余4条评论

3

这是伙伴系统堆管理算法的古老症状,其中使用2的幂递归地将每个块分成二叉树,因此对于9M,下一个大小为16M。如果将数组大小降低到8mb,则可以看到使用情况减少了一半。这不是一个新问题,本地程序员也在处理它。

小对象池(小于85,000字节)的管理方式不同,但在9MB时,您的数组位于大对象池中。从.NET 4.5开始,大对象堆不参与压缩,大对象立即晋升到第二代。

您无法强制使用该算法,但您可以通过确定最有效地填充二进制段的大小来强制使用用户代码。

如果需要用9 MB数组填充进程空间,请执行以下操作之一:

  1. 找出如何节省1MB以将数组减少到8MB段
  2. 编写或使用分段数组类,该类抽象了一个由1或2MB数组段组成的数组,使用索引器属性。构建无限位域或可增长的ArrayList的方法相同。实际上,我认为其中一个容器已经这样做了。
  3. 转移到64位

回收伙伴系统堆的碎片化部分是具有对数返回的优化(即您几乎已经用完内存了)。在某些时候,除非数据大小固定,否则您将不得不转移到64位。


强制运行时有各种可能的含义。我的建议是按照伙伴算法将数组分配为2的幂次方,否则你只是在与它作斗争。你不能替换堆算法/GC,所以要与之合作。 - codenheim
1
值得一提的是,4.5.1 版本增加了 System.Runtime.GCSettings.LargeObjectHeapCompactionMode,但如果实际上没有使用内存,则这并不是非常相关。 - EricLaw
@EricLaw - 真的吗?我不知道这件事。在我最新的_CLR via C#_副本中甚至没有记录下来。感谢你指出来! - codenheim
@EricLaw - 说句实话,在其他运行时中有GC实际上会移动已使用的内存进行压缩。压缩的概念并不总是依赖于使用情况。但当然我们正在谈论CLR。 - codenheim

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