.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>相同,代碼中只給出了比較重要的幾個方法。

  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 }

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM