前幾天解決完非典型 Out Of Memory 茶包,感覺自己雖然寫了這麼多年 .NET,對記憶體管理的了解仍偏虛浮,只知道背後有個強大的 GC 會負責找記憶體空間放物件,物件不用了會自動回收空間,完全不用我們操煩。需要物件時 new 一下,不要殘留變數、屬性指向超出變數範圍(Scope)的物件,.NET Runtime 自會打理好大小事。但面對記憶體洩漏、記憶體不足等疑難雜症,缺少運作原理知識,就會不知從何下手。

這系列文章算是老鳥蹲馬步,回頭把 .NET 記憶體管理的一些術語觀念弄清楚,有了這個基礎,未來處理 .NET 記憶體茶包能更容易抽絲剝繭找出真相。第一篇先從幾個關鍵術語談起:Managed Heap、Small Object Heap(SOH)、Large Object Heap(LOH)、Garbage Collector(GC)。

Reference Type 類 .NET 物件 (延伸閱讀:Value Type vs Reference Type 自我測驗),會被保存在名為 Managed Heap 的記憶體區塊。建立新物件時,會從 Managed Heap 找出一塊空間存放該物件。Managed Heap 分為 Small Object Heap (SOH) 及 Large Object Heap (LOH),以 85,000 Bytes 為門檻(Large Object Heap Threadshold),大於 85,000 Bytes 的物件放在 LOH,小於這個大小的則一律放在 SOH。(註:Large Object Heap Threadshold 可以調整)

.NET Runtime 在啟動程序時,GC 會配置兩個記憶體區段作為 SOH 跟 LOH。SOH 又會依物件存活時間長短區分 Generation (層代、世代),由年輕到長壽分為 G0、G1、G2,由 Garbage Collector (GC) 負責管理。

區分 Generation 有不少好處:回收整理(Collect)記憶體以 Generation 為單位,會比每次處理一整個 Heap 有效率,並且可依據各 Generation 安排適合的回收頻率,像是 G0 多為暫時性或區域變數,建立與丟棄的頻率都高,需要較常執行回收;G2 異動最少,久久整理一次即可。回收很耗費 CPU 資源,過度頻率執行將影響程式效能,GC 會自行判斷時機,一般不需由程式觸發,只有極少的狀況需要程式強制執行 GC.Collect()。另外,GC 會自動調配 G0、G1、G2 的小大,確保運作效率。

