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

Wesley13
• 阅读 579

我们在开发过程中曾经遇到过一个奇怪的问题:当软件加载了很多比较大规模的数据后,会偶尔出现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/)。

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

 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 }

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

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

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

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

23 Mb allocated

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

考虑到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++的堆中创建对象要快很多——当然,是在后者不使用额外的内存管理模块的情况下。】

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

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

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

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

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

从上到下看

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

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

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


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

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

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

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

最后分享我们定义的分块列表,它对IList接口的实现行为与List相同,代码中只给出了比较重要的几个方法。

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

  1 public class BlockList<T> : IList<T>
  2 {
  3     private static int maxAllocSize;
  4     private static int initAllocSize;
  5     private T[][] blocks;
  6     private int blockCount;
  7     private int[] blockSizes;
  8     private int version;
  9     private int countCache;
 10     private int countCacheVersion;
 11 
 12     static BlockList()
 13     {
 14         var type = typeof(T);
 15         var size = type.IsValueType ? Marshal.SizeOf(default(T)) : IntPtr.Size;
 16         maxAllocSize = 80000 / size;
 17         initAllocSize = 8;
 18     }
 19 
 20     public BlockList()
 21     {
 22         blocks = new T[8][];
 23         blockSizes = new int[8];
 24         blockCount = 0;
 25     }
 26 
 27     public void Add(T item)
 28     {
 29         int blockId = 0, blockSize = 0;
 30         if (blockCount == 0)
 31         {
 32             UseNewBlock();
 33         }
 34         else
 35         {
 36             blockId = blockCount - 1;
 37             blockSize = blockSizes[blockId];
 38             if (blockSize == blocks[blockId].Length)
 39             {
 40                 if (!ExpandBlock(blockId))
 41                 {
 42                     UseNewBlock();
 43                     ++blockId;
 44                     blockSize = 0;
 45                 }
 46             }
 47         }
 48 
 49         blocks[blockId][blockSize] = item;
 50         ++blockSizes[blockId];
 51         ++version;
 52     }
 53 
 54     public void Insert(int index, T item)
 55     {
 56         if (index > Count)
 57         {
 58             throw new ArgumentOutOfRangeException("index");
 59         }
 60 
 61         if (blockCount == 0)
 62         {
 63             UseNewBlock();
 64             blocks[0][0] = item;
 65             blockSizes[0] = 1;
 66             ++version;
 67             return;
 68         }
 69 
 70         for (int i = 0; i < blockCount; ++i)
 71         {
 72             if (index >= blockSizes[i])
 73             {
 74                 index -= blockSizes[i];
 75                 continue;
 76             }
 77 
 78             if (blockSizes[i] < blocks[i].Length || ExpandBlock(i))
 79             {
 80                 for (var j = blockSizes[i]; j > index; --j)
 81                 {
 82                     blocks[i][j] = blocks[i][j - 1];
 83                 }
 84 
 85                 blocks[i][index] = item;
 86                 ++blockSizes[i];
 87                 break;
 88             }
 89 
 90             if (i == blockCount - 1)
 91             {
 92                 UseNewBlock();
 93             }
 94 
 95             if (blockSizes[i + 1] == blocks[i + 1].Length
 96                 && !ExpandBlock(i + 1))
 97             {
 98                 UseNewBlock();
 99                 var newBlock = blocks[blockCount - 1];
100                 for (int j = blockCount - 1; j > i + 1; --j)
101                 {
102                     blocks[j] = blocks[j - 1];
103                     blockSizes[j] = blockSizes[j - 1];
104                 }
105 
106                 blocks[i + 1] = newBlock;
107                 blockSizes[i + 1] = 0;
108             }
109 
110             var nextBlock = blocks[i + 1];
111             var nextBlockSize = blockSizes[i + 1];
112             for (var j = nextBlockSize; j > 0; --j)
113             {
114                 nextBlock[j] = nextBlock[j - 1];
115             }
116 
117             nextBlock[0] = blocks[i][blockSizes[i] - 1];
118             ++blockSizes[i + 1];
119 
120             for (var j = blockSizes[i] - 1; j > index; --j)
121             {
122                 blocks[i][j] = blocks[i][j - 1];
123             }
124 
125             blocks[i][index] = item;
126             break;
127         }
128 
129         ++version;
130     }
131 
132     public void RemoveAt(int index)
133     {
134         if (index < 0 || index >= Count)
135         {
136             throw new ArgumentOutOfRangeException("index");
137         }
138 
139         for (int i = 0; i < blockCount; ++i)
140         {
141             if (index >= blockSizes[i])
142             {
143                 index -= blockSizes[i];
144                 continue;
145             }
146 
147             if (blockSizes[i] == 1)
148             {
149                 for (int j = i + 1; j < blockCount; ++j)
150                 {
151                     blocks[j - 1] = blocks[j];
152                     blockSizes[j - 1] = blockSizes[j];
153                 }
154 
155                 blocks[blockCount - 1] = null;
156                 blockSizes[blockCount - 1] = 0;
157                 --blockCount;
158             }
159             else
160             {
161                 for (int j = index + 1; j < blockSizes[i]; ++j)
162                 {
163                     blocks[i][j - 1] = blocks[i][j];
164                 }
165 
166                 blocks[i][blockSizes[i] - 1] = default(T);
167                 --blockSizes[i];
168             }
169 
170             break;
171         }
172 
173         ++version;
174     }
175 
176     private bool ExpandBlock(int blockId)
177     {
178         var length = blocks[blockId].Length;
179         if (length == maxAllocSize)
180         {
181             return false;
182         }
183 
184         length = Math.Min(length * 2, maxAllocSize);
185         Array.Resize(ref blocks[blockId], length);
186         return true;
187     }
188 
189     private void UseNewBlock()
190     {
191         if (blockCount == blocks.Length)
192         {
193             Array.Resize(ref blocks, blockCount * 2);
194             Array.Resize(ref blockSizes, blockCount * 2);
195         }
196 
197         blocks[blockCount] = new T[initAllocSize];
198         blockSizes[blockCount] = 0;
199         ++blockCount;
200     }
201 }

