我们在开发过程中曾经遇到过一个奇怪的问题:当软件加载了很多比较大规模的数据后,会偶尔出现OutOfMemoryException异常,但通过内存检查工具却发现还有很多可用内存。于是我们怀疑是可用内存总量充足,但却没有足够的连续内存了——也是说存在很多未分配的内存空隙。但不是说.NET运行时的垃圾收集器会压缩使用中的内存,从而使已经释放的内存空隙连成一片吗?于是我深入研究了一下垃圾回收相关的内容,终明确的了问题所在——大对象堆(LOH)的使用。如果你也遇到过类似的问题或者对相关的细节有兴趣的话,继续读读吧。

  如果没有特殊说明,后面的叙述都是针对32位系统。

  首先我们来探讨另外一个问题:不考虑非托管内存的使用,在坏情况下,当系统出现OutOfMemoryException异常时,有效的内存(程序中有GC Root的对象所占用的内存)使用量会是多大呢?2G?1G?500M?50M?或者更小(是不是以为我在开玩笑)?来看下面这段代码:

    public class Program 
     { 
         static void Main(string[] args) 
         { 
             var smallBlockSize = 90000; 
             var largeBlockSize = 1 << 24; 
             var count = 0; 
             var bigBlock = new byte[0]; 
             try
             { 
                 var smallBlocks = new List<byte[]>(); 
                 while (true) 
                 { 
                     GC.Collect(); 
                     bigBlock = new byte[largeBlockSize]; 
                     largeBlockSize++; 
                     smallBlocks.Add(new byte[smallBlockSize]); 
                     count++; 
                 } 
             } 
             catch (OutOfMemoryException) 
             { 
                 bigBlock = null; 
                 GC.Collect(); 
                 Console.WriteLine("{0} Mb allocated",  
                     (count * smallBlockSize) / (1024 * 1024)); 
             } 
              
             Console.ReadLine(); 
         } 
     }

  这段代码不断的交替分配一个较小的数组和一个较大的数组,其中较小数组的大小为90, 000字节,而较大数组的大小从16M字节开始,每次增加一个字节。如代码第15行所示,在每一次循环中bigBlock都会引用新分配的大数组,从而使之前的大数组变成可以被垃圾回收的对象。在发生OutOfMemoryException时,实际上代码会有count个小数组和一个大小为 16M + count 的大数组处于有效状态。后代码输出了异常发生时小数组所占用的内存总量。

  下面是在我的机器上的运行结果——和你的预测有多大差别?提醒一下,如果你要亲自测试这段代码,而你的机器是64位的话,一定要把生成目标改为x86。

23 Mb allocated

  考虑到32位程序有2G的可用内存,这里实现的使用率只有1%!

  下面即介绍个中原因。需要说明的是,我只是想以简单的方式阐明问题,所以有些语言可能并不精确,可以参考http://msdn.microsoft.com/en-us/magazine/cc534993.aspx以获得更详细的说明。

  .NET的垃圾回收机制基于“Generation”的概念,并且一共有G0, G1, G2三个Generation。一般情况下,每个新创建的对象都属于于G0,对象每经历一次垃圾回收过程而未被回收时,会进入下一个Generation(G0 -> G1 -> G2),但如果对象已经处于G2,则它仍然会处于G2中。

  软件开始运行时,运行时会为每一个Generation预留一块连续的内存(这样说并不严格,但不影响此问题的描述),同时会保持一个指向此内存区域中尚未使用部分的指针P,当需要为对象分配空间时,直接返回P所在的地址,并将P做相应的调整即可,如下图所示。【顺便说一句,也正是因为这一技术,在.NET中创建一个对象要比在C或C++的堆中创建对象要快很多——当然,是在后者不使用额外的内存管理模块的情况下。】

  在对某个Generation进行垃圾回收时,运行时会先标记所有可以从有效引用到达的对象,然后压缩内存空间,将有效对象集中到一起,而合并已回收的对象占用的空间,如下图所示。

  但是,问题出在上面特别标出的“一般情况”之外。.NET会将对象分成两种情况区别对象,一种是大小小于85, 000字节的对象,称之为小对象,它对应于前面描述的一般情况;另外一种是大小在85, 000之上的对象,称之为大对象,是它造成了前面示例代码中内存使用率的问题。在.NET中,所有大对象都是分配在另外一个特别的连续内存(LOH, Large Object Heap)中的,而且,每个大对象在创建时即属于G2,也是说只有在进行Generation 2的垃圾回收时,才会处理LOH。而且在对LOH进行垃圾回收时不会压缩内存!更进一步,LOH上空间的使用方式也很特殊——当分配一个大对象时,运行时会优先尝试在LOH的尾部进行分配,如果尾部空间不足,会尝试向操作系统请求更多的内存空间,只有在这一步也失败时,才会重新搜索之前无效对象留下的内存空隙。如下图所示:

  从上到下看

  1、LOH中已经存在一个大小为85K的对象和一个大小为16M对象,当需要分配另外一个大小为85K的对象时,会在尾部分配空间;

  2、此时发生了一次垃圾回收,大小为16M的对象被回收,其占用的空间为未使用状态,但运行时并没有对LOH进行压缩;

  3、此时再分配一个大小为16.1M的对象时,分尝试在LOH尾部分配,但尾部空间不足。所以,

  4、运行时向操作系统请求额外的内存,并将对象分配在尾部;

  5、此时如果再需要分配一个大小为85K的对象,则优先使用尾部的空间。

  所以前面的示例代码会造成LOH变成下面这个样子,当后要分配16M + N的内存时,因为前面已经没有任何一块连续区域满足要求时,所以会引发OutOfMemoryExceptiojn异常。