.NET Core GC 的設計


此文章轉載自:http://www.cnblogs.com/zkweb/p/6288457.html

作者: Maoni Stephens ( @maoni0) - 2015

提示: 推薦看 The Garbage Collection Handbook 這本書學習更多關於GC的知識 (在文章底部的鏈接中)

組件結構

在GC中有兩個主要的組件, 一個是分配器(Allocator), 另一個是收集器(Collector)。

分配器負責獲取更多的內存並且在適當的時機觸發收集器。

收集器負責回收垃圾和不再被程序使用的對象內存。

此外還有一些途徑可以觸發收集器, 例如手動調用GC.Collect函數或析構線程(Finalizer Thread)收到一個內存不足的異步通知(由收集器發送)。

分配器的設計

分配器由運行引擎(Execution Engine (EE))調用, 調用時會帶有以下的信息:

  • 需要分配的大小
  • 線程專用的分配上下文(Allocation Context)
  • 標記, 如對象是否有析構函數

GC不會根據對象類型的不同做出特殊處理, 它會從運行引擎獲取對象的大小, 根據對象的大小把對象分為兩類:

  • 小對象 (小於 85,000字節)
  • 大對象 (大於或等於 85,000字節)

原則上小對象和大對象都可以用同樣的方式處理, 但因為壓縮(Compacting)大對象的代價會比較昂貴所以GC會區分對待。

當GC把一段內存交給分配器時, 它會參照分配上下文(Allocation Context)。
分配上下文的大小取決於分配單位(Allocation Quantum)的定義。

  • 分配上下文 是在堆段(Heap Segment)中專門給指定線程使用的小區域, 在單核計算機(指單邏輯核心)中只會使用一個上下文, 即第0世代(Generation 0)分配上下文。
  • 分配單位 是分配器每次申請的內存大小, 用於在分配上下文中給對象分配內存. 分配單位通常為8K且受管理對象(Managed Object)的大小通常為32字節, 使得一個分配單位可以用於多個對象的分配 (譯者注: 原理和內存池相同)

大對象不會使用分配上下文和分配單位, 因為一個大對象本身可以大於這些小區域。
並且使用這些區域帶來的好處(會在下面討論)僅僅受限於小對象。
大對象會直接在堆段(Heap Segment)中分配。

分配器的設計要求實現:

  • 在適當時機觸發GC: 如果超過了分批預算(由收集器設置的閾值)或分配器不能在指定的堆段上分配時將會觸發GC, 分配預算(Allocation Budget)和受管理的段(Managed Segments)的細節將在下面討論。
  • 保持對象位置: 如果多個對象在同一個堆段中分配, 它們的虛擬內存地址也會鄰接。
  • 高效的使用緩存: 分配器每次都會按 分配單位 申請內存, 而不是按每個對象的大小. 在申請內存后它會對足夠激活cpu緩存的內存大小進行清0, 因為在此之后對象會從這塊內存中分配(所以性能會得到提升). 分配單位通常為8K。
  • 高效的避免線程鎖: 因為分配上下文和線程的綁定, 可以保證每個分配單位的內存只有對應一個線程可以操作. 因此只要當前的分配上下文沒有用完, 給對象分配內存時就不需要線程鎖。
  • 內存完整性: GC總會把新分配的對象的內存清0, 從而防止對象指向隨機的內容(未定義的內容)。
  • 保證堆可爬取(Crawlable): 分配器會在每個分配單位即將用完之際新建一個自由對象(Free Object)填充, 例如一個分配單位剩余30個字節並且下一個要分配的對象需要40個字節, 則分配器會新建一個30個字節的自由對象填充原來的分配單位並重新獲取一個新的分配單位

分配器的API

 Object* GCHeap::Alloc(size_t size, DWORD flags);
 Object* GCHeap::Alloc(alloc_context* acontext, size_t size, DWORD flags);

以上的函數可以用於分配小對象和大對象。
另外還有一個函數用於強制從大對象的堆(LOH: Large Object Heap)中分配內存:

Object* GCHeap::AllocLHeap(size_t size, DWORD flags);

收集器的設計

GC的目標

GC致力於高效的內存管理, 只要求程序員付出很小的努力
高效指的是:

  • GC應該足夠頻繁的發生, 以避免堆中有大量(根據比例和絕對值)未使用但已分配的對象(垃圾)和多余的內存。
  • GC應該盡可能不頻繁的發生, 以避免過多的消耗cpu時間, 即便頻繁發生可以讓程序占用更小的內存。
  • GC應該是生產性的, 如果一次GC只回收了少量的內存那么這次GC和它消耗的cpu周期被浪費了。
  • 每次GC應該足夠快, 許多場景會要求低延遲。
  • 程序員們不需要為了優化內存的利用對GC了解太多(取決於他們的工作)。
    – GC應該調整自身以適應不同的內存使用模式。

受管理的堆(Managed Heap)的邏輯表現

CLR GC把對象分成了不同的世代, 當第 N 世代的垃圾被回收后, 生存的對象會被標記為第 N+1 世代, 這個過程被稱為升級(Promotion)。
還有一些例外的情況當我們決定是否降級或不升級。

