.NET垃圾回收器之谜

28
在我的工作中,我们遇到了OutOfMemoryExceptions的问题。 我编写了一段简单的代码来模拟某些行为,并最终得到了以下谜团。 请看这个简单的代码,当它耗尽内存时就会崩溃。
class Program
{
    private static void Main()
    {
        List<byte[]> list = new List<byte[]>(200000);
        int iter = 0;

        try
        {
            for (;;iter++)
            {
                list.Add(new byte[10000]);
            }
        }
        catch (OutOfMemoryException)
        {
            Console.WriteLine("Iterations: " + iter);
        }
    }
}

在我的电脑上,最终结果是:

迭代次数: 148008

然后我在每1000次迭代之后的循环中添加了一个 GC.Collect 调用:

            //...
            for (;;iter++)
            {
                list.Add(new byte[10000]);

                if (iter % 1000 == 0)
                    GC.Collect();
            }
            //...

并且惊奇的是:

Iterations: 172048

当我在每10个迭代后调用GC.Collect时,我甚至得到了193716个循环。有两件奇怪的事情:

  1. 手动调用GC.Collect如何会产生如此严重的影响(分配增加多达30%)?

  2. 当没有“丢失”的引用(我甚至预设了List的容量),GC能够收集什么东西?


6
有趣的问题。我猜想这与内存空间的碎片整理有关,如果您经常调用GC.Collect,它可能会保持连续块更大,因此异常稍后才会发生。但这只是一种猜测,我正在等待看看其他人有什么说法。 - Lucero
2
我无法回答你的问题,但在第二点中,你不能声明没有丢失的引用,因为你无法查看“Add”方法。 - flq
1
@Joel,您是指Rotor吗?因为据我所知,他们并不保证Rotor与RTM代码相同,因此某些类可能存在差异。 - Lucero
1
实际上,我相信Joel指的是可用于调试的实际.NET源代码(但不适用于其他用途)。这不是完整的代码,因为CLR内部仍未公开,但由于List<T>是100%托管类,因此代码将完全可读。 - em70
1
尝试将分配的字节数组大小更改为8的倍数。 - AnthonyWJones
显示剩余5条评论
4个回答

11

垃圾收集过程中的一部分是压缩阶段。在此阶段,已分配内存的块会被移动以减少碎片化。当分配内存时,并不总是紧跟着上一个已分配内存块之后进行分配。因此,由于垃圾收集器更好地利用可用空间而使更多可用空间,您可以将其挤压得更多。

我正在尝试运行一些测试,但我的机器处理不了它们。请尝试使用这个方法,它将告诉GC固定内存中的对象,以便它们不会被移动。

byte[] b = new byte[10000];
GCHandle.Alloc(b, GCHandleType.Pinned);
list.Add(b);

关于你的评论,当 GC 移动数据时,它不会清除任何内容,只是更好地利用所有内存空间。让我们尝试过度简化这个问题。当你第一次分配字节数组时,假设它被插入到从位置 0 到 10000 的内存中。下一次你分配字节数组时,它可能不会从 10001 开始,也许会从 10500 开始。现在你有 499 个字节没有被使用,并且不会被应用程序使用。因此,当 GC 进行压缩操作时,它会将 10500 数组移动到 10001,以便使用那额外的 499 字节。再次说明,这是一个非常简化的描述。