本文分享 CSDN - 唯笑志在。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

点赞
收藏
评论区
推荐文章
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
浪人 浪人
3年前
Android 内存泄露:详解 Handler 内存泄露的原因与解决方案
前言在Android开发中,内存泄露十分常见1.内存泄露的定义:本该被回收的对象不能被回收而停留在堆内存中2.内存泄露出现的原因:当一个对象已经不再被使用时,本该被回收但却因为有另外一个正在使用的对象持有它的引用从而导致它不能被回收。这就导致了内存泄漏。本文将详细讲解内存泄露的其中一种情况:在Handler中发生的内
Stella981 Stella981
3年前
JVM系列之:内存与垃圾回收篇(二)
JVM系列之:内存与垃圾回收篇(二)本篇内容概述:1、堆HeapArea2、方法区MethodArea3、运行时数据区总结4、对象的实例化内存布局和访问定位一、堆HeapArea1、堆的核心概念
Wesley13 Wesley13
3年前
JDK8中JVM堆内存划分
一:JVM中内存JVM中内存通常划分为两个部分,分别为堆内存与栈内存,栈内存主要用运行线程方法存放本地暂时变量与线程中方法运行时候须要的引用对象地址。JVM全部的对象信息都存放在堆内存中。相比栈内存,堆内存能够所大的多,所以JVM一直通过对堆内存划分不同的功能区块实现对堆内存中对象管理。堆内存不够最常见的错误就是OOM(OutOf
Wesley13 Wesley13
3年前
Unity优化之
当我们来创建一个对象、字符串或数组时,我们需要从称为堆的中央池中为其分配内存来存储它。当它不再被使用时,我们又需要来释放这块内存便于重复使用。在以前这个过程通常需要我们通过适当的函数调用显式地分配和释放块内存来实现。但现在,运行时系统如Unity的mono引擎将自动地为我们管理内存。自动内存管理比显式分配/释放需要更少的编码工作,大大减少了内存泄漏的可能性(
Wesley13 Wesley13
3年前
Java 中的堆和栈
Java中的堆和栈简单的说:Java把内存划分成两种:一种是栈内存,一种是堆内存。      在函数中定义的一些基本类型的变量和对象的引用变量都在函数的栈内存中分配。      当在一段代码块定义一个变量时,Java就在栈中为这个变量分配内存空间,当超过变量的作用域后,Java会自动释放掉为该变量所分配的
Wesley13 Wesley13
3年前
Java内存溢出和内存泄露后怎么解决
1.首先这里先说一下内存溢出和内存泄露的区别:内存溢出outofmemory,是指程序在申请内存时,没有足够的内存空间供其使用,出现outofmemory;比如申请了一个integer,但给它存了long才能存下的数,那就是内存溢出。内存泄露memoryleak,是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,
Stella981 Stella981
3年前
Redis 内存管理策略
背景Redis很多时候都是在使用内存,数据一直写,但内存是有限的,如果Redis内存满了,那么我们的很多缓存操作都会超时、失败,接着可能会引发雪崩。那么当内存达到阀值Redis是怎么处理的呢?配置内存限制maxmemory我们可以通过在配置文件中配置maxmemory来限制内存的最大使用情况。如果maxmem
Wesley13 Wesley13
3年前
PHP垃圾回收机制
php5.3之前使用的垃圾回收机制是单纯的“引用计数”,也就是每个内存对象都分配一个计数器,当内存对象被变量引用时,计数器1;当变量引用撤掉后,计数器1;当计数器0时,表明内存对象没有被使用,该内存对象则进行销毁,垃圾回收完成。“引用计数”存在问题,就是当两个或多个对象互相引用形成环状后,内存对象的计数器则不会消减为0;这时候,这一组内存对象已经
Stella981 Stella981
3年前
JavaScript:垃圾收集机制
  JavaScript具有自动垃圾收集机制。也就是说,执行环境会负责管理代码执行过程中使用的内存。开发人员不必关心内存分配和回收问题。  垃圾收集机制的原理:找到不再继续使用的变量,然后进行释放其占用的内存。所以,垃圾收集器会按照固定的时间间隔(或代码执行中设定的收集时间)持续执行这一操作。  垃圾收集器会跟踪哪些变量有用哪些变量没用,对没用的变量