昨天文章提到,85000 Bytes 以下的物件會建立在 SOH、超過的大型物件則會建立在 LOH。LOH 與 SOH 的一項重要差異是 - LOH 會隨 G2 回收回收不用的物件記憶體,但不會進行壓實(Compact,搬移物件讓物件緊密相鄰),因此被清掉物件會形成空洞穿插在留存物件間,空洞可重複利用,但只能放入比空洞小的新物件,這個現象稱為碎片化 (LOH Fragmentation)。

因於新建物件時必須取得一整塊連續空間,當 LOH 碎化片嚴重時,有可能出現記憶體空間明明剩很多,但建立物件卻出現 Out Of Memory Exception 的狀況。例如:要建立一個 10MB 的 byte[10 * 1024 * 1024],LOH 空間還剩 500MB,但分散成 100 個不相連的 5MB,由於無法取得連續 10MB,轟! 程式噴出 OutOfMemoryException。

我試著用實驗來模擬上述狀況。

實驗構想是先連續穿插建立 80MB 及 16MB 的 byte[] 物件,用掉近 1.2GB 記憶體,接著將 80MB byte[] 通通清除再呼叫 GC.Collect() 約可回收 1GB 記憶體,理論上要再建個 300MB byte[] 綽綽有餘,但實際上會得到 OutOfMemoryException。

static void Main(string[] args)
{
    try
    {
        Test_LOH_Fragmentation();
    }
    catch (Exception ex)
    {
        Console.WriteLine("ERROR-" + ex.Message);
    }
    Console.ReadLine();
}

static List<object> dataObjects = new List<object>();
static List<byte[]> fragObjects = new List<byte[]>();
static void DumpMemSize(string msg)
{
    var memSz = GC.GetTotalMemory(false) / 1024 / 1024;
    Console.WriteLine($"Managed Heap = {memSz}MB {msg}");
}

static void Test_LOH_Fragmentation()
{
    Func<int, byte[]> createBigArray = (mb) =>
    {
        return new byte[mb * 1024 * 1024];
    };
    for (int i = 0; i < 13; i++)
    {
        dataObjects.Add(createBigArray(80));
        fragObjects.Add(createBigArray(16));
        DumpMemSize($"Objects added - {i} ");
    }
    dataObjects.Clear();
    DumpMemSize($"dataObjects cleared");
    GC.Collect();
    DumpMemSize($"GC.Collect()");
    dataObjects.Add(createBigArray(300));
}

實測結果如下,使用 GC.GetTotalMemory(false)(forceFullCollection 參數要傳 false 避免觸發回收動作) 查詢 Managed Heap 耗用狀況,可以看到跑完 13 次迴圈記憶體用到 1.2GB,清掉所有 80MB byte[] 物件但保留 16MB byte[] 物件,在沒有執行 GC.Collect() 前,記憶體用量仍是 1.2GB。GC.Collect() 後降到 208MB (16 * 13 = 208)。理論上我們已回收 1GB 空間,但想建立一個 300MB byte[] 會出現 OutOfMemoryException。

由以上實驗可驗,當 LOH 嚴重碎片化,就可能發生「剩餘記憶體充足,但因過於零散湊不出夠大連續空間,觸發記憶體不足錯誤」的狀況。為了更清楚觀察 LOH 分佈狀況,我們拿出 Sysinternals 的好用記憶體偵察工具 - VMMap

下圖紅框處為 13 個 80MB 及 16MB byte[] 物件的 LOH 排列方式,原則上採一個 80MB、一個 16MB、一個 80MB、一個 16MB 穿插排列,此時 Managed Heap 使用記憶體量約 1.2GB(紅色箭頭處)。我們先記下前幾個 16MB 的 Address 0A94、11EA、1978、2078 以供稍後比對。

下圖是刪掉所有 80MB byte[] 並 GC.Collect() 後的狀況,Managed Heap 使用記憶體下降到 208MB,紅框清單只剩 16MB 項目,前四個的 Address 為 0A94、11EA、1978、2078,代表其都留在原來的位置上。刪掉的的 13 個 16MB byte[] 留下 13 個 80MB 空間,但湊不成 300MB 連續空間,便是最後發生 OutOfMemoryException 的原因。

針對此一問題,.NET 4.5.1+ 提供了一個解決方法,有個 GCSettings.LargeObjectHeapCompactionMode 屬性 可在 LOH 回收時進行壓實動作,設定 GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce,下 次 GC.Collect() 時將重新整理搬移 LOH 物件清出連續空間。如下圖所示,加上此一設定,最後的 300MB 物件便能建立成功。