1
这听起来很有道理,但是1)我仍然看不到任何对象被清除(好吧,List.Add可能会添加一些噪音,但使用resharper进行快速检查显示它并没有);2)当分配了如此多的内存时,框架应该多次调用GC并执行相同的操作。 - Elephantik
这也是我之前想的(请看我在问题上的评论)。 不过,不太合理的是在内存不足的情况下应该调用GC,从而在那时压缩内存。然而,由于GC以某种方式也会从操作系统中分配内存块,因此没有一个大的内存块,而是一系列内存块来处理。 调用GC.Collect可能会重新组织块,以便减少丢失空间(无用的内存太小,无法用于此分配在OS块的末尾)。 - Lucero
2
我理解你的观点,但只要没有死对象,我就看不出有故意分割内存的理由。垃圾回收的好处之一应该是从非分段的空闲内存中受益,这样新创建的对象就不必寻找空闲空间。而且 - 如前所述 - 自动调用GC也应该做到同样的效果。 - Elephantik
我的回答有什么荒谬的地方吗?你有更好的答案在哪里? - Bob
2
正如我所指出的... 1) 内存碎片化只会在进行垃圾回收时发生(我可能错了,但我相信我是正确的),而且 2) "正常" 的垃圾回收应该能够解决这个问题。所以,无论我手动调用还是不调用,都不应该有任何区别。但是,实际上存在(我认为是很大的)区别。 - Elephantik
1
  1. 即使GC不运行,内存碎片化仍可能发生。就像我之前提到的,只因为你请求了一些内存,并不意味着它会立刻从下一个连续的块中得到分配。
  2. 垃圾回收器并不总是可预测的。只因为它应该执行某些操作,并不意味着它一定会执行。还需要注意的是,GC被设计用来比其他情况更好地处理现实世界中的问题。这并不是一个典型的现实世界情况,所以你会看到一些变化。
- Bob

5

好的观点,LOH可能会参与其中,但是大列表一直存在,所以LOH不应该被分割。 - Elephantik
你可以通过缩小小数组来测试Lucero的观点。虽然我只知道那个85000的限制。 - H H
我进行了一个测试,将数据插入到较小的数组中以避免LOH,但行为仍然相同。 - Elephantik

2
CLR有时会将数组放在LOH上。如果您通过WinDbg查看内存转储,您会发现有些数组的大小不到85,000字节。这是未记录的行为 - 但这就是它的工作方式。
由于您正在破坏LOH堆,而LOH堆从未被压缩,因此会出现OutOfMemoryErrors。
关于您的问题:
2)当没有“丢失”的引用时(我甚至已经预设了List的容量),GC可以收集什么?
您传递给列表的new byte [10000]存在被覆盖的引用。一个局部变量被编译并分配给new byte[10000]。在循环的每次迭代中,您都会创建一个具有预定义大小为10000的新byte[],并将其分配给局部变量。任何先前的变量值都将被覆盖,并且该内存在下次GC运行时对变量所在的代(在本例中可能是LOH)进行回收。

“将数组的副本制作并传递给列表”是错误的。 - Eldritch Conundrum
这怎么会是“完全错误”的呢?每次迭代都会创建一个新的数组副本(我所说的副本是因为它们具有相同的数组大小)。然后,将该新数组添加到现有的List<byte[]>中,每次迭代都会增加byte[10000]的数量。 - Dave Black
好的,我现在明白你的意思是“创建一个新数组”。对我来说,复制一个数组意味着复制它的内容,但这里并没有发生这样的事情。 - Eldritch Conundrum
每次迭代时,变量超出范围并将在下一次GC运行时被收集,这部分答案是不正确的。方法内的本地作用域影响编译期间合法的名称,但所有本地变量都在方法调用期间分配在堆栈上。引用由局部作用域循环变量引用的对象之所以可以被收集,是因为该变量在下一个循环迭代中被覆盖为新值,使先前引用的对象不可访问且符合GC的条件。 - Peter Duniho
@PeterDuniho 是的,尽管没有明确定义的变量,编译器会为 'new byte[10000]' 分配一个变量,正如您所提到的,它在每次迭代中被覆盖。我会澄清/更正我的答案。虽然堆栈变量在方法执行期间一直存在于内存中,但如果CLR将其分配在LOH上,则一旦它不再具有根源并已达到方法中的安全点并触发了Gen2 / LOH收集,它就有资格进行收集。在这种情况下,在方法完成之前收集该值。 - Dave Black
显示剩余2条评论

0

x64环境会做什么?是的,虚拟内存的数量确实会增加,但唯一的作用是延长OutOfMemoryException发生之前的时间。问题的根本原因仍未解决。从x86到x64,LOH的大小不会改变,而且由于GC不会压缩LOH,LOH仍然会被分段。 - Dave Black

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