用任何帶 GC 的語言最后都要直面 GC 問題。在以前學習 C# 的時候就被迫讀了一大堆 .NET Garbage Collection 的文檔。最近也學習了一番 golang 的垃圾回收機制,在這里記錄一下。
常見 GC 算法
趁着這個機會我總結了一下常見的 GC 算法。分別是:引用計數法、Mark-Sweep法、三色標記法、分代收集法。
1. 引用計數法
原理是在每個對象內部維護一個整數值,叫做這個對象的引用計數,當對象被引用時引用計數加一,當對象不被引用時引用計數減一。當引用計數為 0 時,自動銷毀對象。
目前引用計數法主要用在 c++ 標准庫的 std::shared_ptr 、微軟的 COM 、Objective-C 和 PHP 中。
但是引用計數法有個缺陷就是不能解決循環引用的問題。循環引用是指對象 A 和對象 B 互相持有對方的引用。這樣兩個對象的引用計數都不是 0 ,因此永遠不能被收集。
另外的缺陷是,每次對象的賦值都要將引用計數加一,增加了消耗。
2. Mark-Sweep法(標記清除法)
這個算法分為兩步,標記和清除。
- 標記:從程序的根節點開始, 遞歸地 遍歷所有對象,將能遍歷到的對象打上標記。
- 清除:講所有未標記的的對象當作垃圾銷毀。

圖片來自 https://en.wikipedia.org/wiki/Tracing_garbage_collection
如圖所示。
但是這個算法也有一個缺陷,就是人們常常說的 STW 問題(Stop The World)。因為算法在標記時必須暫停整個程序,否則其他線程的代碼可能會改變對象狀態,從而可能把不應該回收的對象當做垃圾收集掉。
當程序中的對象逐漸增多時,遞歸遍歷整個對象樹會消耗很多的時間,在大型程序中這個時間可能會是毫秒級別的。讓所有的用戶等待幾百毫秒的 GC 時間這是不能容忍的。
golang 1.5以前使用的這個算法。
3. 三色標記法
三色標記法是傳統 Mark-Sweep 的一個改進,它是一個並發的 GC 算法。
原理如下,
- 首先創建三個集合:白、灰、黑。
- 將所有對象放入白色集合中。
- 然后從根節點開始遍歷所有對象(注意這里並不遞歸遍歷),把遍歷到的對象從白色集合放入灰色集合。
- 之后遍歷灰色集合,將灰色對象引用的對象從白色集合放入灰色集合,之后將此灰色對象放入黑色集合
- 重復 4 直到灰色中無任何對象
- 通過write-barrier檢測對象有變化,重復以上操作
- 收集所有白色對象(垃圾)

圖片來自 https://en.wikipedia.org/wiki/Tracing_garbage_collection
過程如上圖所示。
這個算法可以實現 "on-the-fly",也就是在程序執行的同時進行收集,並不需要暫停整個程序。
但是也會有一個缺陷,可能程序中的垃圾產生的速度會大於垃圾收集的速度,這樣會導致程序中的垃圾越來越多無法被收集掉。
使用這種算法的是 Go 1.5、Go 1.6。
4. 分代收集
分代收集也是傳統 Mark-Sweep 的一個改進。這個算法是基於一個經驗:絕大多數對象的生命周期都很短。所以按照對象的生命周期長短來進行分代。
一般 GC 都會分三代,在 java 中稱之為新生代(Young Generation)、年老代(Tenured Generation)和永久代(Permanent Generation);在 .NET 中稱之為第 0 代、第 1 代和第2代。
原理如下:
- 新對象放入第 0 代
- 當內存用量超過一個較小的閾值時,觸發 0 代收集
- 第 0 代幸存的對象(未被收集)放入第 1 代
- 只有當內存用量超過一個較高的閾值時,才會觸發 1 代收集
- 2 代同理
因為 0 代中的對象十分少,所以每次收集時遍歷都會非常快(比 1 代收集快幾個數量級)。只有內存消耗過於大的時候才會觸發較慢的 1 代和 2 代收集。
因此,分代收集是目前比較好的垃圾回收方式。使用的語言(平台)有 jvm、.NET 。
golang 的 GC
go 語言在 1.3 以前,使用的是比較蠢的傳統 Mark-Sweep 算法。
1.3 版本進行了一下改進,把 Sweep 改為了並行操作。
1.5 版本進行了較大改進,使用了三色標記算法。go 1.5 在源碼中的解釋是“非分代的、非移動的、並發的、三色的標記清除垃圾收集器”
go 除了標准的三色收集以外,還有一個輔助回收功能,防止垃圾產生過快手機不過來的情況。這部分代碼在 runtime.gcAssistAlloc
中。
但是 golang 並沒有分代收集,所以對於巨量的小對象還是很苦手的,會導致整個 mark 過程十分長,在某些極端情況下,甚至會導致 GC 線程占據 50% 以上的 CPU。
因此,當程序由於高並發等原因造成大量小對象的gc問題時,最好可以使用 sync.Pool
等對象池技術,避免大量小對象加大 GC 壓力。
go采用三色標記和寫屏障:
- 起初所有的對象都是白色
- 掃描找出所有可達對象,標記為灰色,放入待處理隊列
- 從隊列提取灰色對象,將其引用對象標記為灰色放入隊列
- 寫屏障監視對象的內存修改,重新標色或放回隊列
關於go的寫屏障(write barrier),可以閱讀最近一篇比較熱的文章《Proposal: Eliminate STW stack re-scanning》。 作者主要介紹下個版本Go為了消除STW所做的一些改進,包括寫屏障的優化方式。
並發的三色標記算法是一個經典算法,通過write barrier,維護”黑色對象不能引用白色對象”這條約束,就可以保證程序的正確性。Go1.5會在標記階段開啟write barrier。在這個階段里,如果用戶代碼想要執行操作,修改一個黑色對象去引用白色對象,則write barrier代碼直接將該白色對象置為灰色。去讀源代碼實現的時候,有一個很小的細節:原版的算法中只是黑色引用白色則需要將白色標記,而Go1.5實現中是不管黑色/灰色/白色對象,只要引用了白色對象,就將這個白色對象標記。這么做的原因是,Go的標記位圖跟對象本身的內存是在不同的地方,無法原子性地進行修改,而采用一些線程同步的實現代價又較高,所以這里的算法做過一些變種的處理。