我们在开发过程中曾经遇到过一个奇怪的问题:当软件加载了很多比较大规模的数据后,会偶尔出现OutOfMemoryException异常,但通过内存检查工具却发现还有很多可用内存。于是我们怀疑是可用内存总量充足,但却没有足够的连续内存了----也就是说存在很多未分配的内存空隙。但不是说.NET运行时的垃圾收集器会压缩使用中的内存,从而使已经释放的内存空隙连成一片吗?于是我深入研究了一下垃圾回收相关的内容,最终明确的了问题所在----大对象堆(LOH)的使用。如果你也遇到过类似的问题或者对相关的细节有兴趣的话,就继续读读吧。
如果没有特殊说明,后面的叙述都是针对32位系统。
首先我们来探讨另外一个问题:不考虑非托管内存的使用,在最坏情况下,当系统出现OutOfMemoryException异常时,有效的内存(程序中有GCRoot的对象所占用的内存)使用量会是多大呢?2G?1G? 500M?50M?或者更小(是不是以为我在开玩笑)?来看下面这段代码(参考
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | public class Program { static void Main( string []args) { varsmallBlockSize = 90000; varlargeBlockSize = 1 <<24; varcount = 0; varbigBlock = new byte [0]; try { varsmallBlocks = 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 ObjectHeap)中的,而且,每个大对象在创建时即属于G2,也就是说只有在进行Generation2的垃圾回收时,才会处理LOH。而且在对LOH进行垃圾回收时不会压缩内存!更进一步,LOH上空间的使用方式也很特殊----当分配一个大对象时,运行时会优先尝试在LOH的尾部进行分配,如果尾部空间不足,就会尝试向操作系统请求更多的内存空间,只有在这一步也失败时,才会重新搜索之前无效对象留下的内存空隙。如下图所示:
从上到下看
LOH中已经存在一个大小为85K的对象和一个大小为16M对象,当需要分配另外一个大小为85K的对象时,会在尾部分配空间;
此时发生了一次垃圾回收,大小为16M的对象被回收,其占用的空间为未使用状态,但运行时并没有对LOH进行压缩;
此时再分配一个大小为16.1M的对象时,分尝试在LOH尾部分配,但尾部空间不足。所以,
运行时向操作系统请求额外的内存,并将对象分配在尾部;
此时如果再需要分配一个大小为85K的对象,则优先使用尾部的空间。
所以前面的示例代码会造成LOH变成下面这个样子,当最后要分配16M+N的内存时,因为前面已经没有任何一块连续区域满足要求时,所以就会引发OutOfMemoryExceptiojn异常。
要解决这一问题其实并不容易,但可以考虑下面的策略。
将比较大的对象分割成较小的对象,使每个小对象大小小于85,000字节,从而不再分配在LOH上;
尽量“重用”少量的大对象,而不是分配很多大对象;
每隔一段时间就重启一下程序。
最终我们发现,我们的软件中使用数组(List<float>)保存了一些曲线数据,而这些曲线的大小很可能会超过了85,000字节,同时曲线对象的个数也非常多,从而对LOH造成了很大的压力,甚至出现了文章开头所描述的情况。针对这一情况,我们采用了策略1的方法,定义了一个类似C++中deque的数据结构,它以分块内存的方式存储数据,而且保证每一块的大小都小于85,000,从而解决了这一问题。
此外要说的是,不要以为64位环境中可以忽略这一问题。虽然64位环境下有更大的内存空间,但对于操作系统来说,.NET中的LOH会提交很大范围的内存区域,所以当存在大量的内存空隙时,即使不会出现OutOfMemoryException异常,也会使得内页页面交换的频率不断上升,从而使软件运行的越来越慢。
最后分享我们定义的分块列表,它对IList<T>接口的实现行为与List<T>相同,代码中只给出了比较重要的几个方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | public class BlockList<T>: IList<T> { private static int maxAllocSize; private static int initAllocSize; private T[][] blocks; private int blockCount; private int [] blockSizes; private int version; private int countCache; private int countCacheVersion; static BlockList() { vartype = typeof (T); varsize = type.IsValueType ?Marshal.SizeOf( default (T)) :IntPtr.Size; maxAllocSize= 80000 / size; initAllocSize= 8; } public BlockList() { blocks= new T[8][]; blockSizes= new int [8]; blockCount= 0; } public void Add(T item) { int blockId = 0, blockSize = 0; if (blockCount == 0) { UseNewBlock(); } else { blockId= blockCount - 1; blockSize= blockSizes[blockId]; if (blockSize == blocks[blockId].Length) { if (!ExpandBlock(blockId)) { UseNewBlock(); ++blockId; blockSize= 0; } } } blocks[blockId][blockSize]= item; ++blockSizes[blockId]; ++version; } public void Insert( int index,T item) { if (index > Count) { throw new ArgumentOutOfRangeExcept "index" ); } if (blockCount == 0) { UseNewBlock(); blocks[0][0]= item; blockSizes[0]= 1; ++version; return ; } for ( int i = 0; i <blockCount; ++i) { if (index >= blockSizes[i]) { index-= blockSizes[i]; continue ; } if (blockSizes[i] < blocks[i].Length ||ExpandBlock(i)) { for (var j = blockSizes[i]; j > index;--j) { blocks[i][j]= blocks[i][j - 1]; } blocks[i][index]= item; ++blockSizes[i]; break ; } if (i == blockCount - 1) { UseNewBlock(); } if (blockSizes[i + 1] == blocks[i + 1].Length &&!ExpandBlock(i + 1)) { UseNewBlock(); varnewBlock = blocks[blockCount - 1]; for ( int j = blockCount - 1; j> i + 1; --j) { blocks[j]= blocks[j - 1]; blockSizes[j]= blockSizes[j - 1]; } blocks[i+ 1] = newBlock; blockSizes[i+ 1] = 0; } varnextBlock = blocks[i + 1]; varnextBlockSize = blockSizes[i + 1]; for (var j = nextBlockSize; j > 0;--j) { nextBlock[j]= nextBlock[j - 1]; } nextBlock[0]= blocks[i][blockSizes[i] - 1]; ++blockSizes[i+ 1]; for (var j = blockSizes[i] - 1; j > index;--j) { blocks[i][j]= blocks[i][j - 1]; } blocks[i][index]= item; break ; } ++version; } public void RemoveAt( int index) { if (index < 0 || index >=Count) { throw new ArgumentOutOfRangeExcept "index" ); |
联系客服