GC 的前置工作:快速而準確的根枚舉是怎麼做到的? ⚡
在開始標記前,主流運行時都會做一輪“整頓秩序”的前置工作,目標是:以極低停頓時間把 <span style="color:red;">GC 根(Roots)</span> 找全、找準。根通常來自:線程<span style="color:red;">寄存器</span>、各線程<span style="color:red;">棧幀</span>、全局/靜態區、線程本地存儲(TLS)、運行時句柄表(Handles)、以及原生接口持有的引用。
一、前置工作要點(抽絲剝繭)
- 進入安全點:把線程帶到 <span style="color:red;">Safepoint/Handshake</span>,凍結可見狀態,確保棧幀一致。🚦
- 固定指針版圖:JIT/解釋器提前生成 <span style="color:red;">OopMap/Stack Map/GC Info</span>,精確指出“哪幾個槽是指針”。
- 刷寫屏障日誌:把<span style="color:red;">寫屏障</span>緩存沖刷到全局隊列,避免遺漏新寫入的交叉代/跨區引用。
- 收攏分配緩存:封存各線程 <span style="color:red;">TLAB</span> 未用空間,防止“幽靈對象”溜進掃描集。
- 處理原生臨界區:標記 <span style="color:red;">JNI/Pin</span> 區域,必要時保守處理,避免移動。
- 選擇增量語義:按算法切換 <span style="color:red;">SATB</span>(快照起始)或 <span style="color:red;">增量更新</span> 策略,約束屏障行為。🧠
二、GC如何“快”速枚舉根(核心技巧)
- 精確而非保守:藉助 <span style="color:red;">Stack Map/OopMap</span>,只掃真正的指針槽,跳過純值槽;精確性減少無謂訪問。
- 寄存器快照:在安全點保存 <span style="color:red;">寄存器</span> 集合,直接當作根輸入隊列。
- 併發/增量棧掃描:將大部分棧掃描挪到併發階段(如“<span style="color:red;">stack watermark</span>”技術),把 STW 縮短到啓動和收尾兩個極短瞬間。
- 句柄表直取:運行時維護 <span style="color:red;">Handle</span> 區,根即表項,順序遍歷即可。
- 記憶集/卡表:用 <span style="color:red;">Card Table/Remembered Set</span> 快速定位“老指向新”等跨區引用,避免全堆掃描。🔧
- 分代/分區配合:先掃小而熱的集合(年輕代/活躍區),把冷大區域推遲併發處理,降低停頓峯值。
三、不同運行時的“根枚舉”實現差異(原理解釋表)
| 運行時 | 根識別方式 | 棧/寄存器處理 | 屏障語義 | 典型特徵 |
|---|---|---|---|---|
| JVM HotSpot | <span style="color:red;">OopMap</span> 精確標註 | 安全點寄存器快照;支持併發棧處理 | G1/ZGC 常用 <span style="color:red;">SATB</span>;分代含卡表 | <span style="color:red;">TLAB</span>、分區化(Z/Shenandoah)降低停頓 |
| .NET CLR | <span style="color:red;">GC Info</span>(JIT 生成) | Cooperate 模式下安全點棧走訪 | 主要基於寫屏障+記憶集 | Server/Background GC,短暫掛起枚舉根 |
| Go | 編譯器 <span style="color:red;">Stack Map</span> | 啓動短暫停掃描全局與棧根 | 三色標記+寫屏障維護併發正確性 | goroutine 多棧,根枚舉極短 STW |
| V8 | 精確根表 + 句柄區 | STW 快照 + 增量/併發標記 | 增量 <span style="color:red;">marking barrier</span> | 代際/分區化,句柄快速可達 |
四、寫屏障與根枚舉的配合(微型示例+解釋)
偽代碼:寫屏障(“增量更新”風格)
void write_barrier(Object* base, Field* slot, Object* newv) {
*slot = newv; // 1. 真正寫入
if (isOld(base) && isYoung(newv)) // 2. 發現跨代
card_table.mark(slot); // 3. 標記卡表,供根枚舉/掃描使用
}
解釋:
- 第1行:完成用户態寫入,避免額外延遲。
- 第2行:僅當“老→新”跨代時才介入,減少開銷。
- 第3行:把該槽所在卡片標紅,之後 GC 只需枚舉這些卡片,而不是掃整片老年代。
偽代碼:依據 Stack Map 掃描棧根
for (Frame f : thread.stack()) { // 1. 逐棧幀
auto map = f.stackmap_at_safepoint(); // 2. 取精確指針位圖
for (int i : map.pointer_slots()) { // 3. 僅遍歷指針槽
enqueue_root(*(Object**)f.slot(i)); // 4. 入根隊列
}
}
解釋:
- 第1行:按調用棧順序遍歷,保證可重複性。
- 第2行:讀取安全點處的<span style="color:red;">指針位圖</span>,避免猜測。
- 第3行:只訪問被標註為“指針”的槽,跳過整數/浮點。
- 第4行:把找到的引用加入標記起點隊列,供併發標記線程消費。
五、極簡“工作流程”(從停到跑)
- 觸發 GC → 2) 線程到 <span style="color:red;">Safepoint</span>,保存寄存器/棧指針 → 3) 封存 <span style="color:red;">TLAB</span>、刷屏障緩衝 → 4) 用 <span style="color:red;">OopMap/Stack Map</span> 精確枚舉根 + 卡表補充跨區根 → 5) 放開線程,併發繼續標記/清理。
一句話總結:快速根枚舉的關鍵在於把“不確定”變成“已知”。通過 <span style="color:red;">安全點</span> 保證一致性,用 <span style="color:red;">指針地圖</span> 精確定位,用 <span style="color:red;">寫屏障 + 記憶集</span> 限定增量變化,再輔以 <span style="color:red;">併發棧處理</span> 把重活移出停頓區間,GC 就能既“快”又“準”。✅