Fork me on GitHub
.NET陷阱之五:奇怪的OutOfMemoryException——大对象堆引起的问题与对策

.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的尾部进行分配,如果尾部空间不足,就会尝试向操作系统请求更多的内存空间,只有在这一步也失败时,才会重新搜索之前无效对象留下的内存空隙。如下图所示:

从上到下看

  1. LOH中已经存在一个大小为85K的对象和一个大小为16M对象,当需要分配另外一个大小为85K的对象时,会在尾部分配空间;
  2. 此时发生了一次垃圾回收,大小为16M的对象被回收,其占用的空间为未使用状态,但运行时并没有对LOH进行压缩;
  3. 此时再分配一个大小为16.1M的对象时,分尝试在LOH尾部分配,但尾部空间不足。所以,
  4. 运行时向操作系统请求额外的内存,并将对象分配在尾部;
  5. 此时如果再需要分配一个大小为85K的对象,则优先使用尾部的空间。

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

 


要解决这一问题其实并不容易,但可以考虑下面的策略。 

  1. 将比较大的对象分割成较小的对象,使每个小对象大小小于85, 000字节,从而不再分配在LOH上;
  2. 尽量“重用”少量的大对象,而不是分配很多大对象;
  3. 每隔一段时间就重启一下程序。

最终我们发现,我们的软件中使用数组(List<float>)保存了一些曲线数据,而这些曲线的大小很可能会超过了85, 000字节,同时曲线对象的个数也非常多,从而对LOH造成了很大的压力,甚至出现了文章开头所描述的情况。针对这一情况,我们采用了策略1的方法,定义了一个类似C++中deque的数据结构,它以分块内存的方式存储数据,而且保证每一块的大小都小于85, 000,从而解决了这一问题。

此外要说的是,不要以为64位环境中可以忽略这一问题。虽然64位环境下有更大的内存空间,但对于操作系统来说,.NET中的LOH会提交很大范围的内存区域,所以当存在大量的内存空隙时,即使不会出现OutOfMemoryException异常,也会使得内页页面交换的频率不断上升,从而使软件运行的越来越慢。

最后分享我们定义的分块列表,它对IList<T>接口的实现行为与List<T>相同,所以略去了注释。

View Code
 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 }

 

 

.NET陷阱

 
摘要: 我们在开发过程中曾经遇到过一个奇怪的问题:当软件加载了很多比较大规模的数据后,会偶尔出现OutOfMemoryException异常,但通过内存检查工具却发现还有很多可用内存。于是我们怀疑是可用内存总量充足,但却没有足够的连续内存了——也就是说存在很多未分配的内存空隙。但不是说.NET运行时的垃圾收集器会压缩使用中的内存,从而使已经释放的内存空隙连成一片吗?于是我深入研究了一下垃圾回收相关的内容,最终明确的了问题所在——大对象堆(LOH)的使用。如果你也遇到过类似的问题或者对相关的细节有兴趣的话,就继续读读吧。如果没有特殊说明,后面的叙述都是针对32位系统。首先我们来探讨另外一个问题:不考虑非阅读全文
posted @ 2013-04-16 20:42 Bruce Bi 阅读(225) | 评论 (3) 编辑
摘要: 大家可能都遇到过没有取消事件监听而带来的一些问题,像内存泄露、访问无效数据等。当我们写下如下代码时:source.StateChanged += observer.SourceStateChangedHandler实际上source会保持有对observer的一个引用,所以如果source的生命期长于observer的话,则当其它地方不引用observer时,如果不显示解除监听,则observer不会被垃圾回收。这可能会带来两个问题:其一,如果observer占用了大量内存的话,则这部分内存不会被释放;其二,程序的其它地方可能已经处于不一致的状态,这样当source.StateChanged事阅读全文
posted @ 2013-04-08 18:43 Bruce Bi 阅读(876) | 评论 (4) 编辑
摘要: 在我们的代码中,有时会在控件中添加对数据对象的引用。比如使用树节点的Tag属性保存相应的对象,以便在界面操作中能简单的进行访问。因为其它地方不会引用这些数据,所以我们期望在控件被销毁时,垃圾回收机制能回收相应的内存。但当软件运行了一段时间后,内存使用量会变得非常大。下面是简化后的示例代码: 1 using System; 2 using System.Windows.Forms; 3 4 namespace MemoryLeak 5 { 6 public class MainForm : Form 7 { 8 private Button holderButt...阅读全文
posted @ 2013-04-03 11:21 Bruce Bi 阅读(112) | 评论 (1) 编辑
摘要: 我们的软件中需要很多自定义的光标,以便在用户交互过程中进行必要的提示。我们开始的做法是将光标放到资源文件中,然后用类似下面的代码加载:var cursor = new Cursor(new MemoryStream(Resource.OpenHandIcon));... ...if (useDefaultCursor){ control.Cursor = Cursors.Default;}else{ control.Cursor = cursor;}但在测试过程中应该显示自定义光标时,总是时而替换成功,时而替换不成功。原来是.NET中提供的Cursor类的问题,Cursor的构造函...阅读全文
posted @ 2013-04-02 17:11 Bruce Bi 阅读(81) | 评论 (1) 编辑
摘要: 代码中有一个类,其中包含一个字典(Dictionary<Key, Value>),本来想让前者实现IDeserializationCallback接口,以便在反序列化时根据字典的内容做一些初始化工作,结果循环字典元素的代码就是不走。费了好大劲才找到原因,先来看有问题的代码: 1 using System; 2 using System.Collections.Generic; 3 using System.IO; 4 using System.Runtime.Serialization; 5 using System.Runtime.Serialization.Formatters阅读全文
posted @ 2013-04-01 17:40 Bruce Bi 阅读(45) | 评论 (0) 编辑
 
分类: .NET陷阱
posted on 2013-04-16 22:25  HackerVirus  阅读(407)  评论(0编辑  收藏  举报