.NET陷阱之五:奇怪的OutOfMemoryException——大对象堆引起的问题与对策
我们在开发过程中曾经遇到过一个奇怪的问题:当软件加载了很多比较大规模的数据后,会偶尔出现OutOfMemoryException异常,但通过内存检查工具却发现还有很多可用内存。于是我们怀疑是可用内存总量充足,但却没有足够的连续内存了——也就是说存在很多未分配的内存空隙。但不是说.NET运行时的垃圾收集器会压缩使用中的内存,从而使已经释放的内存空隙连成一片吗?于是我深入研究了一下垃圾回收相关的内容,最终明确的了问题所在——大对象堆(LOH)的使用。如果你也遇到过类似的问题或者对相关的细节有兴趣的话,就继续读读吧。
如果没有特殊说明,后面的叙述都是针对32位系统。
首先我们来探讨另外一个问题:不考虑非托管内存的使用,在最坏情况下,当系统出现OutOfMemoryException异常时,有效的内存(程序中有GC Root的对象所占用的内存)使用量会是多大呢?2G? 1G? 500M? 50M?或者更小(是不是以为我在开玩笑)?来看下面这段代码(参考 https://www.simple-talk.com/dotnet/.net-framework/the-dangers-of-the-large-object-heap/)。
1 public class Program 2 { 3 static void Main(string[] args) 4 { 5 var smallBlockSize = 90000; 6 var largeBlockSize = 1 << 24; 7 var count = 0; 8 var bigBlock = new byte[0]; 9 try 10 { 11 var smallBlocks = new List<byte[]>(); 12 while (true) 13 { 14 GC.Collect(); 15 bigBlock = new byte[largeBlockSize]; 16 largeBlockSize++; 17 smallBlocks.Add(new byte[smallBlockSize]); 18 count++; 19 } 20 } 21 catch (OutOfMemoryException) 22 { 23 bigBlock = null; 24 GC.Collect(); 25 Console.WriteLine("{0} Mb allocated", 26 (count * smallBlockSize) / (1024 * 1024)); 27 } 28 29 Console.ReadLine(); 30 } 31 }
这段代码不断的交替分配一个较小的数组和一个较大的数组,其中较小数组的大小为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的尾部进行分配,如果尾部空间不足,就会尝试向操作系统请求更多的内存空间,只有在这一步也失败时,才会重新搜索之前无效对象留下的内存空隙。如下图所示:
从上到下看
- 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 [Serializable] 2 public class BlockList<T> : IList<T> 3 { 4 private static int maxAllocSize; 5 6 private static int initAllocSize; 7 8 private T[][] blocks; 9 10 private int blockCount; 11 12 private int[] blockSizes; 13 14 private int version; 15 16 private int countCache; 17 18 private int countCacheVersion; 19 20 static BlockList() 21 { 22 var type = typeof(T); 23 var size = type.IsValueType ? Marshal.SizeOf(default(T)) : IntPtr.Size; 24 maxAllocSize = 80000 / size; 25 initAllocSize = 8; 26 } 27 28 public BlockList() 29 { 30 Reset(); 31 } 32 33 public BlockList(IEnumerable<T> collection) 34 : this() 35 { 36 AddRange(collection); 37 } 38 39 public int Count 40 { 41 get 42 { 43 if (version != countCacheVersion) 44 { 45 countCache = 0; 46 for (int i = 0; i < blockCount; ++i) 47 { 48 countCache += blockSizes[i]; 49 } 50 51 countCacheVersion = version; 52 } 53 54 return countCache; 55 } 56 } 57 58 bool ICollection<T>.IsReadOnly 59 { 60 get 61 { 62 return false; 63 } 64 } 65 66 public int BlockCount 67 { 68 get 69 { 70 return blockCount; 71 } 72 } 73 74 public T this[int index] 75 { 76 get 77 { 78 if (index < 0) 79 { 80 throw new ArgumentOutOfRangeException("index"); 81 } 82 83 for (int i = 0; i < blockCount; ++i) 84 { 85 if (index < blockSizes[i]) 86 { 87 return blocks[i][index]; 88 } 89 90 index -= blockSizes[i]; 91 } 92 93 throw new ArgumentOutOfRangeException("index"); 94 } 95 set 96 { 97 if (index < 0) 98 { 99 throw new ArgumentOutOfRangeException("index"); 100 } 101 102 for (int i = 0; i < blockCount; ++i) 103 { 104 if (index < blockSizes[i]) 105 { 106 blocks[i][index] = value; 107 ++version; 108 return; 109 } 110 111 index -= blockSizes[i]; 112 } 113 114 throw new ArgumentOutOfRangeException("index"); 115 } 116 } 117 118 public void Reset() 119 { 120 blocks = new T[8][]; 121 blockSizes = new int[8]; 122 blockCount = 0; 123 } 124 125 public T[] GetBlockBuffer(int blockId) 126 { 127 return blocks[blockId]; 128 } 129 130 public int GetBlockSize(int blockId) 131 { 132 return blockSizes[blockId]; 133 } 134 135 public void Add(T item) 136 { 137 int blockId = 0, blockSize = 0; 138 if (blockCount == 0) 139 { 140 UseNewBlock(); 141 } 142 else 143 { 144 blockId = blockCount - 1; 145 blockSize = blockSizes[blockId]; 146 if (blockSize == blocks[blockId].Length) 147 { 148 if (!ExpandBlock(blockId)) 149 { 150 UseNewBlock(); 151 ++blockId; 152 blockSize = 0; 153 } 154 } 155 } 156 157 blocks[blockId][blockSize] = item; 158 ++blockSizes[blockId]; 159 ++version; 160 } 161 162 public void AddRange(IEnumerable<T> collection) 163 { 164 if (collection == null) 165 { 166 throw new ArgumentNullException("collection"); 167 } 168 169 foreach (var item in collection) 170 { 171 Add(item); 172 } 173 174 ++version; 175 } 176 177 public void Clear() 178 { 179 Array.Clear(blocks, 0, blocks.Length); 180 Array.Clear(blockSizes, 0, blockSizes.Length); 181 blockCount = 0; 182 ++version; 183 } 184 185 public bool Contains(T item) 186 { 187 return IndexOf(item) != -1; 188 } 189 190 public void CopyTo(T[] array, int arrayIndex) 191 { 192 if (array == null) 193 { 194 throw new ArgumentNullException("array"); 195 } 196 197 if (arrayIndex < 0 || arrayIndex + Count > array.Length) 198 { 199 throw new ArgumentException("arrayIndex"); 200 } 201 202 for (int i = 0; i < blockCount; ++i) 203 { 204 Array.Copy(blocks[i], 0, array, arrayIndex, blockSizes[i]); 205 arrayIndex += blockSizes[i]; 206 } 207 } 208 209 public int IndexOf(T item) 210 { 211 var comparer = EqualityComparer<T>.Default; 212 for (int i = 0; i < Count; ++i) 213 { 214 if (comparer.Equals(this[i], item)) 215 { 216 return i; 217 } 218 } 219 220 return -1; 221 } 222 223 public void Insert(int index, T item) 224 { 225 if (index > Count) 226 { 227 throw new ArgumentOutOfRangeException("index"); 228 } 229 230 if (blockCount == 0) 231 { 232 UseNewBlock(); 233 blocks[0][0] = item; 234 blockSizes[0] = 1; 235 ++version; 236 return; 237 } 238 239 for (int i = 0; i < blockCount; ++i) 240 { 241 if (index >= blockSizes[i]) 242 { 243 index -= blockSizes[i]; 244 continue; 245 } 246 247 if (blockSizes[i] < blocks[i].Length || ExpandBlock(i)) 248 { 249 for (var j = blockSizes[i]; j > index; --j) 250 { 251 blocks[i][j] = blocks[i][j - 1]; 252 } 253 254 blocks[i][index] = item; 255 ++blockSizes[i]; 256 break; 257 } 258 259 if (i == blockCount - 1) 260 { 261 UseNewBlock(); 262 } 263 264 if (blockSizes[i + 1] == blocks[i + 1].Length 265 && !ExpandBlock(i + 1)) 266 { 267 UseNewBlock(); 268 var newBlock = blocks[blockCount - 1]; 269 for (int j = blockCount - 1; j > i + 1; --j) 270 { 271 blocks[j] = blocks[j - 1]; 272 blockSizes[j] = blockSizes[j - 1]; 273 } 274 275 blocks[i + 1] = newBlock; 276 blockSizes[i + 1] = 0; 277 } 278 279 var nextBlock = blocks[i + 1]; 280 var nextBlockSize = blockSizes[i + 1]; 281 for (var j = nextBlockSize; j > 0; --j) 282 { 283 nextBlock[j] = nextBlock[j - 1]; 284 } 285 286 nextBlock[0] = blocks[i][blockSizes[i] - 1]; 287 ++blockSizes[i + 1]; 288 289 for (var j = blockSizes[i] - 1; j > index; --j) 290 { 291 blocks[i][j] = blocks[i][j - 1]; 292 } 293 294 blocks[i][index] = item; 295 break; 296 } 297 298 ++version; 299 } 300 301 public bool Remove(T item) 302 { 303 int index = IndexOf(item); 304 if (index >= 0) 305 { 306 RemoveAt(index); 307 ++version; 308 return true; 309 } 310 311 return false; 312 } 313 314 public void RemoveAt(int index) 315 { 316 if (index < 0 || index >= Count) 317 { 318 throw new ArgumentOutOfRangeException("index"); 319 } 320 321 for (int i = 0; i < blockCount; ++i) 322 { 323 if (index >= blockSizes[i]) 324 { 325 index -= blockSizes[i]; 326 continue; 327 } 328 329 if (blockSizes[i] == 1) 330 { 331 for (int j = i + 1; j < blockCount; ++j) 332 { 333 blocks[j - 1] = blocks[j]; 334 blockSizes[j - 1] = blockSizes[j]; 335 } 336 337 blocks[blockCount - 1] = null; 338 blockSizes[blockCount - 1] = 0; 339 --blockCount; 340 } 341 else 342 { 343 for (int j = index + 1; j < blockSizes[i]; ++j) 344 { 345 blocks[i][j - 1] = blocks[i][j]; 346 } 347 348 blocks[i][blockSizes[i] - 1] = default(T); 349 --blockSizes[i]; 350 } 351 352 break; 353 } 354 355 ++version; 356 } 357 358 public void RemoveRange(int index, int count) 359 { 360 if (index < 0 || index + count > Count) 361 { 362 throw new ArgumentException(); 363 } 364 365 for (var i = 0; i < count; ++i) 366 { 367 RemoveAt(index); 368 } 369 } 370 371 public IEnumerator<T> GetEnumerator() 372 { 373 return new Enumerator<T>(this); 374 } 375 376 IEnumerator IEnumerable.GetEnumerator() 377 { 378 return new Enumerator<T>(this); 379 } 380 381 private bool ExpandBlock(int blockId) 382 { 383 var length = blocks[blockId].Length; 384 if (length == maxAllocSize) 385 { 386 return false; 387 } 388 389 length = Math.Min(length * 2, maxAllocSize); 390 Array.Resize(ref blocks[blockId], length); 391 return true; 392 } 393 394 private void UseNewBlock() 395 { 396 if (blockCount == blocks.Length) 397 { 398 Array.Resize(ref blocks, blockCount * 2); 399 Array.Resize(ref blockSizes, blockCount * 2); 400 } 401 402 blocks[blockCount] = new T[initAllocSize]; 403 blockSizes[blockCount] = 0; 404 ++blockCount; 405 } 406 407 [Serializable] 408 private struct Enumerator<U> : IEnumerator<U> 409 { 410 private BlockList<U> list; 411 412 private int index; 413 414 private U current; 415 416 private int version; 417 418 internal Enumerator(BlockList<U> blockList) 419 { 420 list = blockList; 421 index = 0; 422 version = list.version; 423 current = default(U); 424 } 425 426 public void Dispose() 427 { 428 } 429 430 public bool MoveNext() 431 { 432 if (version == list.version && index < list.Count) 433 { 434 current = list[index]; 435 index++; 436 return true; 437 } 438 439 return MoveNextRare(); 440 } 441 442 private bool MoveNextRare() 443 { 444 if (version != list.version) 445 { 446 throw new InvalidOperationException(); 447 } 448 449 index = list.Count + 1; 450 current = default(U); 451 return false; 452 } 453 454 public U Current 455 { 456 get 457 { 458 return this.current; 459 } 460 } 461 462 object IEnumerator.Current 463 { 464 get 465 { 466 if (index == 0 || index == list.Count + 1) 467 { 468 throw new InvalidOperationException(); 469 } 470 471 return Current; 472 } 473 } 474 475 void IEnumerator.Reset() 476 { 477 if (version != list.version) 478 { 479 throw new InvalidOperationException(); 480 } 481 482 index = 0; 483 current = default(U); 484 } 485 } 486 }