深度剖析 Golang 的 GC 掃描對象的實現(xiàn)
??大綱??
掃描的目的
掃描的實現(xiàn)
編譯階段
運行期內(nèi)存分配
運行掃描階段
總結(jié)

之前闡述了 golang 垃圾回收通過保證三色不變式來保證回收的正確性,通過寫屏障來實現(xiàn)業(yè)務(wù)賦值器和 gc 回收器正確的并發(fā)的邏輯。其中高概率的提到了“掃描隊列”和“掃描對象”。隊列這個邏輯非常容易理解,那么”掃描對象“ 這個你理解了嗎?有直觀的感受嗎?這篇文章就是要把這個掃描的過程深入剖析下。
掃描的東西是啥?形象化描述下 怎么去做的掃描?形象化描述下
我們就是要把這兩個抽象的概念搞懂,不能停留在語言級別淺層面,要知其然知其所以然。
??掃描的目的??
掃描到底是為了什么?
之前的文章我們深入剖析了垃圾回收的理論和實現(xiàn),可以總結(jié)這么節(jié)點:
垃圾回收的根本目的是:“回收那些業(yè)務(wù)永遠都不會再使用的內(nèi)存塊”; 掃描的目的則是:“把這些不再使用的內(nèi)存塊找出來”;
我們通過地毯式的掃描,從一些 root 起點開始,不斷推進搜索,最終形成了一張有向可達的網(wǎng),那些不在網(wǎng)里的就是沒有被引用到的,也就是可回收的內(nèi)存。
??掃描的實現(xiàn)??
掃描對象代碼邏輯其實不簡單,但主體線索很清晰,可以分為三部分:
編譯階段:編譯期是非常重要的一環(huán),針對靜態(tài)類型做好標記準備(旁白:原則上編譯期能做的絕對不留到運行期); 運行階段:賦值器分配內(nèi)存的時候,根據(jù)編譯階段的 type 標示,會為分配的對象內(nèi)存設(shè)置好一個對應(yīng)的指針標示的 bitmap; 掃描階段:根據(jù)指針的 bitmap 標示,地毯式掃描;
結(jié)構(gòu)體對齊
要理解編譯階段做的事情,那么首先要理解結(jié)構(gòu)體對齊的基礎(chǔ)知識。這個和 C 語言類似,golang 的結(jié)構(gòu)體是有對齊規(guī)則的,也就是說,必要的時候可能會填充一些內(nèi)存空間來滿足對齊的要求??偨Y(jié)來說兩條規(guī)則:
長度要對齊 地址要對齊
“長度要對齊”怎么理解?
結(jié)構(gòu)體的長度要至少是內(nèi)部最長的基礎(chǔ)字段的整數(shù)倍。
舉例:
type TestStruct struct {ptr uintptr // 8f1 uint32 // 4f2 uint8 // 1}
這個結(jié)構(gòu)體內(nèi)存占用 size 多大?
答案是:16個字節(jié),因為字段 ptr 是 uintptr 類型,占 8 字節(jié),是內(nèi)部字段最大的,TestStruct 整體長度要和 8 字節(jié)對齊。那么就是 16 字節(jié)了,而不是有些人想的 13 字節(jié)(8+4+1)。
dlv 調(diào)試如下:
(dlv) p typ*runtime._type {size: 16,...
字節(jié)示意圖:
|--8 Byte--|--4 Byte--|--4 Byte--|“地址要對齊”怎么理解?
字段的地址偏移要是自身長度的整數(shù)倍。
舉例:
type TestStruct struct {ptr uintptr // 8f1 uint8 // 1f2 uint32 // 4}
假設(shè) new 一個 TestStruct 結(jié)構(gòu)體 a 的地址是 0xc00008a010 ,那么 &a.ptr 是 0xc00008a010 (= a + 0),&a.f1 是 0xc00008a018 (= a + 8) ,&a.f2 是 0xc00008a01c (= a + 8 + 4) 。
dlv 調(diào)試如下:
(dlv) p &a.ptr(*uintptr)(0xc00008a010)(dlv) p &a.f1(*uint8)(0xc00008a018)(dlv) p &a.f2(*uint32)(0xc00008a01c)
假設(shè) TestStruct 分配對象 a 的地址是 0xc00008a010 ,解釋如下:
ptr 是第一個字段,當然和結(jié)構(gòu)體本身地址一樣,相對偏移是 0,所以地址是 0xc00008a010 == 0xc00008a010 + 0;f1 是第二個字段,由于前一個字段 ptr 是 uintptr 類型(8字節(jié)),并且由于 f1 本身是 uint8 類型(1字節(jié)),所以 f1 從 8 偏移開始沒毛病,所以 f1 的偏移地址從 0xc00008a018 == 0xc00008a010 + 8;f2 是第三個字段,由于前一個字段 f1 是 uint8(1字節(jié)),所以表面上看好像 f2 要接著 0xc00008a019(= 0xc00008a018 +1) 這個地址才對,但是 f2 本身是 uint32 (4字節(jié)的類型),所以 f2 地址偏移至少要是 4 的倍數(shù),所以 f2 的地址要從0xc00008a01c(0xc00008a018 + 4)這個地址開始才對。也就是說,f1 到 f2 之間填充了一些不用的空間,為了地址對齊。
所以這樣算下來,整個 TestStruct 的占用空間長度是 16字節(jié) (8+1+3+4)。
指針位標記
golang 的所有類型都對應(yīng)一個 _type 結(jié)構(gòu),可以在 runtime/type.go 里面找到,定義如下:
type _type struct {size uintptrptrdata uintptr // size of memory prefix holding all pointershash uint32tflag tflagalign uint8fieldalign uint8kind uint8alg *typeAlg// gcdata stores the GC type data for the garbage collector.// If the KindGCProg bit is set in kind, gcdata is a GC program.// Otherwise it is a ptrmask bitmap. See mbitmap.go for details.gcdata *bytestr nameOffptrToThis typeOff}
比如我們定義了一個 Struct 如下:
type TestStruct struct {ptr uintptr // 8f1 uint8 // 1f2 *uint8 // 8f3 uint32 // 4f4 *uint64 // 8??f5??uint64????//?8}
該結(jié)構(gòu) dlv 調(diào)試如下:
(dlv) p typ*runtime._type {size: 48,ptrdata: 40,hash: 4075663022,tflag: tflagUncommon|tflagExtraStar|tflagNamed (7),align: 8,fieldalign: 8,kind: 25,alg: *runtime.typeAlg {hash: type..hash.main.TestStruct, equal: type..eq.main.TestStruct},gcdata: *20,str: 28887,ptrToThis: 49504,}
在編譯期間,編譯器就會在內(nèi)部生成一個 _type 結(jié)構(gòu)體與之對應(yīng)。_type 里面重點解釋幾個和本次掃描主題相關(guān)的字段:
size:類型長度,我們上面這個類型長度應(yīng)該是 32 字節(jié); 這里理解要應(yīng)用上上面講的結(jié)構(gòu)體字節(jié)對齊的知識,這里就不再復(fù)述; ptrdata:指針截止的長度位置,我們 f4 是指針,所以包含指針的字段最多也就到 40 字節(jié)的位置,ptrdata==40; 要理解字節(jié)對齊哈; kind:表明類型,我們是自定義struct類型,所以 kind == 25 kind 枚舉定義在 runtime/typekind.go文件里;gcdata:這個就重要了,這個就是指針的 bitmap,因為編譯器他在編譯分析的時候,肯定就知道了所有的類型結(jié)構(gòu),那么自然知道所有的指針位置。gcdata 是 *byte類型(byte 數(shù)組),當前值是 20,20 轉(zhuǎn)換成二進制數(shù)據(jù)就是00010100,這個眼熟不?這個你要從右往左看就是00101000(從低 bit 往高 bit 看),這個不就是剛好是TestStruct的指針 bitmap 嘛,每個 bit 表示一個指針大?。? 字節(jié))的內(nèi)存,00101000?第 3 個 bit 和第 5 個 bit 是 1,表示 第 3 個字段(第 3 個 8 字節(jié)的位置)和第 5 個字段(第 5 個 8 字節(jié)的位置)是存儲的是指針類型,這里剛好就和TestStruct.f2?和TestStruct.f4?對應(yīng)起來。
劃重點:這里重點回顧一下 uintptr 類型的問題,這里注意到,第一個字段 ptr(uintptr 類型)在指針的 bitmap 上是沒有標記成指針類型的,這里一定要注意了,uintptr 是數(shù)值類型,非指針類型,用這個存儲指針是無法保護對象的(掃描的時候 uintptr 指向的對象不會被掃描),這里就是實錘了。
小結(jié):
編譯階段給每個類型生成 _type 類型,內(nèi)部對類型字段生成指針的 bitmap,這個是后面掃描行為的基礎(chǔ)依據(jù)。
思考題:是否可以不用 bitmap,其實有個最簡單最笨拙的掃描方式,我們可以不搞這個指針的 bitmap,我上來就直接掃描,每 8 字節(jié)的讀取內(nèi)存,然后去看這個內(nèi)存塊存儲的值是否指向了一個對象?如果是我就保護起來。
這個實現(xiàn)理論上可以滿足,但是有兩個不能接受的缺陷:
精度太低,你編譯期間不做準備,那運行期間就要來償還這部分損耗,你無法判斷是不是指針,所以只要指向了一個有效內(nèi)存地址,就得無腦保護,這樣就保護了很多不需要保護的內(nèi)存塊; 掃描太低效,必須全地址掃描,因為你沒有 bitmap,無法識別是否有指針。也無法做優(yōu)化,比如我們程序里面可能 一半以上的類型內(nèi)是不包含指針的,這種根本就不需要掃描;
下一步就是賦值器的做的事情,也就是業(yè)務(wù)運行的過程中分配內(nèi)存。分配內(nèi)存的時候肯定要指定類型,調(diào)用 runtime.newobject 函數(shù)進行分配,本質(zhì)上調(diào)用 mallocgc 函數(shù)來操作。mallocgc 函數(shù)做幾件事情:
分配內(nèi)存 內(nèi)存采樣 gc 標記準備
我們這里重點分析給 gc 做掃描做的準備。在分配完堆內(nèi)存之后,會調(diào)用一個函數(shù) heapBitsSetType ,這個函數(shù)邏輯非常復(fù)雜,但是做的事情其實一句話能概括:“給 gc 掃描做準備,對分配的內(nèi)存塊做好標記,這小塊內(nèi)存中,哪些位置是指針,我們用一個 bitmap 對應(yīng)記錄下來”。這就是 heapBitsSetType 500 多行代碼做的所有事情,之所以這么復(fù)雜是因為要判斷各種情況。
heapBitsSetType 主要邏輯解析:
func heapBitsSetType(x, size, dataSize uintptr, typ *_type) {// ...// 最重要的兩個步驟:// 通過分配地址反查獲取到 heap 的 heapBits 結(jié)構(gòu)(回憶下 golang 的內(nèi)存地址管理)h := heapBitsForAddr(x)// 獲取到類型的指針 bitmap;ptrmask := typ.gcdata // start of 1-bit pointer mask (or GC program, handled below)var (// ...)// 把 h.bitp 這個堆上的 bitmap 取出來;hbitp = h.bitp// 該類型的指針 bitmapp = ptrmask// ...if p != nil {// 把 bitmap 第一個字節(jié)保存起來b = uintptr(*p)// p 指向下一個字節(jié)p = add1(p)//nb = 8}// 我們的是簡單的 Struct 結(jié)構(gòu)(48==48)if typ.size == dataSize {// nw == 5 == 40/8,說明掃描到第 5 個字段為止即可。// ptrdata 指明有指針的范圍在[0, 40]以內(nèi),再往外確定就沒有指針字段了;nw = typ.ptrdata / sys.PtrSize} else {nw = ((dataSize/typ.size-1)*typ.size + typ.ptrdata) / sys.PtrSize}switch {default:throw("heapBitsSetType: unexpected shift")case h.shift == 0:// b 是類型的 ptr bitmap => 00010100// bitPointerAll => 00001111// hb => 0000 0100hb = b & bitPointerAll// bitScan => 0001 0000// 0001 0000 | 0100 0000 | 1000 0000// hb => 1101 0100hb |= bitScan | bitScan<<(2*heapBitsShift) | bitScan<<(3*heapBitsShift)// 賦值 hbitp => 1101 0100*hbitp = uint8(hb)// 指針往后一個字節(jié)(遞進一個字節(jié))hbitp = add1(hbitp)// b => 0000 0001b >>= 4// nb => 4nb -= 4case sys.PtrSize == 8 && h.shift == 2:// ...}// ...// 處理完了前 4 bit,接下來處理后 4 bitnb -= 4for {// b => 0000 0001// hb => 0000 0001hb = b & bitPointerAll// hb => 1111 0001hb |= bitScanAllif w += 4; w >= nw {// 處理完了,有指針的字段都包含在已經(jīng)處理的 ptrmask 范圍內(nèi)了break}// ...}Phase3:// Phase 3: Write last byte or partial byte and zero the rest of the bitmap entries.// 8 > 5if w > nw {// mask => 1mask := uintptr(1)<<(4-(w-nw)) - 1// hb => 0001 0001hb &= mask | mask<<4 // apply mask to both pointer bits and scan bits}// nw => 6nw = size / sys.PtrSize// ...if w == nw+2 {// 賦值 hbitp => 0001 0001*hbitp = *hbitp&^(bitPointer|bitScan|(bitPointer|bitScan)<uint8 (hb)}Phase4:// Phase 4: Copy unrolled bitmap to per-arena bitmaps, if necessary.// ...}
所以,上面函數(shù)調(diào)用完,h.bitp 就給設(shè)置上了:
低字節(jié) -> 高字節(jié) [ 1101 0100 ], [ 0001 0001 ] |--前4*8字節(jié)--|--后4*8字節(jié)--|
這個就是 mallocgc 內(nèi)存的時候做的事情。
總結(jié)就一句話:根據(jù)編譯期間針對每個 struct 生成的 type 結(jié)構(gòu),來設(shè)置 gc 需要掃描的位圖,也就是指針 bitmap。(旁白:每分配一塊內(nèi)存出去,我都會有一個 bitmap 對應(yīng)到這個內(nèi)存塊,指明哪些地方有指針)。
掃描以 markroot開始,從棧,全局變量,寄存器等根對象開始掃描,創(chuàng)建一個有向引用圖,把根對象投入到隊列中,重點的一個函數(shù)就是scanstack。另外異步的 goroutine?運行gcDrain函數(shù),從隊列里消費對象,并且掃描這個對象;掃描調(diào)用的就是 scanobject函數(shù)
下面重點介紹:scanstack,scanobject ?這個函數(shù)怎么掃描對象。
scanstack
這個函數(shù)是起點函數(shù)( 起始最原始的還是 markroot,但是我們這里梳理主線 ),該掃描棧上所有可達對象,因為棧是一個根,因為你做事情總要有個開始的地方,那么“棧”就是 golang 的起點。
func scanstack(gp *g, gcw *gcWork) {// ...// 掃描棧上所有的可達的對象state.buildIndex()for {p := state.getPtr()if p == 0 {break}// 獲取一個到棧上對象obj := state.findObject(p)if obj == nil {continue}// 獲取到這個對象的類型t := obj.typ// ...// 獲取到這個類型內(nèi)存塊的 ptr 的 bitmap(編譯期間編譯器設(shè)置好)gcdata := t.gcdatavar s *mspanif t.kind&kindGCProg != 0 {s = materializeGCProg(t.ptrdata, gcdata)gcdata = (*byte)(unsafe.Pointer(s.startAddr))}// 掃描這個對象// 起點:對象起始地址 => state.stack.lo + obj.off// 終點:t.ptrdata (還記得這個吧,這個指明了指針所在內(nèi)的邊界)// 指針 bitmap:t.gcdatascanblock(state.stack.lo+uintptr(obj.off), t.ptrdata, gcdata, gcw, &state)if s != nil {dematerializeGCProg(s)}}// ...}
小結(jié)::
找到這個 goroutine 棧上的內(nèi)存對象(一個個找,一個個處理); 找到對象之后,獲取到這個對象的 type 結(jié)構(gòu),然后取出 type.ptrdata, type.gcdata ,從而我們就知道掃描的內(nèi)存范圍,和內(nèi)存塊上指針的所在位置; 調(diào)用 scanblock 掃描這個內(nèi)存塊;
scanblock
scanblock 這個函數(shù)不說你應(yīng)該知道,這是一個非常底層且通用的函數(shù),他的一切參數(shù)都是傳入的,這個函數(shù)作為一個基礎(chǔ)函數(shù)被很多地方調(diào)用:
/*b0: 掃描開始的位置n0: 掃描結(jié)束的長度ptrmask: 指針的 bitmap*/func scanblock(b0, n0 uintptr, ptrmask *uint8, gcw *gcWork, stk *stackScanState) {b := b0n := n0// 掃描到長度 n 為止;for i := uintptr(0); i < n; {// 每個 bit 標識一個 8 字節(jié),8個 bit (1個字節(jié))標識 64 個字節(jié);// 這里計算到合適的 bitsbits := uint32(*addb(ptrmask, i/(sys.PtrSize*8)))// 如果整個 bits == 0,那么說明這 8 個 8 字節(jié)都沒有指針引用,可以直接跳到下一輪if bits == 0 {i += sys.PtrSize * 8continue}// bits 非0,說明內(nèi)部有指針引用,就必須一個個掃描查看;for j := 0; j < 8 && i < n; j++ {// 指針類型?只有標識了指針類型的,才有可能走到下面的邏輯去;if bits&1 != 0 {p := *(*uintptr)(unsafe.Pointer(b + i))if p != 0 {if obj, span, objIndex := findObject(p, b, i); obj != 0 {// 如果這 8 字節(jié)指向的是可達的內(nèi)存對象,那么就投入掃描隊列(置灰)保護起來;greyobject(obj, b, i, span, gcw, objIndex)} else if stk != nil && p >= stk.stack.lo && p < stk.stack.hi {stk.putPtr(p)}}}bits >>= 1i += sys.PtrSize}}}
如果以上面的 TestStruct 結(jié)構(gòu)舉例的話,假設(shè)在棧上分配了對象 TestStruct{},地址是 0xc00007cf20,那么會從這個地址掃描 scanblock ( 0xc00007cf20, 40, 20, xxx)
type TestStruct struct {??ptr?uintptr???//?8??f1??uint8?????// 1??f2??*uint8????//?8f3 uint32 // 4??f4??*uint64???// 8???f5??uint64????// 8?}
示意圖如下:

最外層 for 循環(huán)一次就夠了,里面 for 循環(huán) 5 次,掃描到 f4 字段就完了(還記得 type.ptrdata == 40 吧 )。只有 f2 ,f4 字段才會作為指針去掃描。如果 f2, f4 字段存儲的是有效的指針,那么指向的對象會被保護起來(greyobject)。
小結(jié):
scanblock這個函數(shù)非常簡單,只掃描給定的一段內(nèi)存塊;大循環(huán)每次遞進 64 個字節(jié),小循環(huán)每次遞進 8 字節(jié); 是否作為指針掃描是由 ptrmask 指定的; 只要長度和地址是對齊的,指針類型按 8 字節(jié)對齊,那么我們按照 8 字節(jié)遞進掃描一定是全方位覆蓋,不會漏掉一個對象的; 再次提醒下,uintptr 是數(shù)值類型,編譯器不會標識成指針類型,所以不受掃描保護;
scanobject
gcDrain 這個函數(shù)就是從隊列里不斷獲取,處理這些對象,最重要的一個就是調(diào)用 scanobject 繼續(xù)掃描對象。

markroot 從根(棧)掃描,把掃描到的對象投入掃描隊列。gcDrain 等函數(shù)從里面不斷獲取,不斷處理,并且掃描這些對象,進一步挖掘引用關(guān)系,當掃描結(jié)束之后,那些沒有掃描到的就是垃圾了。
還是 TestStruct 舉例:
type TestStruct struct {ptr uintptr // 8f1 uint8 // 1??f2??*uint8? //?8f3 uint32 // 4f4 *uint64 // 8f5 uint64 // 8}
如果一個創(chuàng)建在堆上的 TestStruct 對象被投入到掃描隊列,對應(yīng)的 type.gcdata 是 0001 0100 ,TestStruct 對應(yīng)編譯器創(chuàng)建的 type 類型如下:
(dlv) p typ*runtime._type {size: 48,ptrdata: 40,...gcdata: *20,... }
scanobject ?邏輯如下:
/*b : 是對象的內(nèi)存地址gcw : 是掃描隊列的封裝*/func scanobject(b uintptr, gcw *gcWork) {// 通過對象地址 b 獲取到這塊內(nèi)存地址對應(yīng)的 hbitshbits := heapBitsForAddr(b)// 通過對象地址 b 獲取到這塊內(nèi)存地址所在的 spans := spanOfUnchecked(b)// span 的元素大小n := s.elemsizeif n == 0 {throw("scanobject n == 0")}// ...var i uintptr// 每 8 個字節(jié)處理遞進處理(因為堆上對象分配都是 span,每個 span 的內(nèi)存塊都是定長的,所以掃描邊界就是 span.elemsize )for i = 0; i < n; i += sys.PtrSize {if i != 0 {hbits = hbits.next()}// 獲取到內(nèi)存塊的 bitmapbits := hbits.bits()// 確認該整個內(nèi)存塊沒有指針,直接跳出,節(jié)約時間;if i != 1*sys.PtrSize && bits&bitScan == 0 {break // no more pointers in this object}// 確認 bits 對應(yīng)的小塊內(nèi)存沒有指針,所以可以直接到下一輪// 如果是指針,那么就往下看看這 8 字節(jié)啥情況if bits&bitPointer == 0 {continue // not a pointer}// 把這 8 字節(jié)里面存的值取出來;obj := *(*uintptr)(unsafe.Pointer(b + i))// 如果 obj 有值,并且合法(不在一個 span 的內(nèi)存塊里)if obj != 0 && obj-b >= n {// 如果 obj 指向一個有效的對象,那么把這個對象置灰色,投入掃描隊列,等待處理if obj, span, objIndex := findObject(obj, b, i); obj != 0 {greyobject(obj, b, i, span, gcw, objIndex)}}}// ...}
小結(jié):
scanobject 的目的其實很簡單:就是進一步發(fā)現(xiàn)引用關(guān)系,盡可能的把可達對象全覆蓋; 這個地方就沒有直接使用到 type ,而是使用到 mallocgc 時候的準備成果( heapBitsSetType 設(shè)置),每個內(nèi)存塊都對應(yīng)了一個指針的 bitmap;
??總結(jié)??
要達到“正確并且高效的掃描”需要 編譯期間,運行分配期間,掃描期間 三者配合處理; 內(nèi)存對齊是非常重要的一個前提條件; 編譯期間生成 type 類型,對用戶定義的類型全方位分析,標記出所有的指針類型字段; 運行期間,賦值器分配內(nèi)存的時候,根據(jù) type 結(jié)構(gòu),設(shè)置和對象內(nèi)存一一對應(yīng)的 bitmap,標明指針所在位置,以便后續(xù) gc 掃描; 回收器掃描期間,從根部開始掃描,遇到對象,則置灰,投入隊列,并且不斷的掃描這些對象指向的對象,直到結(jié)束。掃描的依據(jù),就根據(jù)編譯期間生成的 bitmap,分配期間設(shè)置的 bitmap 來識別哪些地方有指針,然后進一步處理; 掃描只需要給個開始地址,然后每 8 字節(jié)推進就可以掃描了,為了加快效率我們才有了指針的 bitmap (所以這個是個優(yōu)化項); 再次強調(diào)下,定義的非指針類型不受保護,比如 uintptr 里面就算存儲的是一個地址的值,也是不會被掃描到的;
推薦閱讀
站長 polarisxu
自己的原創(chuàng)文章
不限于 Go 技術(shù)
職場和創(chuàng)業(yè)經(jīng)驗
Go語言中文網(wǎng)
每天為你
分享 Go 知識
Go愛好者值得關(guān)注
