前言
GC 的設計裏一直有一個很難繞開的矛盾:高吞吐、低延時、低內存佔用,通常很難同時做到。
傳統做法裏,想要更短的停頓,往往要把更多工作搬到併發階段,甚至讓平時的對象訪問承擔更高成本;想要更高的吞吐量,又往往意味着平時路徑成本必須足夠低,於是更多工作會堆到回收階段;想要更低的內存佔用,則又需要更積極地回收、整理和歸還內存。
.NET 的 Satori GC 最有意思的地方不在於它把某個現有方向做得更激進,而在於它在設計上先問了自己:最頻繁的那部分回收,真的必須是全局問題嗎?
GC 在做什麼
對於託管語言來説,GC 要做的事情並不複雜:
- 找出哪些對象仍然存活
- 回收已經不可達的對象
- 必要時移動存活對象,減少碎片並維持分配效率
麻煩主要出在第三件事。對象一旦被移動,所有指向它們的引用都必須保持一致。為了保證這一點,GC 在某些階段需要暫停用户線程。GC 暫停整個程序的行為通常也叫 STW。
分代
GC 之所以不需要每次都掃描整個堆,是因為它利用了一個非常重要的經驗事實:大多數對象死得很快。
因此現代 GC 通常都會做分代:
- Gen 0:最新創建、也最容易很快死掉的對象
- Gen 1:活過幾輪迴收,但還不算特別老的對象
- Gen 2:已經證明自己很能活的長壽對象
分代的意義在於把最頻繁的回收工作限制在最年輕的那一層。只要大多數短命對象都死在 Gen 0,那麼大部分 GC 就不需要碰到更老的對象。
寫屏障和卡表
分代 GC 還有一個關鍵問題:老對象可能引用新對象。
如果下一次只回收 Gen 0,而 GC 不知道某個 Gen 2 對象里正好存着一個指向 Gen 0 對象的引用,就可能把本來還活着的對象誤回收。
所以 GC 需要一種增量記錄機制。最常見的做法是:
- 當程序寫入對象引用時,順手執行一個很小的額外動作,這叫寫屏障
- 把對應內存區域標成“這裏可能需要重點檢查”,這叫卡表
這樣 GC 在回收年輕代時,就不必重新掃描整個老年代,而只需要檢查被寫屏障標髒的那部分區域。
不可能三角
GC 最痛苦的地方在於,高吞吐、低延時、低內存這三個目標經常互相打架。
如果想要低延時,通常就得把更多工作搬到併發階段,讓程序和 GC 儘量同時工作。問題是,併發不是白來的。為了保證併發期間不出錯,往往需要更復雜的屏障、更嚴格的不變量,甚至更多額外空間。
如果想要高吞吐,通常又希望平時路徑儘量成本夠低。也就是説,分配對象、讀取引用、修改引用這些日常動作最好不要成本太高。問題在於,如果日常什麼都不做,很多賬就得在真正回收時一次性算清,這又容易把停頓做長。
如果想要低內存佔用,通常需要更積極地回收、更積極地整理碎片、甚至更積極地把空閒內存還給操作系統。但這些動作本身也要消耗時間和資源。
所以不同 GC,本質上是在回答同一個問題:我到底把成本放在哪裏?
Satori 的目標
Satori 不是一個完全脱離 .NET 運行時的玩具實驗。它直接接在真實運行時接口上,也就是説,它不是紙上談兵,而是真的在現實約束下重新組織回收方式。
它的目標很明確:
- 儘量少調參,最好能自動適應工作負載
- 避免長時間停頓
- 在現實功能約束下保持完整可用
這裏“現實功能約束”很重要。因為一個 GC 如果只是把難題都刪掉,那當然容易寫得漂亮。但 Satori 並不是這麼做的。它仍然要支持內部指針、終結器、弱引用、依賴句柄、可卸載類型、精確根掃描和保守根掃描。
也就是説,Satori 不是迴避真實世界場景,而是在真實世界場景下重新組織 GC 的工作。
Page、Region 和代
Satori 的堆組織方式圍繞 Page 和 Region 這兩個概念展開。
- Page:更大的預留單位
- Region:Page 內部更小、更適合獨立管理的單位
一個大 Page 裏可以包含很多個小 Region,而 GC 可以圍繞這些 Region 做更細粒度的管理決策。
這裏 Region 很重要,因為在 Satori 裏,Region 不只是物理切分方式,它還是:
- 分配單位
- 線程本地所有權單位
- Gen 0 局部回收單位
- 移動和整理的規劃單位
- 空閒內存歸還時的處理單位
很多 GC 雖然也會把堆切成很多小塊,但這些小塊更多隻是方便調度。Satori 不一樣,Region 本身就是它的核心抽象。
Satori 在 Page 和 Region 周圍維護了不少緊湊的元數據。Page 裏會有卡表、Region 映射和更粗粒度的卡組信息;Region 裏則會有位圖,記錄對象的關鍵狀態,例如是否已標記、是否已逃逸、是否被固定。這些元數據決定了 Satori 後面能不能高效地做線程本地回收、逃逸跟蹤和局部壓縮整理。
把 Gen 0 變成本地問題
Satori 最關鍵的想法可以用一句話概括:如果一批新對象幾乎都只在線程內部短暫存在,那為什麼回收它們時一定要把全世界都叫停?
Satori 在分配小對象時,仍然有每線程的快速分配上下文,所以正常情況下分配非常直接。但它比傳統路徑多做了一層非常關鍵的設計:線程不是隨便向全局堆索要空間,而是儘量在自己手裏的 Region 裏分配。
如果當前 Region 還有空間,事情很簡單,繼續往裏放對象。如果當前 Region 快沒空間了,Satori 的第一反應也不是立刻説“這塊用完了,交給全局 GC,再去拿一塊新的”。它會先判斷這個 Region 還適不適合在線程內部自己清理一下。
這背後的前提是現實程序裏非常常見的一種模式:
- 對象是剛創建的
- 對象生命週期很短
- 對象主要只在當前線程裏流轉
如果一塊 Region 滿足這些條件,那麼它的回收就沒有必要上升為全局問題。當前線程可以在有限的範圍內自行完成這塊 Region 的 Gen 0 回收。這就是 Satori 的 thread-local Gen 0。
普通 Gen 0 回收雖然也是在回收新對象,但通常仍然需要站在整個進程角度來想問題:所有線程現在是什麼狀態,老對象裏哪裏可能指向新對象,哪些卡表需要掃描,哪些全局結構需要同步。
Satori 的 thread-local Gen 0 則把問題收縮成:
- 當前線程自己的棧上還拿着哪些對象
- 當前 Region 裏哪些對象已經和外界產生關係
正是因為它把範圍限制得很小,局部回收才變得現實。
逃逸跟蹤
Satori 用逃逸跟蹤來判斷一個 Region 還能不能繼續保持 thread-local 特性。
一個對象一開始可能只在線程內部使用,但之後完全可能逃逸到別處。例如:
- 放進全局緩存
- 掛到別的線程也能訪問到的對象上
- 丟進任務隊列,之後由其他線程繼續處理
一旦發生這種事,這個對象就不再是純線程本地的了,它已經和外界建立了聯繫。這就是逃逸。
線程本地回收成立的前提是這個 Region 裏的大部分對象真的主要屬於當前線程。但如果對象不斷逃逸出去,那這個 Region 的線程局部性就會越來越弱。繼續強行按線程本地方式處理,只會越來越不划算。
所以 Satori 的做法不是假裝線程本地永遠成立,而是顯式跟蹤逃逸。一旦某個對象逃逸,Satori 不只會記住這個對象本身,還會沿着這個對象在當前 Region 裏的引用,把仍然因此對外可達的對象也一併納入考慮。
但如果只是少量對象逃逸,線程本地回收仍然很值得做,因為整體上這個 Region 依然主要由當前線程使用。真正的問題是逃逸越來越多時怎麼辦。
Satori 在這裏設了一個很現實的閾值:當逃逸量大到一定程度時,就不再把這個 Region 當成 thread-local Gen 0,而是把它轉入更全局的代際管理。
這個閾值的意義很明確:
- 如果只要一發生逃逸就立刻放棄 thread-local Gen 0,那麼很多本來仍然很划算的場景也會失去收益
- 如果無論逃逸多少都堅持 thread-local Gen 0,那麼局部回收又會變得越來越不划算
Satori 選擇的是在這兩者之間取一個平衡點。
線程本地回收
理解了 thread-local Region 和逃逸跟蹤以後,就可以看線程本地回收本身了。
它之所以有機會快,不是因為它做的事情更少,而是因為它處理的範圍更小、要看的根更少。
1. 判斷回收的必要性
Satori 不會一看到空間緊張就立刻做局部回收,它會先判斷這次回收值不值得。例如:
- 離上一次局部回收是不是太近了
- 逃逸是不是已經太多了
- 存活對象是不是已經多到不適合本地小掃除
如果這些條件説明回收很可能不划算,那就不做,直接把問題交給更全局的路徑。
2. 從更小的根集合開始標記
如果仍然適合做線程本地回收,那麼它要看的根集合其實很有限,主要是兩類:
- 當前線程棧上仍然指向該 Region 的對象
- 已經逃逸出去、因此對外可達的對象以及它們在 Region 內可達的對象
這和全局 Gen 0 回收相比,差別非常大。全局 Gen 0 回收需要考慮整個進程裏的線程狀態、老年代到年輕代的引用以及各種全局結構;線程本地回收則只需要處理當前線程棧和當前 Region 內已經逃逸出去的那部分對象圖。
3. 規劃整理方案
標記完活對象之後,還不能立刻宣佈結束。因為 Region 裏可能已經出現很多空洞。
這時 Satori 會判斷:
- 活對象有哪些
- 這些活對象搬到哪裏會更緊湊
- 哪些引用之後需要更新
本質上這一步是在把“哪些對象需要留下、它們應該放到哪裏、之後哪些引用要被改寫”先計算清楚。
4. 更新引用和局部壓縮整理
最後才是真正的整理階段。對象如果被搬到了更緊湊的位置,所有指向這些對象的引用也要同步更新。等引用都更新完,Region 內部就可以重新變得連續,後續分配也更順暢。
而且這裏它整理的不是整個堆,而只是一個小 Region。這就是為什麼它有機會把停頓控制得很小。
舉個例子
假設一個網絡請求在線程 A 上處理。這個請求會創建很多短命對象,例如請求上下文、路由匹配結果、解析數據時的中間結果、若干臨時字符串和列表。
在 Satori 裏,這些對象很可能先進入線程 A 當前持有的某個 Region。
如果請求結束後,這些對象都沒有被放進全局緩存,也沒有被交給別的線程,那麼當這個 Region 空間變緊時,線程 A 完全可以先做一次局部回收,把這批短命垃圾清掉,然後繼續在原 Region 裏分配。
如果請求處理中有一部分對象被放進了全局緩存,或者被封裝成任務交給線程池裏的另一個線程,那這些對象就發生了逃逸。
如果只有少量對象這樣做,Satori 仍然可以維持這個 Region 的線程本地特性,因為整體上它依然主要由線程 A 使用。但如果這種共享越來越多,最後逃逸量超過閾值,這個 Region 就不再適合繼續走 thread-local Gen 0 的路線。它會退出私有狀態,轉入更全局的 GC 流程。
這就是 Satori 的基本策略:能在線程本地解決,就儘量在線程本地解決;一旦局部性不再成立,就及時退回全局路徑。
全局 GC
Satori 當然不只是一個線程本地回收器。它同樣有完整的全局 GC 體系,用來處理這些場景:
- 逃逸已經很多的 Region
- 更老的對象
- 全局內存壓力
- Region 之間的移動與整理
Satori 的思路不是隻做局部回收,而是把最頻繁、最適合局部化的那部分工作先拿走,剩下真正需要全局處理的事情,再交給全局 GC。
全局 GC 階段
一次全局 GC 大致還是繞不開幾件事:
- 標記哪些對象還活着
- 規劃哪些 Region 值得整理、哪些 Region 值得移動
- 更新引用
- 必要時移動和壓縮整理
Satori 的目標不是把這些階段全部變沒,而是讓其中儘可能多的部分和應用程序併發進行。它追求的不是永遠絕對零停頓,而是儘量不要把與堆大小成比例的大工作放到阻塞階段裏一起做。
可選移動
這裏恰好能看出 Satori 和另一類低延時 GC 的哲學差異。
很多極低停頓 GC 之所以能把停頓時間壓得非常穩定,是因為它們願意為對象隨時可以併發移動這件事付出更高的日常成本。也就是説,平時每次訪問對象時,程序都要遵守更強的規則。
Satori 沒有默認走這條路。它承認對象移動和壓縮整理很重要,但它不把任何時候都必須無條件併發移動設成鐵律。Region 級別的移動是可選能力,而不是必須永遠打開的總開關。
這背後的取捨其實很清楚:
- 如果你堅持讓任何對象都能隨時併發移動,平時路徑通常成本會更高
- 如果你允許移動變成一種按需使用的能力,平時路徑就更有機會降低成本
Satori 選擇的是後者。這也是它為什麼有機會保住吞吐量。
讓應用線程幫忙推進回收
Satori 還有一個非常實用的設計,就是應用線程協助推進回收。
如果程序分配內存的速度非常快,而併發回收推進得不夠快,那麼垃圾會越積越多。到了最後,就可能不得不用一次很重的阻塞階段去把進度追回來。
Satori 的做法是:當檢測到這種風險時,正在分配內存的線程自己也會順手做一點回收推進工作。
這樣做的好處有兩個:
- 避免分配速度徹底甩開回收速度
- 避免最後只能靠一次很重的停頓把問題補回來
換句話説,Satori 不是把所有併發成本平均攤到每一次訪問上,而是更傾向於在真的快失控的時候才讓應用線程多幫一點忙。這也是它兼顧吞吐量和低延時的關鍵技巧之一。
低內存佔用
低延時 GC 往往更吃內存,這並不奇怪。因為想把停頓做短,通常就意味着:
- 更多併發階段
- 更多緩衝空間
- 更保守的內存保留
- 更多用於協調正確性的元數據
Satori 之所以有機會把內存佔用也壓下來,是因為它不是隻在“怎麼回收”上想辦法,而是在三個方向一起發力。
1. 讓短命垃圾儘量死在年輕階段
如果一個對象本來只在線程內部短暫存在,那麼最理想的情況就是它還沒來得及混進更老的代,就已經在線程本地回收裏死掉了。
這會直接帶來兩個好處:
- 更老的代不會被大量短命垃圾污染
- 後續全局 GC 要處理的活對象總量也會變小
thread-local Gen 0 機制本身,就已經在替低內存佔用打基礎。
2. 顯式把空閒內存還給操作系統
Satori 裏還有一個專門負責整理空閒 Region 並歸還內存的線程。
它不負責主回收邏輯,而是負責做很務實的事情:
- 看哪些 Region 已經空得足夠明顯
- 嘗試合併相鄰的空閒 Region
- 把已經不需要保持提交狀態的內存還給操作系統
更重要的是,它不是一股腦猛衝,而是限速的。它會控制掃描和歸還節奏,避免為了省一點內存反而把系統抖得很厲害。
3. 儘量減少元數據開銷
GC 除了要管對象本身,還要維護很多輔助狀態。如果這些輔助狀態一味膨脹,哪怕對象回收得再好,整體內存佔用也可能不好看。
Satori 在這方面有一個很巧妙的做法:它會盡量複用對象頭附近在 64 位環境下尚未充分利用的空間,去存放一些臨時信息,例如局部整理時需要的鏈接信息或移動後的轉發表信息,而不是動不動就額外開一大堆旁表。
實現不可能三角:同時做到高吞吐、低延時和低內存佔用
現在把前面的設計拼起來,就能比較清楚地看到 Satori 的邏輯了。
為什麼能做到低延時
因為它把最頻繁的年輕對象回收,從全局協調問題變成了線程局部問題。
只要對象大多還停留在線程內部,一次回收只需要處理一個小 Region、當前線程自己的棧以及少量已逃逸對象,而不是讓所有託管線程停下來配合。
為什麼能做到高吞吐
因為它沒有默認選擇讓每次對象訪問成本都變得更高的路線。
Satori 的主要思路不是把強約束鋪滿所有日常路徑,而是:
- 先把最常見的小垃圾局部化
- 再用併發全局 GC 和應用線程協助推進,去兜住更大的壓力
另外,局部回收還能減少短命垃圾升入更老代的機會,這又進一步減輕了後續全局 GC 的掃描和整理壓力,對吞吐量也是加分項。
為什麼還能做到低內存佔用
因為它不是隻會更快回收,還會同時做這些事:
- 讓短命垃圾更早死掉,減少升代污染
- 顯式歸還空閒提交內存
- 控制元數據本身的膨脹
所以 Satori 的低內存佔用,不是某個單點技巧帶來的,而是一整套設計共同作用的結果。
和其他 GC 的對比
這裏把幾類常見 GC 放在一起比較,最重要的不是看誰在哪個 benchmark 裏贏了,而是看它們各自把成本放在哪裏。
Workstation GC 和 Server GC
這兩個 GC 在架構上是同一條線上的不同形態,而不是兩套完全不同的算法。
它們都採用分代設計,依賴寫屏障和卡表來處理老對象指向年輕對象的引用;小對象堆仍然是 Gen 0 / Gen 1 / Gen 2 的分層,老年代回收則會進入更重的標記、規劃和整理階段。
Workstation GC 更適合較輕量、較交互式的場景。它的特點是:
- 更偏向單堆、較剋制的資源使用
- Gen 0 / Gen 1 仍然是前台 STW
- Gen 2 可以做後台回收,但整體並行度有限
它的優點是實現成熟、資源佔用相對剋制;缺點也很明確,面對高併發和高分配率時,吞吐量上限會比較早暴露出來。
Server GC 則是在同樣的基本模型上,把並行能力拉高。它會給每個邏輯處理器準備獨立的堆和更強的 GC 線程資源,因此更適合服務器場景。代價通常是:
- 堆更大
- 線程更多
- 資源佔用更重
但它們有一個共同點沒有變:最頻繁的 Gen 0 / Gen 1 回收本質上仍然是全局 STW 的一部分。Satori 和它們最大的不同點,就在於它先動刀的是這條最頻繁的路徑。
DATAS
DATAS 不是一套全新的 GC 結構,而是疊加在現有 Server GC 之上的策略層。
它解決的是:我應該給這個程序多大的堆預算、多少個堆、怎樣控制 Gen 0 的增長,以及怎樣讓堆大小更貼近真正的長壽數據規模?
也就是説,DATAS 改的是策略,不是機制。它讓現有 Server GC 更聰明,但並不改變“最頻繁的年輕代回收仍然是全局路徑的一部分”這件事。
Satori 則是在解決另一個層次的問題:最頻繁的年輕對象回收,到底能不能不走全局路徑?
G1
G1 也是按 Region 管理堆,但它和 Satori 使用 Region 的目的並不一樣。
G1 的核心思路是:
- 把堆切成很多固定大小的 Region
- 通過跨 Region 引用表記錄 Region 之間的引用關係
- 通過快照式併發標記和寫屏障支持併發標記
- 在回收時把待回收 Region 裏的存活對象複製到別處
也就是説,G1 的 Region 主要服務於全局平衡和停頓目標控制。哪些 Region 該進入回收集合、哪些 Region 該做混合回收、哪些 Region 該參與對象複製,都是圍繞全局調度在轉。
Satori 也使用 Region,但它更進一步:Region 不只是調度單位,還是線程本地所有權、逃逸跟蹤和局部回收的邊界。這是 Satori 和 G1 在設計哲學上的最大區別。
ZGC 和 Shenandoah
這兩類低延時 GC 選擇的是另一條路線。
它們的共同點是:願意在日常路徑裏承擔更強的運行時機制,以換取更穩定的併發移動能力。但它們的具體實現並不一樣:
ZGC 的核心設計是把一部分 GC 狀態直接編碼到指針裏,再配合讀屏障;到了分代 ZGC,又進一步加入了寫入時的額外屏障。它的目標非常明確,就是讓併發移動成為默認能力,從而儘量讓 STW 時間不隨着堆大小一起增長。
Shenandoah 的核心設計則更接近對象間接訪問 + 併發移動壓縮。它會在每個對象上多維持一層間接引用,用來支持併發移動。所以它平時承擔的成本形態和 ZGC 不完全一樣,但本質上仍然是在用更強的運行時機制換取更強的低停頓能力。
Satori 沒有默認採用這條路線。它沒有把對象必須隨時可以併發移動當成前提,而是把移動設成可選能力,同時把最頻繁的 Gen 0 回收局部化。這樣一來,它就不需要像 ZGC 那樣默認接受每次讀對象都要經過額外檢查的成本,也不需要像 Shenandoah 那樣默認接受每個對象都多一層間接訪問的成本。
所以 Satori 和 ZGC、Shenandoah 的差別,不是誰更激進,而是誰把成本放在平時,誰把成本放在回收邊界設計上。
總結
Satori 真正有意思的地方,不在於它是一個併發 GC,而是它重新思考了最頻繁的那部分回收應該怎麼做。
它的核心思路可以濃縮成四句話:
- 短命對象先儘量在線程本地解決
- 一旦對象開始廣泛共享,就及時退回全局路徑
- 全局 GC 儘量併發推進,但不強迫所有日常路徑都為對象移動買單
- 內存佔用不只靠更快回收,還靠主動歸還空閒內存和剋制的元數據設計
如果這條路最終成熟,它帶來的意義可能不只是多了一個實驗性 GC,而是給 .NET 提供了一種非常不同的 GC 設計方向。