小型新物件建立時一律先放在 G0,當 G0 空間不足,GC 會進行清理,回收失效物件佔用的記憶體,並進行壓實的動作(Compact,搬移物件讓物件記憶體位址相接,物件間不要出現空隙)。回收未使用物件整理完 G0,留下還在使用中的物件會搬到 G1,G0 回收時不需要重新檢查 G1、G2;當 G0 回收完,G0 空間仍不足,GC 會對 G1 執行回收;G1 回收時,仍存留物件將升級到 G2;若 G1 也不夠用,則會對 G2 進行回收。
(以下圖片來自 Memory Management in C# by Adam Thorn,展示每次記憶體回收後,G0、G1、G2 的物件存放變化)


圖片來源

大型物件新增時不放在 SOH,而是放在 LOH。LOH 不區分 Generation,視同 G2,但有時也被稱為 G3,它會在回收 G2 時一起執行回收,但有一點重要差異 - LOH 回收時不會 Compact (壓實) 搬移物件(物件資料可能高達數百 KB 到數百 MB,搬移成本過高),回收物件空出來的記憶體會形成空洞,這不連續的可用空間稱為 LOH 破碎(LOH Fragmentation),嚴重時可能出現可用空間總和夠大,但找不出一整段連續空間放置新物件的窘況,導致 Out of Memory Exception。


圖片來源

GC 會依回收後留存物件比率調整各 Generation 記憶體大小,避免因大久沒回收導致無用資料長期佔用實體記憶體(Working Set),又不會因頻繁回收影響效能,力求在二者間取得平衡。

G0 與 G1 又稱為 Ephemeral Generation,必須配置在名為 Ephemeral Segment 的記憶體區間,Ephemeral Segment 也可能包含 G2 物件;Ephemeral Segment 大小依作業系統有所不同:(Server GC Mode 每個 Logical CPU 會有一個 GC Thread,故 Ephemeral Segment 反而較小)

Workstation/Server GC32-bit64-bit
Workstation GC16 MB256 MB
Server GC64 MB4 GB
Server GC with > 4 logical CPUs32 MB2 GB
Server GC with > 8 logical CPUs16 MB1 GB

G0 與 G1 只能放在固定大小的 Ephemeral Segment,在 Ephemeral Segment 之外,還可以有 0 到多個 G2 Segment,其中只會包含 G2 物件,加上 LOH 並不會佔用 Ephemeral Segment,故記憶體使用空間不會因此受限。

看完理論,來實際演練。以下程式用來驗證幾件事:

  1. 以 85000 Bytes 區分大小物件,決定建在 SOH(G0) 還是 LOH(G2)
  2. G0 回收存活物件會升級成 G1,G1 回收物件升級到 G2

這個實驗依靠 GC.GetGeneration(objectVar) 偵測物件位於 G0、G1 或 G2,GC.Colect(generationNo) 指定進行 G0/G1/G2 回收。測試程式碼如下:

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

static void Test_Generation()
{
    var x85minus = new byte[84999 - 12];
    var x85 = new byte[85000 - 12];
    var x85plus = new byte[85001 - 12];
    Console.WriteLine($"Object < 85000bytes => G{GC.GetGeneration(x85minus)}");
    Console.WriteLine($"Object = 85000bytes => G{GC.GetGeneration(x85)}");
    Console.WriteLine($"Object > 85000bytes => G{GC.GetGeneration(x85plus)}");

    var x = new byte[4];
    Console.WriteLine($"Before G0 colection => G{GC.GetGeneration(x)}");
    GC.Collect(0);
    Console.WriteLine($"After 1st G0 collection => G{GC.GetGeneration(x)}");
    GC.Collect(0);
    Console.WriteLine($"After 2nd G0 collection => G{GC.GetGeneration(x)}");
    GC.Collect(1);
    Console.WriteLine($"After G1 collection => G{GC.GetGeneration(x)}");
    GC.Collect(2);
    Console.WriteLine($"After G2 collection => G{GC.GetGeneration(x)}");
}

如下圖所示,我建了三個 byte[],大小分別為 84999、85000、85001 (註:每個 byte[] 物件除了陣列長度需外加 12 Bytes 才是實際大小,故長度減 12 以精準控制物件大小),84999 在 G0(SOH)、85000 及 85001 在 G2(LOH)。第二實驗,x 物件一開始建在 G0,G0 回收後升到 G1,第二次 G0 回收仍留在 G1,執行 G1 回收後升到 G2,符合預期。

小結,本文概略介紹了 Managed Heap、GC、SOH、LOH 等處理 .NET 記憶體問題可能用到的術語,共透過實驗觀察,物件大小與保存位置的差異,以及 G0/G1/G2 回收及 SOH 物件世代升級的行為。

【參考資料】

Introduction to .NET managed heap, GC, SOH and LOH with experiments.


Comments

# by m@rcus

Pro.NET Memory Management 作者有製作關於 .NET 記憶體管理的海報(小抄),提供給黑暗大做參考 https://prodotnetmemory.com/

# by joker

Great,感謝整理。

# by Nelson Yuan

根據我的印象,講到GC機制不講Workstation工作站模式和Server伺服器模式的區別,GC知識就不夠完整透徹,這牽涉到現在對多CUP架構採CUP對稱架構設計導致CUP和記憶體的匯流排有近端記憶體和遠端記憶體之分必須使用的GC管理演算法也不同,應用程式選擇Workstation只能使用單側邏輯CUP群來做GC,大大在個人電腦上也無法實驗Server模式的效能表現。大致是這樣不知有沒有記錯,希望大大更進一步研究成果發表~ 不過總會覺得,除非幾十億的系統設計才有可能需要去認真學習GC理論,不然.NET GC技術真的日常寫程式百分百用不到。

# by Jeffrey

to m@rcus,抛磚引玉成功,謝謝分享好書好小抄。 to Nelson Yuan,呃,這坑看起來好深呀呀呀呀呀~~~ 剛看了 Pro.NET Memory Management 一書的介紹,應該有深入到你說的 CPU Bus 部分,可以參考看看。

# by Daniel Lin

第二段中的英文理解是 Managed Head => Managed Heap、Large Object Head(LOH) => Large Object Heap(LOH) 不知道原意是不是這樣。

# by Jeffrey

to Daniel Lin, 天殘手正常發揮... Orz,謝謝指正

# by Nelson Yuan

OMG我是腦殘,沒發現CPU都打成CUP,大家請小心服用~

Post a comment