[譯]Go:垃圾回收器是怎樣標記內存的?
本文基於Go 1.13
Go的垃圾回收器負責將那些不會再使用的被佔用的內存進行回收。實現的算法是併發的三色標記法以及掃描收集器。我們會看一下標記階段的細節以及不同顏色的使用。
你可以在這篇 文章 中閱讀到不同類型的垃圾回收機制。
標記階段
這個階段主要是掃描內存來確認哪一些內存塊是仍然被使用,在哪一些內存塊是可以被回收的。
然而,由於垃圾回收跟我們的Go程序是併發運行的,所以需要有個方法在掃描進行的同時監測內存的變化。爲了解決這個問題,這裏會用到寫屏障算法並允許Go去跟蹤任何一個指針的變化。實現寫屏障唯一途徑是將程序暫時停止一小段時間,我們稱爲“全世界靜止” (Stop the World)。
在程序運行的開始階段,每一個processor都有一個負責標記內存的worker。
然後,一旦根節點被入隊等待執行,標記階段就會開始對內存進行遍歷和着色。
下面讓我們看個小的例子,這個程序允許我們能夠遵循標記階段所完成的步驟
type struct1 struct { a, b int64 c, d float64 e *struct2 } type struct2 struct { f, g int64 h, i float64 } func main() { s1 := allocStruct1() s2 := allocStruct2() func () { _ = allocStruct2() }() runtime.GC() fmt.Printf("s1 = %X, s2 = %X\n", &s1, &s2) } //go:noinline func allocStruct1() *struct1 { return &struct1{ e: allocStruct2(), } } //go:noinline func allocStruct2() *struct2 { return &struct2{} } 複製代碼
由於結構體 subStruct
內部不包含任何指針,所以會儲存在一個沒有指向另一個對象的內存塊中:
這會讓垃圾清理器更加容易因爲當他進行內存掃描的時候不需要去掃描這些內存塊。
一旦分配完成,我們的程序會強制讓垃圾回收運行一個週期,下面是工作流:
內存掃描
垃圾回收器從棧開始,會追隨指針去遞歸遍歷內存。那些被標記爲 no scan
的內存塊會讓掃描停止繼續掃描。然而,這個過程不是在一個goroutine中完成的。每個指針會在一個垃圾回收器工作池中入隊,被goroutine鎖消耗出隊,出隊後找到新的指向再將其重新在垃圾回收器工作池中入隊,直至遇到 no scan
爲止。
垃圾回收器工作池
着色
worker現在需要有一個途徑去跟蹤哪些內存已經被掃描過而哪些還沒有被掃描、垃圾回收器使用三色標記法如下:
- 最開始階段所有對象標記成白色
- 根對象(堆、棧、全局變量)會被標記成灰色
這兩步都完成以後,垃圾回收器會:
- 拿一個灰色的對象,標記成黑色
- 跟蹤這個對象的指針並將其所指向的所有對象都標記成灰色
然後,重複這兩個步驟直到沒有可以被着色的對象存在爲止。從這個角度出發,對象要麼是黑色,要麼是白色。白色對象代表並沒有任何被其他對象的引用,即可以被清除。
這裏有個上面步驟的展示
一開始所有對象都是白色,然後從根節點開始遞歸,所有沿途對象標記成灰色。如果一個對象被標記成 no scan
,那可以將它塗成黑色,因爲他不需要被繼續往後掃描:
現在灰色對象可以入隊等待掃描並且轉成黑色:
對象以同樣的處理方法入隊直到沒有任何對象需要被處理:
在處理最後一個對象時,黑色的對象就是那個正在使用的內存,而白色的對象就是可以被回收的內存。如我們所見,由於 struct2
的實例是在一個匿名函數中創建的,並且不能從根節點沿着指針追蹤得到,所以他會一直是白色,最後被回收。
着色操作能得以實現歸功於每個內存塊中叫做 gcmarkBits
的位,這個位用來將跟蹤掃描過的地方設成1:
如我們所見,黑色與灰色是同樣的工作方式。在處理上不同的地方是,灰色是可以被入隊掃描的,而黑色是指向鏈的尾部。
以上步驟完成以後,垃圾回收器會啓動Stop the world,啓用寫屏障,將期間的內存改變情況全部入隊垃圾回收器工作池,然後將這些入隊的內存重複以上的步驟進行標記。
運行時分析器 Runtime profiler
這是一個由Go提供的工具,允許我們可視化每一步垃圾回收的過程,並看到垃圾回收是對我們程序的影響有多大。使用這個跟蹤工具運行我們項目代碼能夠還能提供強大的可視化結果,下面是跟蹤圖
標記線程的生命週期同樣可以以goroutinue級別進行可視化。這是goroutine#33的示例,它在開始標記內存之前先在後臺等待。