
.NET Core GC 清掃與壓縮篇章07-源碼深度-.NET Core GC |閱讀時(shí)間約 45 分鐘一、引言標(biāo)記階段識別了存活對象后GC 進(jìn)入回收階段——清掃死亡對象、壓縮存活對象。清掃和壓縮是 GC 中內(nèi)存效率的決定性因素清掃決定了多少死亡內(nèi)存被真正釋放壓縮決定了碎片是否累積到影響分配的程度。但清掃和壓縮的代價(jià)也不容忽視。一次全堆壓縮可能耗時(shí)數(shù)十到數(shù)百毫秒直接反映在應(yīng)用的暫停時(shí)間上。所以 .NET Core GC 的核心設(shè)計(jì)哲學(xué)是能不清壓就不清壓必須清壓時(shí)盡量并行化。這個(gè)哲學(xué)貫穿了從計(jì)劃決策到清掃實(shí)現(xiàn)、從壓縮策略到 LOH 特殊處理的每一個(gè)環(huán)節(jié)。在展開之前先把本章的定位說清楚。07-03 標(biāo)記篇已經(jīng)講完了哪些對象活著本章接著講活著的東西怎么擺回去、死的東西怎么收走。后臺 GC 的并發(fā)編排見 07-05分配路徑見 07-02堆結(jié)構(gòu)與分代布局的背景見 07-01。二、計(jì)劃階段Plan Phase2.1 清掃前的決策GC 在執(zhí)行任何清掃動(dòng)作之前必須先制定計(jì)劃。這個(gè)計(jì)劃決定了哪些代需要清掃、哪些代需要壓縮、LOH 是否需要特殊處理、是否啟用并行清掃。// gc.h - GCPlan 結(jié)構(gòu)體簡化示意不含全部字段 struct GCPlan { bool compact_gen2; // Gen 2 是否需要壓縮 bool compact_loh; // LOH 是否需要壓縮 int sweep_gen0_segments; // Gen 0 需要清掃的段數(shù) int sweep_gen1_segments; // Gen 1 需要清掃的段數(shù) int sweep_gen2_segments; // Gen 2 需要清掃的段數(shù) bool concurrent_sweep; // 是否并發(fā)清掃后臺 GC 時(shí) bool parallel_sweep; // 是否并行清掃多核時(shí) float fragmentation_ratio_gen2; // Gen 2 碎片率 float fragmentation_ratio_loh; // LOH 碎片率 };計(jì)劃階段的核心輸入是碎片率。碎片率定義為碎片率 1.0 - (最大連續(xù)空閑塊大小 / 總空閑內(nèi)存)換句話說碎片率衡量的是空閑內(nèi)存被分散到了多小的塊里。碎片率為 0 表示所有空閑內(nèi)存連成一塊碎片率為 1 表示每個(gè)空閑塊都只有 1 字節(jié)極端情況。2.2 壓縮觸發(fā)條件壓縮不是每次 GC 都做的——它只在碎片率超過閾值時(shí)才觸發(fā)。.NET Core GC 的默認(rèn)閾值因代而異代壓縮觸發(fā)閾值原因Gen 0不壓縮Gen 0 每次 GC 都重建天然無碎片Gen 1不壓縮Gen 1 是 Gen 0 的緩沖區(qū)本身不獨(dú)立管理Gen 2~50%大堆碎片累積快壓縮收益高LOH~70%手動(dòng)LOH 不使用 bump pointer碎片率天然較低注意 Gen 0 和 Gen 1從不壓縮。這是因?yàn)?Gen 0 在每次 GC 時(shí)都會重建——所有存活對象晉升到 Gen 1Gen 0 的段被完全清空并重新使用。這種全量晉升 段復(fù)用的機(jī)制天然消除了 Gen 0 的碎片不需要壓縮。Gen 1 也不壓縮因?yàn)樗皇且粋€(gè)獨(dú)立的內(nèi)存區(qū)域——它和 Gen 2 共享同一組段。對 Gen 2 的壓縮同時(shí)就是對 Gen 1 的壓縮。2.3 計(jì)劃決策的時(shí)序標(biāo)記階段完成 │ ▼ 統(tǒng)計(jì)各代碎片率 │ ├─ Gen 2 碎片率 50%? │ └─ Yes → compact_gen2 true │ ├─ LOH 手動(dòng)觸發(fā)壓縮? │ └─ Yes → compact_loh true │ ├─ 并發(fā) GC 且 Gen 2 需要清掃? │ └─ Yes → concurrent_sweep true │ └─ CPU 核數(shù) 1 且需要清掃? └─ Yes → parallel_sweep true計(jì)劃階段本身非??焱ǔV恍鑾装傥⒚?。它的輸出GCPlan會被傳遞給清掃階段和壓縮階段指導(dǎo)后續(xù)的所有操作。三、清掃階段Sweep Phase3.1 清掃的基本原理清掃是 GC 回收階段的第一步。它的任務(wù)很簡單遍歷段中的所有對象把死亡對象的內(nèi)存標(biāo)記為可用。但實(shí)現(xiàn)起來并不簡單。清掃需要處理以下問題對象遍歷如何高效地遍歷段中的所有對象死亡檢測如何判斷一個(gè)對象是否死亡空閑管理如何管理回收后的空閑內(nèi)存3.1.1 對象遍歷清掃遍歷段的方式與標(biāo)記遍歷對象圖的方式不同。標(biāo)記是從根出發(fā)沿著引用字段下鉆而清掃是順序遍歷段中的每個(gè)對象按對象大小步進(jìn)。// SweepSegment 核心邏輯簡化示意 void SweepSegment(heap_segment* seg) { uint8_t* current seg-start; uint8_t* end seg-allocated; while (current end) { Object* obj (Object*)current; // 檢查對象是否被標(biāo)記 if (!obj-IsMarked()) { // 死亡對象回收其內(nèi)存 size_t size obj-GetSize(); AddToFreeList(current, size); } else { // 存活對象清除標(biāo)記位準(zhǔn)備下一輪 obj-ClearMark(); } // 按對象大小步進(jìn)到下一個(gè)對象 current obj-GetSize(); } }這個(gè)遍歷是順序的對內(nèi)存的訪問模式非常友好——CPU 預(yù)取器可以很好地工作緩存命中率極高。這也是為什么清掃階段通常只占 GC 暫停時(shí)間的 10% 左右。3.1.2 死亡檢測死亡檢測通過檢查對象的標(biāo)記位mark bit實(shí)現(xiàn)。標(biāo)記階段會為每個(gè)存活對象設(shè)置標(biāo)記位清掃階段只需檢查這個(gè)位標(biāo)記位為 1存活對象清除標(biāo)記位繼續(xù)下一個(gè)標(biāo)記位為 0死亡對象回收內(nèi)存標(biāo)記位通常存儲在對象頭的某個(gè)比特位中訪問速度極快一條位操作指令。3.1.3 空閑管理回收后的空閑內(nèi)存需要被管理以便后續(xù)分配能夠快速找到可用空間。.NET Core GC 使用按大小分類的空閑列表size-class free list空閑列表結(jié)構(gòu) ┌─────────────────────────────────────────────────┐ │ FreeList[0] (8-15 字節(jié)) ──? [塊A] ──? [塊B] │ │ FreeList[1] (16-31 字節(jié)) ──? [塊C] ──? NULL │ │ FreeList[2] (32-63 字節(jié)) ──? [塊D] │ │ ... │ │ FreeList[N] (8KB) ──? [塊E] ──? [塊F] │ └─────────────────────────────────────────────────┘空閑列表按大小分類的好處是分配時(shí)可以根據(jù)請求大小直接定位到對應(yīng)的鏈表避免全表搜索。這與 slab 分配器的思想類似——將相近大小的空閑塊歸為一類每類維護(hù)一個(gè)獨(dú)立鏈表。3.2 清掃的零分配保證清掃階段有一個(gè)重要的設(shè)計(jì)原則不分配任何新對象。所有需要的臨時(shí)數(shù)據(jù)結(jié)構(gòu)空閑鏈表節(jié)點(diǎn)等都從已回收的對象本身借用內(nèi)存。這是經(jīng)典的intrusive list侵入式鏈表技術(shù)被回收的對象內(nèi)存直接作為鏈表節(jié)點(diǎn)使用不需要額外分配內(nèi)存來維護(hù)空閑列表。具體來說死亡對象的第一個(gè)字段原本存儲方法表指針之后的位置被覆寫為下一個(gè)空閑塊的指針。死亡對象回收前的內(nèi)存布局 ┌────────────────────────────────────────────┐ │ 方法表指針 (8B) │ 同步塊 (8B) │ 字段數(shù)據(jù)... │ └────────────────────────────────────────────┘ │ ▼ 回收后方法表指針之后的內(nèi)存被覆寫 ┌────────────────────────────────────────────┐ │ 方法表指針 (8B) │ 空閑鏈表指針 ?────────────┘ │ (保持不變GC 不再需要) │ └────────────────────────────────────────────┘這種設(shè)計(jì)的優(yōu)勢零額外內(nèi)存不需要為空閑鏈表分配任何額外內(nèi)存。零額外分配不需要在清掃過程中調(diào)用malloc或new。零額外開銷空閑鏈表的插入操作只是修改一個(gè)指針。不清零被回收的對象不需要清零因?yàn)樗膬?nèi)存即將被重新分配使用。3.3 并發(fā)清掃在后臺 GCBackground GC模式下清掃可以與應(yīng)用線程并發(fā)執(zhí)行。這是 .NET Core 3.0 的重要優(yōu)化時(shí)間線示意 應(yīng)用線程: [Alloc][Alloc][Alloc][Alloc][Alloc][Alloc]... │ │ GC線程: ──[標(biāo)記 Gen2]──[清掃 Gen2]──────[壓縮 Gen2]── │ │ │ │ ├─ 并發(fā)清掃應(yīng)用線程繼續(xù)分配 │ └─ 壓縮時(shí)需要 STW并發(fā)清掃的關(guān)鍵技術(shù)是標(biāo)記-清掃并發(fā)標(biāo)記階段在后臺線程中并行執(zhí)行清掃階段在標(biāo)記完成后立即開始不需要等待應(yīng)用線程全部掛起。只有壓縮階段需要 STWStop-The-World。并發(fā)清掃的實(shí)現(xiàn)依賴于以下機(jī)制標(biāo)記位的原子操作標(biāo)記階段設(shè)置標(biāo)記位使用原子操作清掃階段讀取標(biāo)記位不需要加鎖。段級鎖清掃器獲取段的鎖后其他線程不能修改該段的內(nèi)容。段級鎖的粒度比全局鎖小得多減少了競爭??ㄆ硌a(bǔ)償并發(fā)期間應(yīng)用線程可能創(chuàng)建新的引用這些引用通過卡片表記錄清掃完成后由 GC 進(jìn)行補(bǔ)償掃描。3.4 并行清掃在多核機(jī)器上清掃可以并行化。不同清掃器線程負(fù)責(zé)不同的段互不干擾段 0 ──? 清掃器線程 0 段 1 ──? 清掃器線程 1 段 2 ──? 清掃器線程 2 段 3 ──? 清掃器線程 3并行清掃的加速比接近線性——4 核機(jī)器上清掃 4 個(gè)段的耗時(shí)約等于清掃 1 個(gè)段的 4 倍。但并行清掃也有開銷任務(wù)分發(fā)需要將段分配給清掃器線程這本身有同步開銷??臻e列表合并各線程回收的空閑塊需要合并到全局空閑列表中。緩存競爭多個(gè)線程同時(shí)寫入空閑列表可能導(dǎo)致緩存行失效。因此并行清掃在段數(shù)較多時(shí)收益明顯段數(shù)較少時(shí)可能得不償失。.NET Core GC 會根據(jù)段數(shù)和 CPU 核數(shù)自動(dòng)決定是否啟用并行清掃。四、壓縮階段Compact Phase4.1 為什么需要壓縮清掃雖然回收了死亡對象的內(nèi)存但它留下的空閑塊是分散的。如果死亡對象和存活對象交替出現(xiàn)空閑塊也會交替出現(xiàn)清掃前的段布局 ┌──────┬────────┬──────┬────────┬──────┬────────┬──────┐ │ 存活 │ 死亡 │ 存活 │ 死亡 │ 存活 │ 死亡 │ 存活 │ │ ObjA │ ObjX │ ObjB │ ObjY │ ObjC │ ObjZ │ ObjD │ └──────┴────────┴──────┴────────┴──────┴────────┴──────┘ 空閑塊: [ObjX] [ObjY] [ObjZ] 分散在存活對象之間 清掃后未壓縮 ┌──────┬────────┬──────┬────────┬──────┬────────┬──────┬──────┐ │ 存活 │ 死亡 │ 存活 │ 死亡 │ 存活 │ 死亡 │ 存活 │ 空閑 │ │ ObjA │ ObjX │ ObjB │ ObjY │ ObjC │ ObjZ │ ObjD │ │ └──────┴────────┴──────┴────────┴──────┴────────┴──────┴──────┘ 空閑塊分散在 ObjA/ObjB/ObjC/ObjD 之間 → 碎片化 壓縮后 ┌──────┬──────┬──────┬──────┬──────────────────────────────┐ │ 存活 │ 存活 │ 存活 │ 存活 │ 連續(xù)空閑空間 │ │ ObjA │ ObjB │ ObjC │ ObjD │ (可分配區(qū)域) │ └──────┴──────┴──────┴──────┴──────────────────────────────┘ 所有存活對象移到段開頭空閑空間集中在段尾 → 無碎片壓縮的核心價(jià)值是將分散的空閑塊合并為連續(xù)的可用空間。沒有壓縮碎片會逐步累積最終導(dǎo)致即使總空閑內(nèi)存足夠也無法分配大塊對象因?yàn)檎也坏阶銐虼蟮倪B續(xù)空間。4.2 壓縮的三個(gè)步驟壓縮是 GC 中最復(fù)雜的操作涉及三個(gè)緊密耦合的步驟步驟 1計(jì)算新地址// ComputeNewAddresses簡化示意 void ComputeNewAddresses(int generation) { uint8_t* write_ptr segment-start; // 寫入指針從段頭開始 for (uint8_t* current segment-start; current segment-allocated; current obj-GetSize()) { Object* obj (Object*)current; if (obj-IsMarked()) { // 存活對象記錄新地址 obj-SetNewAddress(write_ptr); write_ptr obj-GetSize(); } // 死亡對象跳過不分配新地址 } segment-allocated write_ptr; // 更新段的分配上限 }這一步是純讀取 順序?qū)懭雽?nèi)存的訪問模式非常友好。每個(gè)存活對象只被訪問一次按順序從段頭掃到段尾。關(guān)鍵細(xì)節(jié)新地址單調(diào)遞增存活對象按原順序排列新地址嚴(yán)格遞增。這意味著壓縮不會改變對象的相對順序有利于緩存局部性。死亡對象被跳過死亡對象不占用新地址空間相當(dāng)于在計(jì)算新地址的同時(shí)完成了收縮。對象大小不變壓縮不改變對象大小只改變對象的起始地址。步驟 2修復(fù)引用Fix References// FixReferences簡化示意 void FixReferences(int generation) { // 修復(fù)棧上的引用 for (Thread* thread : suspended_threads) { for (RefLocation ref : thread-stack_references) { if (ref.target segment_start ref.target segment_end) { *ref.location ref.target-GetNewAddress(); } } } // 修復(fù)堆上的引用存活對象中的引用字段 for (uint8_t* obj segment-start; obj segment-allocated; ) { if (obj-IsMarked()) { obj-FixReferences(); // 對象內(nèi)部的引用字段全部更新 obj obj-GetSize(); } } // 修復(fù)靜態(tài)字段 for (StaticRef ref : static_references) { if (ref.target segment_start ref.target segment_end) { *ref.location ref.target-GetNewAddress(); } } // 修復(fù)句柄表 for (HandleEntry entry : handle_table) { if (entry.target segment_start entry.target segment_end) { entry.target entry.target-GetNewAddress(); } } }引用修復(fù)是壓縮中最復(fù)雜、最耗時(shí)的部分。需要修復(fù)的引用來源包括棧引用各線程棧幀中指向壓縮段的對象引用。堆引用存活對象內(nèi)部的引用字段指向同一段或其他段中的對象。靜態(tài)字段類靜態(tài)變量中指向壓縮段的引用。句柄表GCHandle 中指向壓縮段的引用。GC 內(nèi)部結(jié)構(gòu)如卡片表、根表等。引用修復(fù)的關(guān)鍵約束必須在復(fù)制之前完成。因?yàn)閺?fù)制會移動(dòng)對象如果先復(fù)制再修復(fù)引用修復(fù)時(shí)找不到舊地址。步驟 3復(fù)制對象數(shù)據(jù)// CopyObjects簡化示意 void CopyObjects(int generation) { for (uint8_t* current segment-start; current segment-old_allocated; current obj-GetSize()) { Object* obj (Object*)current; if (obj-IsMarked()) { uint8_t* new_addr obj-GetNewAddress(); if (current ! new_addr) { // 對象需要移動(dòng)復(fù)制數(shù)據(jù) size_t size obj-GetSize(); MemMove(new_addr, current, size); } } } }復(fù)制階段使用MemMove批量移動(dòng)對象數(shù)據(jù)。由于對象是按順序處理的且新地址也是順序遞增的MemMove的調(diào)用可以很好地利用 CPU 的 DMA 緩存行預(yù)取和 SIMD 指令。關(guān)鍵優(yōu)化原地跳過如果對象的新地址等于舊地址通常發(fā)生在段尾部的存活對象直接跳過不復(fù)制。批量復(fù)制MemMove內(nèi)部使用 SIMD 指令如 AVX2一次復(fù)制 32 或 64 字節(jié)比逐字節(jié)復(fù)制快數(shù)倍。寫合并多個(gè)小對象的復(fù)制可以合并為一次MemMove調(diào)用減少函數(shù)調(diào)用開銷。4.3 壓縮的代價(jià)壓縮涉及大量內(nèi)存復(fù)制和引用更新是 GC 中最耗時(shí)的操作。以下是一個(gè)典型場景的耗時(shí)分解100MB Gen 2 堆碎片率 60%一次全量壓縮 步驟 耗時(shí) 占比 ───────────────────────────────────── 計(jì)算新地址 5-10ms ~15% 修復(fù)引用 20-80ms ~60% ← 最耗時(shí) 復(fù)制對象數(shù)據(jù) 5-10ms ~15% 清理段元數(shù)據(jù) 1-2ms ~5% ───────────────────────────────────── 合計(jì) 30-100ms 100%各步驟的耗時(shí)特征計(jì)算新地址O(n) 順序遍歷緩存友好耗時(shí)與存活對象數(shù)成正比。修復(fù)引用O(n r)n 為存活對象數(shù)r 為引用字段總數(shù)。引用字段越多耗時(shí)越長。棧引用和句柄表引用的修復(fù)需要逐個(gè)處理無法批量優(yōu)化。復(fù)制對象數(shù)據(jù)O(n)但MemMove的常數(shù)極小實(shí)際耗時(shí)通常低于引用修復(fù)。清理段元數(shù)據(jù)O(1) 或 O(段數(shù))耗時(shí)可忽略。4.4 壓縮的并發(fā)化.NET Core GC 在 .NET 6 中引入了并發(fā)壓縮的部分優(yōu)化。核心思想是將壓縮拆分為多個(gè)階段其中計(jì)算新地址和修復(fù)引用可以在后臺線程中并行執(zhí)行只有最后的復(fù)制 最終引用修復(fù)需要 STW。傳統(tǒng)壓縮全 STW [計(jì)算新地址][修復(fù)引用][復(fù)制對象][最終修復(fù)] ← 全部 STW 并發(fā)壓縮.NET 6 [計(jì)算新地址]──[修復(fù)引用] ← 后臺線程并行執(zhí)行 │ ▼ STW 切換點(diǎn) [復(fù)制對象][最終修復(fù)] ← 短 STW這種設(shè)計(jì)的優(yōu)勢是將 STW 時(shí)間從幾十毫秒降低到幾毫秒。后臺階段雖然也消耗 CPU 和內(nèi)存帶寬但不影響應(yīng)用線程只有最后的 STW 階段會暫停應(yīng)用。五、LOH 清掃與壓縮5.1 LOH 為什么不同LOH大對象堆的清掃和壓縮與 SOH小對象堆有本質(zhì)區(qū)別原因在 07-02 中已經(jīng)提到過不使用 bump pointerLOH 使用空閑列表管理天然不存在清掃后合并空閑塊的需求。對象生命周期長LOH 對象通常存活整個(gè)應(yīng)用生命周期碎片率增長緩慢。壓縮代價(jià)極高LOH 對象可能從 85KB 到幾 GB復(fù)制一個(gè)大對象的代價(jià)相當(dāng)于復(fù)制成千上萬個(gè)小對象。5.2 LOH 清掃LOH 的清掃與 SOH 類似也是遍歷死亡對象并回收內(nèi)存。但由于 LOH 對象數(shù)量遠(yuǎn)少于 SOH 對象LOH 清掃的耗時(shí)通??梢院雎圆挥?jì)。// LOH Sweep簡化示意 void SweepLOH() { for (auto entry : loh_free_list) { if (!entry.obj-IsMarked()) { // 死亡 LOH 對象回收 AddToLOHFreeList(entry.obj, entry.size); } else { // 存活 LOH 對象清除標(biāo)記 entry.obj-ClearMark(); } } }5.3 LOH 壓縮LOH 壓縮是一個(gè)手動(dòng)觸發(fā)的操作默認(rèn)不自動(dòng)執(zhí)行。這是因?yàn)?LOH 壓縮的代價(jià)極高——它需要移動(dòng)所有存活的大對象并更新所有指向它們的引用。// 手動(dòng)觸發(fā) LOH 壓縮 GCSettings.LargeObjectHeapCompactionMode GCLargeObjectHeapCompactionMode.CompactOnce; // 下一次 GC.Collect() 會壓縮 LOH GC.Collect();LOH 壓縮的觸發(fā)條件觸發(fā)方式條件說明手動(dòng)觸發(fā)GCSettings.LargeObjectHeapCompactionMode CompactOnce下一次 GC.Collect() 觸發(fā)永久模式GCSettings.LargeObjectHeapCompactionMode CompactEveryTime每次 GC.Collect() 都觸發(fā)不推薦自動(dòng)觸發(fā)無.NET Core 默認(rèn)不自動(dòng)壓縮 LOHLOH 壓縮的代價(jià)內(nèi)存復(fù)制所有存活 LOH 對象需要被移動(dòng)復(fù)制量可能達(dá)到數(shù) GB。引用更新所有指向 LOH 對象的引用需要更新包括棧引用、堆引用、靜態(tài)字段、句柄表。STW 時(shí)間長LOH 壓縮通常需要 STW 數(shù)百毫秒到數(shù)秒取決于 LOH 大小和存活對象數(shù)。因此LOH 壓縮應(yīng)該謹(jǐn)慎使用只在確實(shí)存在嚴(yán)重碎片問題且影響分配時(shí)才觸發(fā)。5.4 LOH 碎片問題與緩解LOH 碎片是 .NET 中一個(gè)經(jīng)典問題。當(dāng)頻繁分配和釋放大小不一的 LOH 對象時(shí)空閑列表會逐漸碎片化最終導(dǎo)致即使總空閑內(nèi)存足夠也無法分配新的大對象。緩解 LOH 碎片的策略避免頻繁分配/釋放 LOH 對象使用對象池復(fù)用 LOH 對象??刂茖ο蟠笮”M量使對象大小低于 85,000 字節(jié)閾值使其走 SOH 路徑。定期手動(dòng)壓縮在低峰期手動(dòng)觸發(fā) LOH 壓縮。使用ArrayPoolT對于數(shù)組類型的 LOH 對象使用數(shù)組池復(fù)用。六、壓縮的代價(jià)與觸發(fā)策略6.1 壓縮耗時(shí)分解一次完整的 Gen 2 壓縮100MB 堆的耗時(shí)構(gòu)成Full GC 耗時(shí)分解100MB 堆碎片率 60% 標(biāo)記: ████████████████████████ 60% (15-30ms) ← 后臺GC改造目標(biāo) 清掃: ████ 10% (2-5ms) 壓縮: ██████ 30% (10-50ms) ← 最慢但僅在碎片率超閾值時(shí)觸發(fā) ───────────────────────────────────────────────── 合計(jì): 100% (27-85ms)關(guān)鍵觀察標(biāo)記占大頭標(biāo)記是耗時(shí)最長的階段也是后臺 GC 優(yōu)化的主要目標(biāo)。清掃最穩(wěn)定清掃耗時(shí)波動(dòng)最小因?yàn)樗灰蕾嚧婊顚ο髷?shù)與碎片率無關(guān)。壓縮最可變壓縮耗時(shí)波動(dòng)最大從幾毫秒低碎片到數(shù)百毫秒高碎片 大堆。6.2 壓縮觸發(fā)策略.NET Core GC 的壓縮觸發(fā)策略可以概括為保守觸發(fā)按需壓縮。是否壓縮的決策邏輯 1. Gen 0? → 不壓縮每次 GC 都重建 2. Gen 1? → 不壓縮與 Gen 2 共享段隨 Gen 2 一起壓縮 3. Gen 2? ├─ 碎片率 50%? → Yes → 壓縮 └─ No → 不壓縮 4. LOH? ├─ 手動(dòng)觸發(fā)? → Yes → 壓縮 └─ No → 不壓縮即使碎片率很高這個(gè)策略的核心考量壓縮的代價(jià)高壓縮需要移動(dòng)對象、更新引用耗時(shí)可能遠(yuǎn)超清掃。碎片不是最緊急的問題只要還有足夠大的連續(xù)空閑塊碎片不會導(dǎo)致分配失敗。預(yù)算機(jī)制優(yōu)先通過 budget 機(jī)制控制 GC 頻率比通過壓縮控制碎片更經(jīng)濟(jì)。6.3 壓縮的替代方案在某些場景下壓縮不是最優(yōu)選擇。.NET Core GC 提供了以下替代方案段復(fù)用回收的段不還給操作系統(tǒng)留給下輪復(fù)用。這避免了還了再要的系統(tǒng)調(diào)用開銷??臻e列表優(yōu)化通過按大小分類的空閑列表提高小塊的分配命中率減少對新段的需求。晉升策略調(diào)優(yōu)調(diào)整對象晉升策略讓短命對象更快離開 Gen 0減少 Gen 0 的碎片累積。七、GC 耗時(shí)分解與優(yōu)化方向7.1 一次 Full GC 的完整耗時(shí)一次 Full GC100MB Gen 2 堆碎片率 40%的耗時(shí)構(gòu)成 階段 耗時(shí) 是否 STW 可并發(fā) ───────────────────────────────────────────────── 掛起線程 1-5ms Yes 否 根掃描 1-3ms Yes 否 標(biāo)記 Gen 0/1 5-10ms 部分 是后臺 標(biāo)記 Gen 2 10-20ms 部分 是后臺 清掃 Gen 0 1-2ms 部分 是并發(fā) 清掃 Gen 1/2 2-5ms 部分 是并發(fā) 壓縮 Gen 2 10-30ms Yes 否 修復(fù)引用 5-20ms Yes 部分 恢復(fù)線程 1-3ms Yes 否 ───────────────────────────────────────────────── 總 STW 時(shí)間 20-60ms 總耗時(shí)含并發(fā) 30-100ms7.2 優(yōu)化方向基于耗時(shí)分解GC 優(yōu)化的優(yōu)先級如下減少標(biāo)記耗時(shí)后臺 GC 已經(jīng)將標(biāo)記移到后臺進(jìn)一步優(yōu)化空間有限。減少壓縮觸發(fā)通過 budget 自適應(yīng)和晉升策略降低碎片率減少壓縮觸發(fā)頻率。減少 STW 時(shí)間并發(fā)壓縮.NET 6將大部分壓縮工作移到后臺進(jìn)一步降低 STW 時(shí)間。減少清掃耗時(shí)清掃已經(jīng)很快10%優(yōu)化空間有限。八、清掃和壓縮的時(shí)機(jī)選擇8.1 何時(shí)壓縮.NET Core GC 根據(jù)以下因素決定是否壓縮碎片率空閑空間分散度超過閾值時(shí)觸發(fā)壓縮。Gen 2 默認(rèn)閾值約 50%LOH 默認(rèn)不自動(dòng)壓縮。最近一次壓縮的時(shí)間間隔避免頻繁壓縮。如果剛做過壓縮即使碎片率略有上升也不會立即再次壓縮。堆大小大堆壓縮的收益更大——碎片浪費(fèi)的絕對空間更多壓縮后釋放的連續(xù)空間也更可觀。內(nèi)存壓力當(dāng)操作系統(tǒng)通知內(nèi)存緊張時(shí)GC 可能降低壓縮閾值更積極地壓縮以釋放空間。8.2 實(shí)際項(xiàng)目中的觀察在實(shí)際項(xiàng)目中大多數(shù) GC 只需要清掃不需要壓縮。壓縮通常只在以下場景觸發(fā)長運(yùn)行服務(wù)運(yùn)行時(shí)間越長碎片累積越多壓縮觸發(fā)越頻繁。對象生命周期差異大如果同時(shí)存在短命對象和長命對象長命對象之間的空洞會逐步累積為碎片。LOH 頻繁分配雖然 LOH 不自動(dòng)壓縮但 LOH 碎片會影響 Gen 2 的碎片率間接觸發(fā) Gen 2 壓縮。九、清掃的零分配保證9.1 Intrusive List 技術(shù)清掃階段有一個(gè)重要的設(shè)計(jì)原則不分配任何新對象。所有需要的臨時(shí)數(shù)據(jù)空閑鏈表節(jié)點(diǎn)等從已回收的對象本身借用內(nèi)存。這是經(jīng)典的intrusive list侵入式鏈表技術(shù)。具體來說被回收的死亡對象的內(nèi)存被直接用作空閑鏈表的節(jié)點(diǎn)。死亡對象的第一個(gè)字段原本存儲方法表指針之后的位置被覆寫為下一個(gè)空閑塊的指針。這樣空閑鏈表不需要任何額外的內(nèi)存分配——它寄生在已回收的對象內(nèi)存上。死亡對象回收前的內(nèi)存布局 ┌──────────────────────────────────────────────────────┐ │ 方法表指針 (8B) │ 同步塊 (8B) │ 字段數(shù)據(jù) (N 字節(jié)) │ │ ← 對象頭 ← 對象數(shù)據(jù) │ └──────────────────────────────────────────────────────┘ │ ▼ 回收后方法表指針之后的內(nèi)存被覆寫為鏈表指針 ┌──────────────────────────────────────────────────────┐ │ 方法表指針 (8B) │ 空閑鏈表下一個(gè)節(jié)點(diǎn)指針 (8B) │ ... │ │ (GC 不再需要方法表指針) │ └──────────────────────────────────────────────────────┘ │ ▼ 插入空閑鏈表 FreeList[size_class] ──? [此塊] ──? [下一個(gè)空閑塊] ──? ...9.2 零分配的優(yōu)勢這種設(shè)計(jì)的優(yōu)勢是多方面的零額外內(nèi)存不需要為空閑鏈表分配任何額外內(nèi)存。空閑鏈表的節(jié)點(diǎn)就是死亡對象本身。零額外分配不需要在清掃過程中調(diào)用malloc或new避免了在 GC 過程中觸發(fā)遞歸 GC 的風(fēng)險(xiǎn)。零額外開銷空閑鏈表的插入操作只是修改一個(gè)指針時(shí)間復(fù)雜度 O(1)。不清零被回收的對象不需要清零。因?yàn)樗膬?nèi)存即將被重新分配使用清零是浪費(fèi)。緩存友好空閑鏈表節(jié)點(diǎn)與對象數(shù)據(jù)在同一塊內(nèi)存中訪問局部性好。9.3 與 Java GC 的對比Java 的 OpenJDK GC如 G1使用獨(dú)立的空閑列表結(jié)構(gòu)每個(gè)空閑塊需要額外的元數(shù)據(jù)來記錄大小和下一個(gè)節(jié)點(diǎn)。這意味著清掃階段需要為每個(gè)死亡塊分配一個(gè)空閑節(jié)點(diǎn)。.NET 的 intrusive list 技術(shù)避免了這一開銷是零分配保證的關(guān)鍵。十、與 Unity GC 的對比10.1 Unity (SGen) 的清掃與壓縮Unity 的 GC 基于 Mono 的 SGenScavenge-Sweep Garbage Collector與 .NET Core GC 有顯著差異特性.NET Core GCUnity (SGen)清掃方式順序遍歷 intrusive list標(biāo)記-清掃 獨(dú)立空閑列表壓縮策略Gen 2 按需壓縮分代壓縮Scavenge 階段LOH 壓縮手動(dòng)觸發(fā)無專門 LOH并發(fā)清掃.NET 3.0 支持增量清掃可配置零分配保證清掃階段不分配部分支持壓縮暫停時(shí)間30-100ms100MB10-50ms100MBUnity 的增量清掃是應(yīng)對移動(dòng)設(shè)備資源限制的特殊設(shè)計(jì)——將清掃拆分成多個(gè)小片段避免單次清掃暫停過長。而 .NET Core GC 更傾向于一次性完成通過后臺 GC 和并發(fā)壓縮來降低單次暫停的代價(jià)。10.2 為什么 Unity 的壓縮暫停更短Unity SGen 的壓縮暫停通常比 .NET Core GC 短原因有三堆更小移動(dòng)設(shè)備的堆通常只有幾十 MB而 .NET 服務(wù)端應(yīng)用的堆可能達(dá)到數(shù) GB。壓縮耗時(shí)與堆大小成正比。壓縮策略更保守SGen 只在必要時(shí)壓縮小代不壓縮整個(gè)堆。對象更小移動(dòng)應(yīng)用的對象通常較小復(fù)制代價(jià)更低。但這些差異也反映了平臺約束移動(dòng)設(shè)備內(nèi)存有限不能像服務(wù)端那樣使用大堆和 LOH。十一、量化數(shù)據(jù)的口徑說明11.1 數(shù)據(jù)性質(zhì)本文給出的所有量化數(shù)據(jù)耗時(shí)、百分比、閾值等均為典型估算或示例量級非實(shí)測常數(shù)。實(shí)際數(shù)值取決于以下因素堆大小100MB 堆與 10GB 堆的耗時(shí)相差兩個(gè)數(shù)量級。碎片率碎片率從 10% 上升到 80%壓縮耗時(shí)可能增加 5-10 倍。CPU 核數(shù)并行清掃和壓縮的加速比接近線性但受內(nèi)存帶寬限制。對象大小分布大對象多的堆壓縮耗時(shí)更長。引用密度引用字段多的對象修復(fù)耗時(shí)更長。11.2 典型場景參考以下數(shù)據(jù)基于 16 核 64GB 服務(wù)端場景的典型估算非實(shí)測場景Gen 0 耗時(shí)Gen 2 耗時(shí)是否壓縮低碎片 (20%)0.5-2ms10-30ms否中碎片 (20-50%)0.5-2ms20-60ms否高碎片 (50%)0.5-2ms50-150ms是LOH 壓縮N/A100-500ms是手動(dòng)十二、總結(jié)與實(shí)踐建議12.1 核心要點(diǎn)回顧清掃和壓縮是 GC 回收階段的核心它們的設(shè)計(jì)體現(xiàn)了按需觸發(fā)、零分配保證、并發(fā)優(yōu)化的思想清掃回收死亡對象通過 intrusive list 技術(shù)實(shí)現(xiàn)零分配耗時(shí)僅占 GC 的 10%。壓縮消除碎片將分散的空閑塊合并為連續(xù)空間但代價(jià)高30% 的 GC 耗時(shí)。按需觸發(fā)壓縮只在碎片率超過閾值時(shí)觸發(fā)大多數(shù) GC 只需清掃。并發(fā)優(yōu)化后臺 GC 將清掃移到后臺.NET 6 將壓縮的部分工作移到后臺。LOH 特殊處理LOH 不自動(dòng)壓縮需要手動(dòng)觸發(fā)代價(jià)極高。12.2 實(shí)踐建議基于對清掃和壓縮的理解以下是針對 C# 開發(fā)者的實(shí)踐建議理解壓縮觸發(fā)條件壓縮只在碎片率超過閾值時(shí)觸發(fā)。如果你的應(yīng)用碎片率始終很低壓縮幾乎不會成為性能瓶頸。謹(jǐn)慎使用 LOH 壓縮GCSettings.LargeObjectHeapCompactionMode CompactOnce是核武器級別的操只在確實(shí)存在嚴(yán)重 LOH 碎片且影響分配時(shí)才使用。關(guān)注 Gen 2 堆大小Gen 2 堆越大壓縮耗時(shí)越長。通過減少長命對象的數(shù)量和大小可以降低壓縮耗時(shí)。避免對象生命周期混疊短命對象和長命對象混在一起會導(dǎo)致碎片快速累積??梢酝ㄟ^分層設(shè)計(jì)將短命和長命對象放在不同的類中來減少碎片。監(jiān)控碎片率使用 PerfView 或 ETW 事件監(jiān)控 GC 碎片率發(fā)現(xiàn)異常上升趨勢。理解零分配保證清掃階段不分配新對象所以不會觸發(fā)遞歸 GC。這意味著清掃的耗時(shí)是確定性的不會因 GC 嵌套而膨脹。12.3 清掃與壓縮的決策樹GC 回收階段 │ ├─ 標(biāo)記階段完成 │ │ │ ├─ Gen 0? │ │ └─ 清掃重建段天然無碎片 │ │ │ ├─ Gen 1? │ │ └─ 清掃隨 Gen 2 一起處理 │ │ │ ├─ Gen 2? │ │ ├─ 碎片率 50%? │ │ │ ├─ Yes → 清掃 → 壓縮 │ │ │ └─ No → 清掃不壓縮 │ │ └─ 并發(fā)清掃? │ │ ├─ Yes → 后臺清掃 │ │ └─ No → STW 清掃 │ │ │ └─ LOH? │ ├─ 手動(dòng)壓縮? │ │ ├─ Yes → 清掃 → 壓縮STW耗時(shí)高 │ │ └─ No → 清掃不壓縮 │ └─ 碎片率 70%? │ └─ 仍不自動(dòng)壓縮LOH 壓縮代價(jià)過高十三、深入壓縮的引用修復(fù)細(xì)節(jié)13.1 引用修復(fù)的挑戰(zhàn)引用修復(fù)是壓縮階段最復(fù)雜的部分面臨以下挑戰(zhàn)引用來源多樣棧引用、堆引用、靜態(tài)字段、句柄表、GC 內(nèi)部結(jié)構(gòu)每種來源的修復(fù)方式不同。引用目標(biāo)可能移動(dòng)被引用的對象可能在同一段需要修復(fù)、在不同段不需要修復(fù)因?yàn)?GC 內(nèi)部結(jié)構(gòu)已經(jīng)處理了跨段引用。修復(fù)順序必須先修復(fù)所有引用再移動(dòng)對象數(shù)據(jù)。否則修復(fù)時(shí)找不到舊地址。13.2 棧引用的修復(fù)棧引用的修復(fù)是最直接的部分。在壓縮開始前所有應(yīng)用線程被掛起GC 可以安全地掃描每個(gè)線程的棧幀// 棧引用修復(fù)簡化示意 void FixStackReferences(Thread* thread) { for (StackFrame* frame : thread-frames) { for (RefLocation ref : frame-local_variables) { if (ref.is_gc_ref ref.target_in_compressed_segment) { // 更新引用為新地址 *ref.location ref.target-GetNewAddress(); } } for (RefLocation ref : frame-parameters) { if (ref.is_gc_ref ref.target_in_compressed_segment) { *ref.location ref.target-GetNewAddress(); } } } }棧引用修復(fù)的關(guān)鍵點(diǎn)線程必須掛起修復(fù)棧引用時(shí)應(yīng)用線程不能運(yùn)行否則棧內(nèi)容可能被改寫。寄存器也需要修復(fù)除了棧引用掛起時(shí)保存的寄存器快照中的引用也需要修復(fù)。修復(fù)是原地進(jìn)行的直接修改棧幀中的引用值不需要移動(dòng)棧本身。13.3 堆引用的修復(fù)堆引用的修復(fù)比棧引用復(fù)雜得多。存活對象內(nèi)部的引用字段可能指向同一段中的任何對象也可能指向其他段。只有指向同一段的引用需要修復(fù)// 堆引用修復(fù)簡化示意 void FixHeapReferences(heap_segment* segment) { for (uint8_t* obj segment-start; obj segment-old_allocated; ) { if (obj-IsMarked()) { // 遍歷對象的引用字段 MethodTable* mt obj-GetMethodTable(); for (RefField* field : mt-reference_fields) { uint8_t* ref_addr obj field-offset; Object* target *(Object**)ref_addr; // 只修復(fù)指向同一段的引用 if (target segment-start target segment-end) { *(Object**)ref_addr target-GetNewAddress(); } } obj obj-GetSize(); } else { obj obj-GetSize(); // 死亡對象跳過 } } }堆引用修復(fù)的耗時(shí)取決于引用字段的數(shù)量。一個(gè)包含 10 個(gè)引用字段的對象修復(fù)時(shí)需要檢查 10 次、最多更新 10 次。對于引用字段多的對象如字典、列表修復(fù)耗時(shí)可能顯著高于簡單對象。13.4 句柄表的修復(fù)句柄表的修復(fù)相對簡單因?yàn)榫浔硎且粋€(gè)集中管理的數(shù)據(jù)結(jié)構(gòu)// 句柄表修復(fù)簡化示意 void FixHandleTable(heap_segment* segment) { for (HandleEntry entry : handle_table) { if (entry.target segment-start entry.target segment-end) { entry.target entry.target-GetNewAddress(); } } }句柄表的大小通常遠(yuǎn)小于堆修復(fù)耗時(shí)可以忽略不計(jì)。但需要注意固定句柄pinned handle——固定句柄指向的對象在壓縮時(shí)不能被移動(dòng)壓縮算法需要跳過這些對象。十四、深入壓縮算法的變體14.1 傳統(tǒng)壓縮移動(dòng)對象前面描述的壓縮方式是移動(dòng)對象將存活對象從舊地址復(fù)制到新地址。這是最常見的壓縮方式優(yōu)點(diǎn)是實(shí)現(xiàn)簡單、碎片消除徹底。但移動(dòng)對象有一個(gè)問題指針不變性。如果外部代碼持有對象的原始指針如通過GCHandle的固定句柄移動(dòng)對象會導(dǎo)致指針失效。這就是為什么固定句柄指向的對象不能被移動(dòng)。14.2 間接壓縮更新指針另一種壓縮方式是不移動(dòng)對象只更新引用。具體來說不移動(dòng)任何對象數(shù)據(jù)。為每個(gè)對象維護(hù)一個(gè)轉(zhuǎn)發(fā)指針forwarding pointer指向壓縮后的邏輯位置。所有引用指向轉(zhuǎn)發(fā)指針而不是直接指向?qū)ο?。傳統(tǒng)壓縮移動(dòng)對象 舊地址: [ObjA][ObjB][ObjC] → 新地址: [ObjA][ObjB][ObjC][空閑] 引用 → ObjA 引用 → ObjA (新地址) 間接壓縮更新指針 舊地址: [ObjA][ObjB][ObjC] → 邏輯地址: [ObjA][ObjB][ObjC][空閑] 引用 → ObjA 引用 → 轉(zhuǎn)發(fā)指針 → ObjA (邏輯地址)間接壓縮的優(yōu)點(diǎn)是不需要移動(dòng)對象數(shù)據(jù)缺點(diǎn)是引入了額外的間接尋址每次引用都需要多一次內(nèi)存訪問。這在性能敏感的場景中是不可接受的所以 .NET Core GC 不使用這種方式。14.3 部分壓縮只壓縮熱點(diǎn)段還有一種優(yōu)化思路是只壓縮熱點(diǎn)段即分配最頻繁的段而不是壓縮整個(gè)代。這種部分壓縮的策略是識別熱點(diǎn)段分配最頻繁的段。只壓縮熱點(diǎn)段非熱點(diǎn)段保持原樣。非熱點(diǎn)段中的引用指向熱點(diǎn)段時(shí)通過 GC 內(nèi)部結(jié)構(gòu)進(jìn)行間接尋址。這種方式的優(yōu)點(diǎn)是壓縮范圍小、耗時(shí)短缺點(diǎn)是實(shí)現(xiàn)復(fù)雜、間接尋址有性能開銷。.NET Core GC 目前不使用這種方式但在 .NET 7 中引入了更細(xì)粒度的段級壓縮優(yōu)化。十五、結(jié)語清掃和壓縮是 GC 回收階段的兩個(gè)核心操作它們共同決定了 GC 的內(nèi)存效率和暫停時(shí)間。清掃通過 intrusive list 技術(shù)實(shí)現(xiàn)了零分配耗時(shí)穩(wěn)定且可預(yù)測壓縮通過移動(dòng)對象和修復(fù)引用消除了碎片但代價(jià)較高。.NET Core GC 的設(shè)計(jì)哲學(xué)是能不清壓就不清壓必須清壓時(shí)盡量并行化。通過碎片率閾值、budget 自適應(yīng)、后臺 GC、并發(fā)壓縮等技術(shù).NET Core GC 在大多數(shù)場景下將壓縮的 STW 時(shí)間控制在幾毫秒到幾十毫秒之間。理解清掃和壓縮的機(jī)制有助于開發(fā)者寫出更友好的對象生命周期模式——減少碎片累積、降低壓縮觸發(fā)頻率、避免 LOH 碎片問題。在下一章中我們將深入后臺 GC 的實(shí)現(xiàn)07-05看看 .NET Core GC 如何將清掃和壓縮的耗時(shí)進(jìn)一步攤薄到應(yīng)用線程的分配間隙中。