對於小對象的堆會分為3個世代: 第0世代(gen0), 第1世代(gen1)和第2世代(gen2)。
對於大對象只有1個世代: 第3世代(gen3)。
第0世代和第1世代被稱為短暫(對象的生命周期短)的世代。

對於小對象的堆, 世代中的數字代表了年齡, 第0世代是最年輕的世代。
但不代表第0世代中的對象一定比第1世代和第2世代的對象年輕, 下面將會說明那些例外。
收集一個世代中的垃圾同時也會收集所有比它年輕的世代的垃圾。

原則上大對象可以使用和小對象一樣的處理方式, 但是壓縮大對象的代價會非常的昂貴, 因此大對象和小對象會受到不同的對待。
因為性能上的原因, 大對象只使用了一個世代(第3世代)並且這個世代會和第2世代一起回收垃圾。
因為第2世代和第3世代可以很大, 需要和短暫的世代(第0世代和第1世代)在開銷上划出邊界。

為對象分配內存時總會從最年輕的世代分配 - 對於小對象總會從第0世代分配, 對於大對象總會從第3世代分配(因為只有一個世代)。

受管理的堆(Managed Heap)的物理表現

受管理的堆(Managed Heap)是一個包含了受管理的堆段(Managed Heap Segments)的集合。
受管理的堆段是從系統內核申請得到的一塊連續的內存空間. (譯者注: 即malloc/brk申請得到的空間)
用於區別小對象和大對象, 堆段又分為小對象堆段和大對象堆段。
每個堆中的堆段都是相互鏈接在一起的, 至少會有1個小對象堆段和1個大對象堆段 - 它們會在加載CLR時預留。

每個小對象的堆中只有一個短暫的堆段(Ephemeral Segment)用於存放短暫世代(第0世代和第1世代)的對象, 但也有可能包含第2世代的對象。
其他額外的堆段(0或1或更多個)中只會存放第2世代的對象。

每個大對象的堆中有一個或更多個堆段。

堆段中的空間會從較低的地址向較高的地址消耗, 即地址更小的對象比地址更大的對象更老. 這里也有一些例外將在下面說明。

堆段會在需要時向系統申請, 並且在不包含任何生存的對象時刪除。
但是初始的堆段(加載時預留的)會一直保留。

每個堆中每次只會處理一段(而不是全部), 如在回收小對象和分配大對象時。
這樣的設計提供了更好的性能, 因為大對象只會在第2世代回收時一同回收(代價相當昂貴)。

堆段會按它們的申請順序鏈接在一起, 鏈中的最后一個堆段一定是短暫的堆段(Ephemeral Segment)。
回收的堆段(不包含任何生存的對象)不一定會被刪除, 也可能被作為一個新的短暫的堆段, 這種機制僅在小對象的堆中實現。
每次分配大對象都會考慮整個大對象的堆, 而小對象僅僅考慮短暫的堆段。

分配預算(Allocation Budget)

分配預算是一個關聯於每個世代的邏輯概念, 當世代的大小達到了指定的限制則會觸發GC。
每個世代的預算值屬性基本取決於該世代的對象的生存率, 如果生存率較高, 那么這個限制會調大使得下次對該世代的GC會得到一個更好的回收率。

判斷需要回收哪個世代的垃圾

當GC被觸發時, GC首先需要確定回收哪個世代。
除了分配預算外, 還有這些因素需要考慮:

  • 世代的碎片化程度 – 如果這個世代的碎片化程度比較高, 則收集這個世代將會得到更好的效果
  • 如果系統內存占用比較高, 並且每次可以回收到一定的內存, GC可能會更積極的去回收。
    這對於防止系統內存分頁(把多出的內存數據轉移到硬盤)很重要。
  • 如果短暫的堆段的空間已經用完, GC可能會更積極的去回收以防止申請一個新的堆段。

GC的工作流程

標記階段 (Mark phase)

標記階段的目標是尋找所有存活的對象。

多世代收集器的好處是每次只需要去看堆中最近的對象, 而不需要去看歷史生成的所有對象。
當收集短暫世代(第0世代和第1世代)中的對象時, GC需要尋找這些世代中所有存活的對象,
運行引擎(EE)使用中的對象會標記為存活, 此外被其他對象(更老世代的對象)引用的對象也會標記為存活。

GC在標記更老的世代中的對象時會使用卡片(Cards),
JIT的幫助類會在賦值操作時設置卡片, 如果JIT的幫助類看到一個對象在短暫的范圍中它會設置一個包含了源位置的卡片的字節。
在短暫世代的回收中, GC可以只看堆中其余的部分設置的卡片來找到它們對應的對象。
(譯者注: 這段如果看不懂可以去看原文, 因為我還看不懂所以這里是直譯)

計划階段 (Plan phase)

計划階段會做一個比較來決定使用壓縮(Compaction)還是清掃(Sweeps)。
如果壓縮效果更好則GC會開始實際的壓縮, 否則GC會開始清掃。

重分配階段 (Relocate phase)

