開發過程中常遇到詭異情況:應用程式載入大量資料後,偶爾拋出 OutOfMemoryException,但記憶體分析工具顯示仍有大量可用空間。直覺告訴我們:總記憶體夠了,但找不到「連續」的大塊記憶體——記憶體碎片化了。不過,.NET GC 不是會壓縮記憶體嗎?為何碎片依然存在?
罪魁禍首正是大型物件堆 (Large Object Heap, LOH)。本文深入剖析 LOH 的運作機制,並提供實戰解決策略。
參考經典範例,交替配置小陣列 (90,000 bytes) 與遞增大陣列 (從 16MB 開始,每次 +1 byte)。在 32 位元程序 (2GB 虛擬位址空間) 中,拋出 OOM 時,有效記憶體僅佔約 23 MB (約 1%)。
// 核心迴圈
var smallBlocks = new List<byte[]>();
while (true) {
GC.Collect();
bigBlock = new byte[largeBlockSize++]; // 大物件進入 LOH
smallBlocks.Add(new byte[smallBlockSize]); // 小物件進入 Gen 0
count++;
}
結果揭示:LOH 不壓縮、配置策略優先尾部、碎片無法重用,導致虛擬位址空間被大量「空洞」浪費。
Gen 0/1 GC 會壓縮記憶體(移動存活物件、合併空閒空間),但 LOH 從不壓縮。回收後留下的空洞永久存在,除非被相同或更大尺寸的新物件重用。

實驗中,大陣列不斷增大,尾部空間永遠不夠,持續向 OS 申請新區段,而前方留下的 16MB、16.1MB... 空洞因太小而無法重用,最終耗盡 2GB 位址空間。

將大陣列/集合拆解為多個 < 85,000 bytes 的小區塊。例如實作一個 BlockList<T> (類似 C++ deque),內部以陣列陣列 T[][] 儲存,每塊大小上限設為 80,000 bytes。對外仍實作 IList<T>,呼叫端無感知。
public class BlockList<T> : IList<T> {
private T[][] blocks;
private int[] blockSizes;
private const int MaxBlockSize = 80000 / sizeof(T); // 確保 < 85KB
// ... 實作 Add, Insert, RemoveAt, 索引器等,邏輯為跨區塊定位
private bool ExpandBlock(int blockId) { /* 擴充區塊至 MaxBlockSize */ }
private void UseNewBlock() { /* 新增區塊 */ }
}
實戰案例:系統以 List<float> 儲存大量曲線資料,導致 LOH 壓力過大。改用 BlockList<float> 後問題徹底解決。
對於不可拆分的大緩衝區 (如影像處理、網路封包),建立物件池,ArrayPool<T>.Shared (.NET Core/.NET 5+) 或自行實作 ConcurrentBag<byte[]>,避免頻繁配置/回收造成碎片。
釋放虛擬位址空間,消除碎片。適用於 Worker Role、Windows Service 等可控環境。
雖然 64 位元擁有龐大位址空間 (TB 級),不易發生 OOM,但 LOH 碎片會導致記憶體分頁交換頻率飆升 (Working Set 膨脹),嚴重拖垮效能。因此 64 位元同樣需重視 LOH 管理。
LOH 不壓縮、優先尾部配置的特性,使其成為大物件頻繁配置/釋放場景下的效能殺手。核心對策是「拆分大物件」,將單一大配置轉化為多個小配置,讓 Gen 0/1 GC 的高效壓縮機制發揮作用。BlockList<T> 等資料結構封裝了這份複雜性,是解決 LOH 碎片化的利器。