我所理解的 Go 的 GC (Garbage Collection) 垃圾回收機制
Go 語言(Golang)作為一款內置運行時的現代編程語言,其垃圾回收(Garbage Collection, GC)機制是開發者理解其性能和行為的關鍵一環。要深入理解 Go 的 GC,我們首先需要明確垃圾回收的核心任務是什么,以及它在設計上需要面對哪些權衡與博弈。
在主流的編程語言內存模型中,程序運行時使用到的內存通常可以劃分為幾個區域,其中最主要的是靜態數據區、棧(stack)和堆(heap)。 棧內存 的管理相對簡單:當一個函數被調用時,系統會為其分配一個棧幀(stack frame),用于存儲局部變量、參數以及函數返回地址等信息;當函數執行完畢并返回時,其對應的棧幀會被自動銷毀,所占用的內存也隨之釋放。這種后進先出(LIFO)的管理方式使得棧上數據的生命周期與函數調用周期緊密綁定,無需開發者手動干預。
然而,并非所有數據都適合存放在棧上。對于那些需要在函數調用結束后依然存在,或者大小在編譯期難以確定的數據,通常會在 堆內存 中分配。對于像 C/C++ 這類沒有內置垃圾回收機制的語言,開發者需要顯式地使用如 malloc 這樣的函數向操作系統申請堆內存,并且在不再需要時通過 free 函數手動釋放。如果忘記釋放,就會導致 內存泄漏 (memory leak),即程序持續占用不再使用的內存,最終可能耗盡系統資源。反之,如果在內存釋放后繼續嘗試訪問它(懸垂指針),則可能導致程序崩潰或未定義行為,即 釋放后使用 (use-after-free)錯誤。
在 Go 語言中,開發者通常不需要像 C/C++ 那樣顯式地“申請”和“釋放”內存來管理對象的生命周期,盡管 Go 提供了如 new 關鍵字(用于分配零值的內存并返回指針)和 make 函數(用于初始化切片、映射和通道等內建類型)來進行內存分配。當這些分配發生在堆上,或者當變量因為 逃逸分析 (escape analysis)——一種編譯器優化技術,用于決定變量是分配在棧上還是堆上——而被分配到堆上時,這些對象的生命周期管理便不再由棧的自動機制控制。
這些脫離了棧作用域控制的堆上對象,其內存何時以及如何被回收,就成了 GC 的核心職責。一個優秀的 GC 機制需要在多個維度上進行優化:
- 高效檢測 :如何快速準確地識別哪些內存是不再被使用的“垃圾”?
- 回收時機與頻率 :過于頻繁的垃圾回收會中斷業務邏輯的執行,影響程序性能(吞吐量和延遲);而不頻繁的回收則可能導致不再使用的內存長時間累積,造成內存膨脹,甚至引發內存溢出(Out Of Memory, OOM)。
- 內存碎片 (memory fragmentation):反復分配和釋放不同大小的內存塊可能導致堆中出現許多不連續的小塊空閑內存,這些碎片雖然總量可能不少,但難以滿足較大的內存分配請求。如何減少內存碎片,提高內存利用率,也是 GC 需要考量的。
本文將圍繞 Go 語言的 GC 設計展開討論,力求在過程中逐步厘清上述問題,并理解 Go 如何在這些挑戰中取得平衡。
Go GC 總體設計
通用的垃圾回收原理主要可以歸納為兩大類:
第一類是基于 引用計數 (reference counting)的機制。這種方法為每個對象維護一個計數器,記錄有多少個引用指向該對象。當一個新的引用指向對象時,計數器加一;當一個引用被移除時,計數器減一。一旦對象的引用計數變為零,就表明該對象不再被任何部分使用,可以立即被回收。Python 語言的 GC 機制中就包含了引用計數,C++ 的智能指針 std::shared_ptr 也是基于這一原理。引用計數的主要優點是對象可以在不再被引用的那一刻立即被回收,內存管理及時。然而,它也存在一些顯著的缺點:一是頻繁更新引用計數會帶來額外的運行時開銷;二是它難以處理 循環引用 (circular references)的問題,即一組對象互相引用,即使它們整體已經與程序的其他部分隔離(即成為垃圾),但它們的引用計數都大于零,導致無法被回收。為了解決循環引用,通常需要引入額外的檢測機制,如 C++ 中的 std::weak_ptr(弱指針)。
第二類是基于 可達性分析 (reachability analysis)的追蹤式垃圾回收(tracing garbage collection)。這類 GC 的核心思想是“可達性即存活性”:程序從一組固定的 根對象 (roots)——通常包括全局變量、當前活躍的函數調用棧上的局部變量和參數,以及 CPU 寄存器中的指針等——開始,沿著指針引用關系遍歷內存中的對象圖。所有從根對象出發能夠訪問到的對象都被認為是“活”對象,其余未被訪問到的對象則被視為“垃圾”,可以被回收。Go 語言的 GC 正是采用了基于可達性分析的并發標記清掃(concurrent mark-and-sweep)算法。
那么,Go 語言是如何基于可達性分析的假設,高效且正確地標記并清掃不再使用的內存呢?這需要我們先了解一些 Go 內存管理和 GC 相關的基礎概念。
在進一步探討 Go GC 的具體流程和技術細節之前(假設讀者對 Go 的 GPM 調度模型已有基本了解),我們先介紹一些核心的術語和組件:
- 頁(page) :在 Go 的內存管理中,操作系統級別的內存頁(通常為 4KB 或 8KB)是內存分配的最小單位之一。Go 的運行時會向操作系統申請大塊內存,然后將這些大塊內存劃分為更小的、固定大小的頁進行內部管理。Go 自身的內存管理系統通常使用 8KB 大小的頁。
- span (mspan) :
mspan是 Go 內存管理的核心數據結構,代表了一段連續的頁。一個mspan可以用來存儲特定 大小類 (size class)的多個小對象,或者一個單獨的大對象。mspan內部維護了關于其所含對象的重要元數據,例如對象的分配狀態、存活狀態(用于 GC 標記)等。 - 大小類(size class) :為了高效管理不同大小的對象分配并減少內部碎片,Go 將小對象(通常指小于 32KB 的對象)歸入不同的固定大小等級,即大小類。每個
mspan通常服務于一種特定的大小類,這意味著它內部所有可分配的槽位(slot)都是同樣大小的。 - mcache :
mcache是一個與每個 P(Processor,對應于 GPM 模型中的 P,代表一個邏輯處理器,用于執行 goroutine)相關聯的本地內存緩存。它為當前 P 上運行的 goroutine 提供快速的小對象分配服務。mcache中包含各種大小類的mspan列表,由于是 P 本地緩存,從mcache分配內存通常不需要加鎖,極大地提高了并發分配的效率。 - mcentral :
mcentral是一個全局的、按大小類組織的數據結構。每個大小類都有一個對應的mcentral實例。當某個 P 的mcache中缺少特定大小類的mspan時,它會向相應的mcentral請求。反之,如果mcache中有多余的空閑mspan,也會歸還給mcentral。mcentral起到了在不同 P 之間平衡mspan資源的作用,它也需要處理鎖來保證并發安全。 - mheap :
mheap是 Go 程序的全局堆內存管理器。它持有所有從操作系統申請到的內存(這些大塊內存被稱為 arenas ,例如在 64 位系統上一個 arena 可能是 64MB),并將這些內存切分成mspan分配給各個mcentral。mheap負責管理堆的整體大小,決定何時向操作系統申請更多內存或何時將未使用的內存歸還給操作系統。所有的大對象(大于 32KB)直接由mheap分配。 - 根對象(root object) :如前所述,根對象是 GC 開始追蹤對象可達性的起點。在 Go 中,這主要包括全局變量區域中的指針、每個 goroutine 棧(goroutine stack)上指向堆對象的指針,以及一些運行時內部結構的指針。
- 存活堆(live heap) :指在一輪 GC 標記階段結束后,被確認為仍然存活(即從根對象可達)的所有堆對象的總大小。
- 堆目標(heapGoal) :
heapGoal是一個動態計算的目標堆大小。當實際的堆內存占用達到或超過heapGoal時,就會觸發新一輪的 GC。它的計算通常基于上一輪 GC 結束后的存活堆大小以及一個由環境變量GOGC(默認為 100)控制的百分比,公式為heapGoal = liveHeap * (1 + GOGC/100)。
基于上述概念,Go 的一輪并發標記清掃 GC 大致可以分為以下幾個主要階段:
首先是 標記準備(Mark Setup) 階段。這是一個短暫的 STW (Stop The World)過程,意味著所有用戶 goroutine 都會被暫停。在此期間,GC 會啟用 寫屏障 (write barrier)——一種在并發標記期間確保數據一致性的關鍵機制,我們稍后會詳細討論。同時,還會進行一些必要的初始化工作,為接下來的并發標記做準備。
接下來是 并發標記(Concurrent Marking) 階段。在這個階段,用戶 goroutine 恢復運行,與 GC 的標記工作并行執行。GC 會從根對象開始,遞歸地遍歷所有可達的對象圖。如果一個對象是可達的,它會被標記。為了提高效率,Go 語言的 goroutine 在進行內存分配時,也可能會被要求輔助 GC 執行一部分標記工作(這被稱為 標記輔助 ,mark assist)。寫屏障在這一階段持續工作,以正確處理用戶 goroutine 在標記過程中對指針的修改。在三色標記法中,對象會從初始的“白色”(未訪問)變為“灰色”(已發現但其引用的對象尚未完全掃描),最終變為“黑色”(已掃描且其所有引用的對象也已掃描或加入掃描隊列)。
標記工作基本完成后,進入 標記終止(Mark Termination) 階段。這同樣是一個 STW 階段,所有用戶 goroutine 再次暫停。GC 會完成所有剩余的標記工作,例如處理一些在并發標記期間被寫屏障記錄下來的指針修改,確保所有存活對象都被正確標記。在此之后,所有未被標記(即仍為“白色”)的對象都被認為是垃圾。
最后是 并發清掃(Concurrent Sweeping) 階段。用戶 goroutine 恢復運行。GC 會在后臺或者在用戶 goroutine 嘗試分配新內存時,逐步回收那些在標記階段被識別為垃圾(白色)的對象所占用的內存空間,將其管理的 mspan 中的對應槽位重新標記為空閑,以便后續分配使用。如果一個 mspan 中的所有對象都被回收,那么整個 mspan 就可以被歸還給 mheap,用于其他目的,甚至可能被歸還給操作系統。
值得注意的是,Go 的 GC 設計目標之一是盡可能縮短 STW 的時間,將大部分工作并發化,以減少對應用程序延遲的影響。關于 STW 的具體細節和其必要性,我們將在后續章節中進一步討論。
三色標記法
為了在并發環境中準確地識別存活對象,Go 的 GC 采用了 三色標記法(Tri-color Marking Algorithm)。這個算法將堆中的對象邏輯上分為三種顏色:
- 白色(White) :對象初始狀態,表示尚未被 GC 訪問到。在一輪 GC 結束時,所有仍然是白色的對象都被認為是垃圾,將被回收。
- 灰色(Gray) :對象已被 GC 發現(即從根可達),但其內部的指針(即它所引用的其他對象)尚未被完全掃描。灰色對象被視為一個臨界狀態,它們存在于一個待處理的工作隊列中。
- 黑色(Black) :對象已被 GC 發現,并且其內部所有指針都已經被掃描完畢(即它引用的對象要么也變成了灰色或黑色,要么是
nil)。黑色對象表示 GC 已經處理完畢,并且在當前 GC 周期內是存活的。
垃圾回收進行“染色”(標記)時,其標記信息(例如三色標記法中的顏色)是針對 mspan 中具體的對象或對象槽位(slot)的。 對于包含小對象的 mspan,它內部有一個位圖(bitmap)或者類似的數據結構,用于記錄每個小對象的標記狀態。因此,雖然 mspan 是頁的集合,但 GC 標記的精度是對象級別的,這些標記信息存儲在 mspan 的元數據中。
GC 的標記過程可以想象成一個從根對象開始向外“染色”的過程:
- 初始時,所有對象(除了少量特殊對象)都被認為是白色的。
- GC 從所有根對象開始,將它們標記為灰色,并放入一個待掃描的灰色對象集合(工作隊列)中。
- GC 從灰色對象集合中取出一個灰色對象,掃描它引用的所有其他對象:
對于每一個它引用的白色對象,將其標記為灰色,并放入灰色對象集合中。
當這個灰色對象的所有引用都被掃描完畢后,該對象自身被標記為黑色。
- 重復步驟 3,直到灰色對象集合為空。
- 此時,所有仍然是白色的對象就是不可達的垃圾,可以被回收。
那么,為什么需要三種顏色而不是簡單的兩種(例如,白色和黑色)呢?在非并發的 GC 中,兩種顏色確實足夠。但在并發 GC 中,應用程序的 賦值器 (mutator,即用戶 goroutine)會與 GC 的 收集器 (collector)同時運行。我們需要灰色這個中間狀態配合后問提到的寫屏障來保證正確性。
為了防止這種“丟失對象”的情況,三色標記法引入了灰色狀態,并配合 寫屏障 (write barrier)來共同維護一個關鍵的不變性。這個不變性通常是: 不允許黑色對象直接指向白色對象,除非該白色對象能夠通過其他灰色對象間接可達,或者該白色對象本身即將被標記為灰色。 更嚴格地說,Go 的寫屏障努力確保在并發標記期間,不會出現一個黑色對象直接指向一個白色對象,而這個白色對象又沒有其他路徑可以被灰色對象發現。
如果賦值器試圖創建一個從黑色對象指向白色對象的指針,寫屏障就會介入。例如,當執行 objBlack.field = objWhite 這樣的操作時,寫屏障會確保 objWhite(或與其相關的對象)被標記為灰色,從而保證它不會被遺漏。例如,在一個掃描順序可能導致問題的場景中:GC 線程掃描了對象 X 并將其標記為黑色。隨后,用戶 goroutine 修改了 X 的一個字段,使其指向了一個白色對象 Y。如果沒有寫屏障,Y 可能永遠不會被掃描。有了寫屏障,這次寫入操作會被攔截,寫屏障會將 Y 標記為灰色,放入待處理隊列,確保其后續會被掃描。
Go 的寫屏障主要有兩種形式(或其變種/組合): 插入寫屏障 (insertion write barrier)和 刪除寫屏障 (deletion write barrier),它們共同服務于維護上述三色不變性。
刪除寫屏障、插入寫屏障以及混合屏障
寫屏障是 Go 并發 GC 的核心機制之一,它通過在指針寫入操作前后插入少量額外代碼來實現。當用戶 goroutine(賦值器)修改堆上對象的指針字段時,這些由編譯器自動插入的屏障代碼會被執行,以通知 GC 發生了可能影響對象可達性的變化。
插入寫屏障(Dijkstra-style Insertion Write Barrier)
插入寫屏障的核心思想是:當一個指針從對象 A 指向對象 B 時(A.ptr = B),如果對象 B 是白色的,那么寫屏障會強制將對象 B 標記為灰色。這樣做的目的是防止一個已經被掃描過的黑色對象(比如 A,如果它已經是黑色的)指向一個尚未被發現的白色對象 B,從而避免 B 被遺漏。
具體來說,當賦值器執行 *slot = ptr (其中 slot 是堆上一個指針的地址,ptr 是新的指針值)時:
- 如果
ptr指向的對象是白色的,則將其標記為灰色并加入 GC 的工作隊列。 - 然后才實際執行
*slot = ptr的賦值。
這種屏障確保了所有新建立的引用,如果指向的是白色對象,那么該白色對象都會被“保護”起來(變為灰色),等待 GC 的后續處理。Go 在早期版本中主要依賴這種類型的屏障。它的優點是實現相對簡單,能夠保證正確性(不丟失存活對象)。但它可能導致一些已經死亡的對象(在被標記為灰色后,又因為其他引用的斷開而變得不可達)仍然被當作存活對象處理,直到下一輪 GC,這被稱為“浮動垃圾”(floating garbage)。
刪除寫屏障(Yuasa-style Deletion Write Barrier)
刪除寫屏障關注的是被覆蓋的舊指針。當一個指針字段 A.ptr 原本指向對象 B,現在要改為指向對象 C(或者 nil)時(oldVal = A.ptr; A.ptr = C),刪除寫屏障會在賦值發生 之前 對 oldVal(即 B)進行處理。如果 oldVal 指向的對象是白色的,并且它可能因為這次引用斷開而失去唯一的灰色或黑色先行者,那么刪除寫屏障會將 oldVal 指向的對象標記為灰色。
這種屏障主要用于增量式或并發 GC 中,以確保在并發刪除引用時,不會意外地使一個本應存活的對象(因為其他路徑仍然存在,只是 GC 尚未掃描到)變成不可達。它在處理并發場景下對象圖的動態變化時,能夠提供更強的保障,有助于提高回收的精度。
混合寫屏障(Hybrid Write Barrier)
Go 從 1.8 版本開始引入了一種 混合寫屏障 (hybrid write barrier)。這種屏障結合了插入寫屏障和刪除寫屏障的特性,其主要目的是 消除在標記終止(Mark Termination)STW 階段對所有 goroutine 棧進行重新掃描的需求,從而顯著縮短 STW 的時間。
混合寫屏障的工作方式大致如下:
- 堆指針寫入 :當向堆對象的指針字段寫入新值時(
*slot = ptr),屏障會先將*slot原本指向的對象(如果它是白色的)標記為灰色。這類似于刪除寫屏障的思路,即保護即將被覆蓋的指針所指向的對象。然后才執行指針的寫入。 - 棧指針寫入:棧上的指針寫入不使用這種復雜的屏障,因為棧會在標記階段被特殊處理(例如,初始掃描和可能的STW期間的最終處理)。
混合寫屏障的核心保證是:任何在并發標記期間被黑色對象引用的白色對象,都會通過某種方式被 GC 發現。具體來說:
- 如果一個黑色對象在堆上創建了一個指向白色對象的指針,那么被該指針槽位 覆蓋 的舊對象(如果是白色)會被著色為灰色。新指向的白色對象
ptr如果沒有其他路徑,其可達性依賴于其宿主對象(即包含slot的對象)的狀態。如果宿主是黑色,且ptr是白色,這個場景正是寫屏障要處理的。混合寫屏障通過“保護舊值”的方式,間接保證了當宿主對象被掃描時,即使新值ptr是白色,也不會立即丟失。 - 棧上的指針變化由棧掃描覆蓋(主要是在 GC 開始時的 標記準備 STW 階段 ,會對所有 goroutine 的棧進行掃描,這是識別從棧出發的根引用的必要步驟);在 標記終止 STW 階段 ,不再需要對所有 goroutine 的棧進行第二次完整的、地毯式的掃描。
這其中,最核心的原因是 混合寫屏障加強了對從棧流向堆的指針的追蹤能力 。過去,如果一個指針只存在于棧上,并且在并發標記期間棧發生了復雜的變化,GC 可能會“跟丟”這個指針。引入混合寫屏障后,如果這個指針要“逃逸”到堆上并可能導致問題(比如被一個黑色堆對象引用),混合寫屏障會介入。
因此,不再需要通過一個全局的、重量級的“所有棧都停下來重新徹底掃描一遍”的操作來“查漏補缺”。系統相信,通過初始的棧掃描,結合并發標記期間混合寫屏障在堆上的工作,以及運行時對 goroutine 棧的常規管理,已經足以保證所有存活對象都能被找到。
通過這種設計,Go 的混合寫屏障確保了在并發標記期間,即使賦值器在堆上創建了從黑色對象到白色對象的引用,或者刪除了這樣的引用,三色不變性也能被維護,而無需在標記結束時進行昂貴的完整棧重掃。這使得 Go 的 GC 停頓時間,特別是標記終止階段的 STW,能夠控制在非常低的水平。
STW, Stop the World
現在我們對 Go GC 的流程和寫屏障有了更深入的理解,可以再次審視 STW(Stop The World) 在 GC 周期中的角色、發生的時機以及其必要性。
Go 的 GC 周期中包含兩次主要的 STW 停頓:
標記準備階段(Mark Setup)的 STW
- 何時發生 :在 GC 周期開始時。
- 為何需要 :此階段非常短暫。它的主要任務是啟用寫屏障,并準備 GC 所需的各種數據結構。暫停所有 P(邏輯處理器)和用戶 goroutine 是為了確保在一個一致的程序狀態下安全地開啟寫屏障。如果在此期間用戶 goroutine 仍在運行并修改指針,那么寫屏障的啟用過程可能會出現競態條件,或者遺漏在屏障完全生效前發生的指針修改。此外,此階段還會進行一些根對象的初始掃描準備工作,比如掃描全局變量和準備掃描 goroutine 棧(現代 Go GC 對棧的初始處理更輕量)。
- 如果不 STW 會怎樣 :無法保證寫屏障的一致啟用,可能導致在并發標記初期就丟失對象。根集合的快照也可能不準確。
標記終止階段(Mark Termination)的 STW
- 何時發生 :在并發標記工作基本完成之后,清掃開始之前。
- 為何需要 :此階段用于完成所有并發標記的收尾工作。例如,處理在并發標記期間由寫屏障記錄下來的、需要進一步檢查的指針。在引入混合寫屏障之前,這個階段一個非常重要的任務是重新掃描所有 goroutine 的棧,因為在并發標記期間,棧上的指針可能發生了變化,而這些變化可能沒有被寫屏障完全覆蓋(早期寫屏障主要針對堆指針)。引入混合寫屏障后,棧重掃的負擔大大減輕,甚至在很多情況下被消除了,使得這個 STW 時間顯著縮短。此 STW 確保了在進入清掃階段之前,所有存活對象都已被正確標記為黑色,所有垃圾對象都保持白色。這也是一個同步點,確保所有 GC 工作線程和輔助標記的 goroutine 都已完成其標記任務。
- 如果不 STW 會怎樣 :如果并發標記結束后不進行最終的同步和檢查,可能會有少量本應存活的對象因為并發修改而未被標記,導致被錯誤回收。或者,某些由寫屏障延遲處理的任務沒有完成,標記結果不完整。
為什么需要兩次 STW,而不是一次長時間的 STW?
如果 GC 從頭到尾都是 STW,那么應用程序的響應會受到極大影響,長時間的停頓對于許多在線服務是不可接受的。Go GC 的設計哲學是將盡可能多的工作并發化,只在絕對必要的同步點進行短暫的 STW。這兩次 STW 將整個 GC 周期劃分為幾個階段,使得主要的標記工作(最耗時)可以與用戶 goroutine 并行執行。
GC Pacer 與 heapGoal
Go 的 GC 觸發和步調是由一個稱為 Pacer 的機制來控制的。Pacer 的目標是根據 GOGC 環境變量(默認為 100)設定的比率來決定何時啟動下一輪 GC。GOGC=100 意味著當堆大小增長到上一次 GC 結束后存活堆大小的兩倍時,就應該觸發新的 GC。計算公式為:heapGoal = heap_live * (1 + GOGC/100)其中 heap_live 是上一輪 GC 結束時測得的存活對象總大小。Pacer 會監控當前的堆分配情況,并在接近 heapGoal 時啟動 GC,力求平滑地完成回收任務,避免突兀的性能抖動。
GC 工作的公平性與標記輔助
為了確保 GC 工作能夠及時完成,尤其是在分配速率非常高的 goroutine 存在的情況下,Go 引入了 標記輔助(Mark Assist) 機制。當一個用戶 goroutine 嘗試在堆上分配新內存時,如果此時 GC 的標記階段正在進行中,并且 GC 的進度落后于預期的步調(即分配速度超過了 GC 標記的速度),那么這個正在分配內存的 goroutine 會被要求“幫助”GC 完成一部分標記工作,然后才能繼續其自身的內存分配。這種機制確保了所有 goroutine 都為 GC 貢獻力量,防止某些高分配率的 goroutine “餓死”GC 或導致堆內存失控增長。這不是嚴格意義上 P 之間的公平性,而是確保分配者也承擔 GC 責任,從而間接促進整體進度。GC 的后臺標記任務則由專門的 GC worker goroutine(通常占 GOMAXPROCS 的 25%)執行,它們之間也存在工作竊取機制以平衡負載。
更加細致的 GC 流程
為了更清晰地理解 Go 的垃圾回收過程,我們將其細化為一系列明確的階段和狀態。Go 的運行時內部使用如 _GCoff、_GCmark、_GCmarktermination 等狀態來管理 GC 周期。以下是一輪典型 GC 周期的詳細流程:
**當前狀態: _GCoff (GC 關閉)**
- 描述: GC 當前未激活。應用程序 (mutators) 正常運行。
- 內存分配: 新分配的對象被標記為白色。
- 后臺清掃: 上一個 GC 周期的清掃工作可能仍在并發進行中 (由 gcBgMarkWorker 或按需觸發)。
一個 mspan 必須先被清掃干凈 (即回收其中上一周期標記為白色的對象),其空閑槽位才能用于新的分配。
一旦一個 mspan 被清掃過,在本輪 GC 的后續掃描標記完成前,它不會被再次清掃。
- 觸發條件: 當堆分配的總大小達到根據 GOGC 計算出的 heapGoal 時,準備啟動新一輪 GC。
▼ ▼ ▼ GC 觸發 ▼ ▼ ▼
**階段 1: 標記準備 (Mark Setup) - STW (Stop The World)**
- 運行時狀態轉換: 從 _GCoff 進入 _GCmark 階段。
- 動作:
1. STW 開始: 暫停所有用戶 goroutine 和 P。
2. 設置 gcphase = _GCmark。
3. 啟用寫屏障 (write barrier): 確保并發標記期間對象指針修改的正確性。
4. 啟用標記輔助 (mark assists): 分配內存的用戶 goroutine 可能需要協助 GC 進行標記。
5. 掃描根對象:
- 掃描所有全局變量中的指針。
- 掃描所有當前活躍 goroutine 的棧上的指針 (現代 Go 通過混合屏障等優化,此步驟可能更輕量或分階段)。
- 將從根對象直接可達的對象標記為灰色,并放入待處理工作隊列。
6. STW 結束: 恢復所有用戶 goroutine 和 P。
- 后續: GC 工作 goroutine (通常為 GOMAXPROCS 的 25%) 開始并發標記。用戶 goroutine 繼續執行,并在分配時可能參與標記輔助。
**階段 2: 并發標記 (Concurrent Marking)**
- 運行時狀態: gcphase = _GCmark。
- 描述: 這是 GC 工作的主要階段,與用戶 goroutine 并發執行。
- 動作:
1. GC 工作 goroutine 和標記輔助的 goroutine 從工作隊列中取出灰色對象。
2. 掃描灰色對象的指針字段:
- 對于其引用的每個白色對象,將其標記為灰色并加入工作隊列。
3. 當一個灰色對象的所有指針字段都被掃描后,將其標記為黑色。
4. 寫屏障持續工作: 攔截用戶 goroutine 對指針的修改,以維護三色不變性 (例如,防止黑色對象指向白色對象而未將白色對象置灰)。
5. 持續處理工作隊列,直到隊列為空或滿足特定終止條件。
**階段 3: 標記終止 (Mark Termination) - STW (Stop The World)**
- 運行時狀態轉換: 從 _GCmark 進入 _GCmarktermination 階段。
- 動作:
1. STW 開始: 再次暫停所有用戶 goroutine 和 P。
2. 設置 gcphase = _GCmarktermination。
3. 完成剩余標記工作:
- 處理所有寫屏障記錄的待處理指針。
- 重新檢查某些根集合或特定條件下的對象,確保沒有遺漏 (混合屏障顯著減少了棧重掃的需求)。
- 確保所有可達對象均已標記為黑色。此時,所有仍為白色的對象被確認為垃圾。
4. 禁用寫屏障。
5. 禁用標記輔助。
6. 準備清掃階段: 初始化清掃所需的狀態。
7. STW 結束: 恢復所有用戶 goroutine 和 P。
**階段 4: 并發清掃 (Concurrent Sweeping)**
- 運行時狀態轉換: 從 _GCmarktermination 回到 _GCoff (清掃在 _GCoff 狀態下進行)。
- 描述: 回收在標記階段被識別為垃圾 (白色) 的對象所占用的內存。此階段與用戶 goroutine 并發執行。
- 動作:
1. 設置 gcphase = _GCoff。
2. 清掃器 (sweeper) 開始工作:
- 后臺 GC 工作 goroutine (gcBgMarkWorker) 會主動遍歷所有 mspan,回收其中標記為白色的對象,并將其占用的槽位標記為空閑。
- 按需清掃: 當用戶 goroutine 嘗試分配內存,且所需的 mspan 尚未被清掃時,該 goroutine 可能會先觸發對該 mspan 的清掃。
3. 對于一個 mspan:
- 一旦被清掃,其內部的白色對象所占用的空間被回收。
- 該 mspan 的空閑槽位可立即用于新的對象分配 (新分配的對象為白色)。
- 此 mspan 在下一次 GC 的標記階段完成之前,不會被再次清掃 (即,當前分配到其中的新白色對象不會被本輪 GC 的清掃器誤回收)。
4. 更新堆的統計數據 (如 live heap 大小)。
5. 根據新的 live heap 大小和 GOGC 設置,計算下一次 GC 的 heapGoal。
▼ ▼ ▼ GC 周期結束,系統返回 _GCoff 狀態,等待下一次觸發 ▼ ▼ ▼關于 _GCoff 階段新分配對象的處理
在 _GCoff 階段,GC 的標記工作并未激活。當用戶 goroutine 在此階段申請內存并分配新對象時,這些新對象默認被標記為 白色 。
你可能會問,如果這些新分配的白色對象在 _GCoff 期間(此時上一周期的清掃可能還在進行),它們會不會被錯誤地清掃掉?答案是 不會 。原因在于:
- 清掃針對的是上一周期的垃圾 :GC 的清掃階段是針對 上一輪 標記結束后被識別為白色(即垃圾)的對象。例如,第 N 輪 GC 的清掃器只會回收在第 N 輪標記中最終仍為白色的對象。
mspan先清掃后分配 :一個mspan(內存管理的基本單元)在能夠用于分配新的對象之前,必須確保它已經被上一輪 GC 的清掃器處理完畢。也就是說,如果一個mspan中含有第 N 輪 GC 判定的垃圾,這些垃圾必須被清理掉,相應的槽位變為空閑,然后這個mspan才能用來分配第 N+1 輪(或_GCoff期間)的新對象。- 新對象等待下一輪 GC :在
_GCoff期間分配到已清掃mspan上的新白色對象,它們是“干凈”的,它們是否存活將由 下一輪(即第 N+1 輪)GC 的標記階段來判斷。如果它們在第 N+1 輪標記開始時仍然存活(即從根可達),它們會被標記為灰色,然后黑色;如果不可達,則在第 N+1 輪標記結束后它們依然是白色,并將在第 N+1 輪的清掃階段被回收。
總結來說, “對于一個 mspan,需要先清掃完(上一周期的垃圾),再用于(為新對象)分配。下次 GC 掃描標記完成前,(這個 mspan 中新分配的對象)不會被再次清掃” 。這個機制確保了新分配的對象不會被當前(或剛結束的)GC 周期的清掃過程錯誤回收。
內存碎片與對象復用
長時間運行的程序,尤其是那些頻繁進行內存分配和釋放的程序,常常會面臨 內存碎片 (memory fragmentation)的問題。內存碎片分為內部碎片(因分配單元大于實際需求導致的空間浪費)和外部碎片(空閑內存被分割成許多不連續的小塊,導致雖然總空閑內存足夠,但無法滿足較大的單次分配請求)。
一些其他語言的 GC,例如 Java 中的 HotSpot 虛擬機,采用了 分代收集 (generational collection)的策略。這基于一個常見的“弱分代假說”:大多數對象在年輕時(剛分配后不久)就會死亡,而存活時間較長的對象則傾向于繼續存活更久。因此,Java 將堆分為新生代和老年代。新生代中的對象回收頻繁且通常采用 復制算法 (copying algorithm),這種算法在回收的同時會將存活對象復制到另一塊內存區域,從而自然地整理了內存,消除了碎片。但復制算法的代價是需要額外的空間(通常是可用空間的一半),并且移動對象也會帶來開銷。老年代則采用標記-清除或標記-整理算法。
Go 語言的 GC 并沒有采用分代收集,也沒有使用會移動對象的整理(compacting)算法。這意味著 Go 的 GC 本身不直接通過移動對象來消除外部碎片。然而,Go 的內存分配器通過一系列精巧的設計來緩解內存碎片問題,并促進內存的高效復用:
多級緩存與大小類(Size Classes & Caching Hierarchy)
- Go 的內存分配器為小對象(通常 < 32KB)預定義了大約 67 個 大小類 (size classes)。當程序請求分配一個小對象時,分配器會將其向上取整到最接近的大小類進行分配。這有助于標準化內存塊的大小,減少內部碎片。
- 內存分配通過一個分層緩存系統進行:
mcache(P 本地緩存) ->mcentral(全局大小類緩存) ->mheap(全局堆)。 mcache為每個 P 提供了各種大小類的mspan列表。當 goroutine 在其 P 上分配小對象時,它可以直接從mcache中獲取對應大小類的空閑槽位,這個過程通常是無鎖的,非常迅速。當對象被釋放時,其占用的槽位也會返回到mcache中對應的mspan,以便快速復用。這種設計極大地提高了小對象的分配和回收效率,并鼓勵了內存的本地化復用。
mspan 的管理與復用
- 一個
mspan管理著一連串相同大小類的對象槽位。當一個mspan中的所有對象都被 GC 回收后,這個mspan就變為空閑狀態。 - 空閑的
mspan會被mcentral回收,并可以被重新用于服務于相同大小類的分配請求,或者如果mheap需要,在某些情況下,一個完全空閑的mspan(由多個頁組成)的內存頁甚至可以被拆分或重新組合用于其他大小類或大對象的分配,雖然這不如直接復用高效。
大對象直接分配
對于大對象(> 32KB),它們直接從 mheap 中分配,通常會獨占一個或多個 mspan。當這些大對象被回收后,它們所占用的 mspan 也會被釋放回 mheap。
向操作系統歸還內存(madvise)
- 當
mheap中積累了大量連續的空閑頁(通常是由于大量mspan被完全釋放)時,Go 的運行時會周期性地掃描并嘗試將這些未使用的物理內存歸還給操作系統。這是通過調用操作系統提供的機制(如 Linux 上的madvise系統調用,使用MADV_DONTNEED或MADV_FREE等參數)來實現的。這并不會改變進程的虛擬地址空間大小,但會減少其實際占用的物理內存,從而降低整體系統內存壓力。雖然這不是內存整理,但它能有效減少程序在空閑時的內存足跡。
通過上述機制,Go 語言在不移動對象(避免了移動帶來的復雜性和開銷)的前提下,力求通過精細化的內存管理、高效的本地緩存和及時的內存歸還,來最大限度地減少內存碎片的影響并提高內存的復用率。特別是對于小對象的分配和回收,Go 的性能表現非常出色。
內存感知型垃圾回收的探索:Green Tea GC
隨著 CPU 核心數量的增加和內存架構(如 NUMA,Non-Uniform Memory Access)的日益復雜,內存訪問的延遲和帶寬正成為高性能系統的主要瓶頸。傳統的垃圾回收算法,包括 Go 此前版本的并行標記算法,在進行對象圖遍歷時,其內存訪問模式往往缺乏良好的 空間局部性 (spatial locality,即連續訪問物理上相鄰的內存)和 時間局部性 (temporal locality,即短時間內重復訪問同一內存區域),也未充分考慮內存拓撲結構。這會導致大量的 CPU 周期浪費在等待內存訪問上(即所謂的 memory stalls)。據統計,在 Go 的傳統 GC 掃描循環中,超過 35% 的 CPU 周期可能僅僅是由于內存停頓造成的。
為了應對這一挑戰,Go 團隊一直在探索更具內存感知能力的垃圾回收算法。在 Go 語言的 Issue #73581 中,一個名為 Green Tea ?? Garbage Collector 的新設計被提出,并計劃作為 Go 1.25 版本中的一個可選實驗性功能。
Green Tea GC 的核心思想與優勢
Green Tea GC 的核心理念是: 與其掃描單個孤立的對象,不如按更大的、連續的內存塊(spans)進行掃描。
解決的問題 : 傳統 GC 的對象圖遍歷可能在內存中“跳躍”,導致緩存未命中率高。Green Tea 試圖通過按塊處理來改善內存訪問模式。
原理簡介
- GC 的共享工作隊列不再追蹤單個待掃描的對象,而是追蹤內存 塊(spans) 。在原型實現中,這些主要是指包含小對象(例如,最大 512 字節)的 8KB 大小的
mspan。 - 當 GC 發現一個指向某個塊內對象的指針時,它會標記該對象“需要掃描”(例如,在該塊的元數據中設置一個“灰色位”),并且如果該塊尚未被加入工作隊列,則將其加入。
- GC 工作線程從隊列中取出一個塊。核心假設是,在該塊等待被處理的過程中,可能會有更多的位于該塊內的對象被其他并發掃描的路徑發現并標記為“需要掃描”。
- 當工作線程實際處理這個塊時,它可以一次性掃描該塊內所有被標記為“需要掃描”的對象。由于這些對象物理上位于同一個內存塊(span)中,連續處理它們將顯著提高空間局部性,從而提升緩存利用率并減少內存訪問延遲。
帶來的優勢
- 改善局部性 :通過集中處理同一內存塊中的對象,大幅提升了內存訪問的局部性,減少了緩存不命中和 CPU 等待內存的時間。
- 降低開銷 :與每個小對象都入隊出隊相比,按塊(span)進行調度和管理,均攤了這部分開銷。
- 更好的并發擴展性 :工作分配基于改進的、類似 goroutine 調度器使用的分布式工作竊取隊列,追蹤的是塊而非海量的小對象,這有助于減少全局鎖的競爭。
原型實現細節
Green Tea 的原型主要針對 小對象 span 。這是因為小對象的掃描本身耗時很短,傳統 GC 為每個小對象進行獨立調度和元數據訪問的開銷占比更高,因此從按塊掃描中獲益最大。較大的對象則可能繼續使用原有的掃描算法。
為了支持這種按塊掃描,每個 span 會存儲其內部對象的標記位(例如,每個對象對應一個灰色位和一個黑色位)。當 GC 掃描到一個指向小對象的指針時,它會設置該對象在其 span 內的灰色位。如果這個 span 尚未在掃描隊列中,它會被加入。當一個 span 從隊列中被取出進行處理時,GC 會查找該 span 內所有灰色但非黑色的對象,掃描它們,然后將它們標記為黑色。
為了優化只有一個對象需要掃描的 span(這種情況下新算法的額外開銷可能使其比老算法慢),Green Tea 實現了一些技巧:比如追蹤最初導致 span 入隊的那個對象作為“代表對象”,并使用一個“命中標志”來指示該 span 在排隊期間是否有其他對象也被標記。如果取出 span 時命中標志未設置,GC 就可以直接掃描代表對象,避免遍歷整個 span 的元數據。
Green Tea GC 的目標是使 Go 的垃圾回收器更加“內存感知”,雖然它可能不是完全以內存為中心重新設計的,但它確實朝著更有效地利用現代計算機內存層次結構邁出了重要一步。實驗結果表明,這種新算法在 GC 密集型工作負載上顯著降低了 GC 的 CPU 成本,并為未來進一步的優化(如使用 SIMD指令加速掃描)打開了大門。

