如果GC決定要壓縮, 那就要移動現有的對象, 指向這些對象的引用都需要被更新。
重分配階段需要找出指向回收世代中的對象的所有引用。
相反, 標記階段僅會參考存活的對象因此不需要考慮弱引用(Weak References)。

壓縮階段 (Compact phase)

這個階段的目標非常直接, 因為計划階段已經計算了對象應該移動到的新地址, 壓縮階段會復制對象到這些新地址中。

清掃階段 (Sweep phase)

清掃階段會尋找夾在存活對象中的空余空間(死對象), 並在這些空間里創建一些自由對象。
相鄰的死對象會被合並成一個自由對象。
創建的自由對象會放到 自由對象列表(freelist) 里。

代碼流程

縮寫: (譯者注: 以下不會使用這些縮寫)

  • WKS GC: 工作站GC。
  • SRV GC: 服務器GC

各個模式的舉動

工作站GC - 不啟用並發式GC
  1. 用戶線程超過了分配預算並觸發了GC。
  2. GC調用SuspendEE函數來停止所有受管理的線程(Managed Threads)。
  3. GC決定需要回收哪個世代。
  4. 運行標記階段。
  5. 運行計划階段決定是用壓縮還是用清掃。
  6. 如果決定壓縮則運行重分配階段和壓縮階段, 否則運行清掃階段。
  7. GC調用RestartEE函數來恢復受管理的線程。
  8. 用戶線程恢復運行。
工作站GC - 啟用並發式GC

這里說明了后台GC如何運作。

  1. 用戶線程超過了分配預算並觸發了GC。
  2. GC調用SuspendEE函數來停止所有受管理的線程(Managed Threads)。
  3. GC決定是否使用后台GC。
  4. 如果使用后台GC, 則后台GC線程會被喚醒並進行回收工作. 后台GC線程會調用RestartEE喚醒受管理的線程。
  5. 受管理的線程繼續運行, 同時后台GC線程也繼續它的工作。
  6. 用戶線程可能又一次的超過了分配預算並觸發了一個短暫的GC(我們稱為前台GC), 流程和 "工作站GC - 不啟用並發式GC" 一樣。
  7. 后台GC線程再次調用SuspendEE函數, 進行標記階段(Marking), 然后調用RestartEE函數, 再進行可以並行的清掃階段(Sweep)。
  8. 后台GC已完成工作。
服務器GC - 不啟用並發式GC
  1. 用戶線程超過了分配預算並觸發了GC。
  2. 服務器GC線程被喚醒, 並調用SuspendEE來停止所有受管理的線程(Managed Threads)。
  3. 服務器GC進行回收工作(流程和工作站GC - 不啟用並發式GC相同)。
  4. 服務器GC線程調用RestartEE喚醒受管理的線程。
  5. 用戶線程恢復運行。
服務器GC - 啟用並發式GC

流程和工作站GC - 啟用並發式GC一樣, 除了非后台的GC也會在服務器GC線程中完成。

物理架構

這一段旨在幫助你追蹤代碼流程。

當用戶線程用完分配單位(Allocation Quantum), 需要一個新的分配單位時會調用try_allocate_more_space函數。

當try_allocate_more_space函數需要觸發GC時會調用GarbageCollectGeneration函數。

在 "工作站GC - 不啟用並發式GC" 的模式下, GarbageCollectGeneration會在觸發GC的用戶線程上完成所有工作, 代碼流程是:

 GarbageCollectGeneration()
 {
     SuspendEE();
     garbage_collect();
     RestartEE();
 }
 
 garbage_collect()
 {
     generation_to_condemn();
     gc1();
 }
 
 gc1()
 {
     mark_phase();
     plan_phase();
 }
 
 plan_phase()
 {
     // 實際的計划階段, 判斷要用壓縮還是清掃
     if (compact)
     {
         relocate_phase();
         compact_phase();
     }
     else
         make_free_lists();
 }

在"工作站GC - 啟用並發式GC"的模式(默認模式)下, 后台GC的代碼流程是:


 GarbageCollectGeneration()
 {
     SuspendEE();
     garbage_collect();
     RestartEE();
 }
 
 garbage_collect()
 {
     generation_to_condemn();
     // 判斷要用后台GC, 喚醒后台GC
     do_background_gc();
 }
 
 do_background_gc()
 {
     init_background_gc();
     start_c_gc ();
 
     // 等待后台GC完成工作並重啟受管理的線程
     wait_to_proceed();
 }
 
 bgc_thread_function()
 {
     while (1)
     {
        // 等待事件
        // 喚醒
        gc1();
     }
 }
 
 gc1()
 {
        background_mark_phase();
        background_sweep();
 }

資源鏈接

譯者后注

這份文檔簡單的介紹了GC的設計和工作流程, 同時也帶來和很多疑問, 例如一個程序有多少個堆和什么是卡片等
我將在CoreCLR源碼探索的系列文章中分析CoreCLR的GC源碼來解決這些疑問
另外博客園上已經有一位大神對CoreCLR的GC源碼進行了部分分析,可以 查看他的博客


免責聲明!

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



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