除了 LOH 碎片化,還有一種狀況會導致用不到 32bit 程式的記憶體上限 - 1.6GB。

我寫了另一支程式,while (true) 不斷建立指定大小的 byte[],直到記憶體用完為止。

static void Main(string[] args)
{
    try
    {
        if (args.Length < 1)
            throw new ApplicationException("missing sz argument");
        int sz = int.Parse(args[0]);
        Test_LOH_Reserve_Mem(sz);
    }
    catch (Exception ex)
    {
        Console.WriteLine("ERROR-" + ex.Message);
    }
    Console.ReadLine();
}

static void DumpMemSize(string msg)
{
    var memSz = GC.GetTotalMemory(false) / 1024 / 1024;
    Console.WriteLine($"Managed Heap = {memSz}MB {msg}");
}


static void Test_LOH_Reserve_Mem(int sz)
{
    Func<int, byte[]> createBigArray = (mb) =>
    {
        return new byte[mb * 1024 * 1024];
    };
    var list = new List<byte[]>();
    System.Threading.Thread.Sleep(2000);
    int i = 1;
    try
    {
        while (true)
        {
            list.Add(createBigArray(sz));
            i++;
        }
    }
    catch (Exception ex)
    {
        DumpMemSize($"{i}*{sz}MB");
        Console.WriteLine("ERROR-" + ex.Message);
    }
}

分別測試 1、2、4、8、16、32、54、128 MB,不同大小的 byte[],能用到的最大記憶體量不同(以 GC.GetTotalMemory() 數字為準),1M 可用到 1.68G,大小如為 2M 可用到 1.58G,4M 1.36G,8M 0.9G,16M 最慘,只有 880M,32M 則又上升到 1.15G,64M 1.34G,128M 時則是 1.4G。

由趨勢圖來看,也不全然是物件愈小愈能利用空間,隨著物件變大可用空間變小,在 16M、32M 時到達谷底,之後又回升。

使用 VMMap 分析,32M 只能用到一半記憶體的原因揭曉。

以下是 2M byte[] 物件的 Managed Heap 分析,GC.GetTotalMemory() 得到的數字是 1.58G,即下圖 [1] Committed 的數值,略小於 [2] Size 的數字。GC 在取用記憶體時,會先保留(Reserve)一段記憶體地址,此時空有位址並不實際佔用 RAM 或 Pagefile,待要寫入資料時再 Commit 將部分段位址對映到 RAM。Size 是一開始保留的地址大小,Committed 是有對映到 RAM 或 Pagefile 存存資料的大小,Size 永遠大於等於 Committed,其中差距是 Reserve 但還沒有 Commit 的數量。下方清單中每個 Managed Heap 也有 Size[3] 跟 Committed[4],16348K - 14344K 相差 2040K 是 Reserve 但沒 Commit 的量,相當於下方 [5] Reserved 對應的數字 2040K。亦即,這 16348K 中,放了七個 2048K byte[] 物件,7*2048 = 14336K (下圖藍底列),14336K,加上另兩小塊資料 4K、3872 Bytes 共 14344K,再加上 2040K Reserved,就等於 16348K,數字吻合。

而 8M 時,每個 Managed Heap Size 也是 16M,但每個 LOH 只能放一個 8M byte[],故用 8M 未用,呈現 Reserved;16M 時,每個 Managed Heap 32M 只能放一個 16M,有高達 16374K 的 Reserved,Reserve 取得的記憶體位址,只有一半拿來放資料,讓可用記憶體位址提早用完。這便是 8M、16M 可用記憶體限近乎腰斬,僅 900M 左右的原因。

超過 32M,Managed Heap Size 約略是物件大小再多 16M,Rerserved 固定 16M 左右,故可用記憶體上限再次上升。

由此可知,由於 GC 在取得 LOH 需要的記憶體址位時,是以 16MB 為單位,當物件較大時也會多保留 16MB,原本還沒用的 Reserved 空間可繼續用來放小型物件,但遇到以大量巨型物件為主的情境,每個 Managed Heap 的 Reserved 空間只能閒置,就出現了才用 900MB 就 OutOfMemoryException 的狀況。

【小結】這篇文章重現了兩個記憶體還剩很多卻發生 OutOfMemoryException 的極端範例,一個起因於 LOH 碎片化,一個則是保留記憶體位址未充分利用造成。而展示過程介紹的好用 VMMap 工具,是剖析 OutOfMemoryException 或 Memory Leak 問題的利器,可善加利用。

This article explains how LOH fragmentation and unused reserved memory cause OOM.


Comments

# by Uni

謝黑大, 剛好幫助我排除手邊問題!

Post a comment