常見的GC算法
引用計數法
根據對象自身的引用計數來回收,當引用計數歸零時進行回收,但是計數頻繁更新會帶來更多開銷,且無法解決循環引用的問題。
- 優點:簡單直接,回收速度快
- 缺點:需要額外的空間存放計數,無法處理循環引用的情況;
標記清除法
標記出所有不需要回收的對象,在標記完成後統一回收掉所有未被標記的對象。
- 優點:簡單直接,速度快,適合可回收對象不多的場景
- 缺點:會造成不連續的內存空間(內存碎片),導致有大的對象創建的時候,明明內存中總內存是夠的,但是空間不是連續的造成對象無法分配;
複製法
複製法將內存分爲大小相同的兩塊,每次使用其中的一塊,當這一塊的內存使用完後,將還存活的對象複製到另一塊去,然後再把使用的空間一次清理掉
- 優點:解決了內存碎片的問題,每次清除針對的都是整塊內存,但是因爲移動對象需要耗費時間,效率低於標記清除法;
- 缺點:有部分內存總是利用不到,資源浪費,移動存活對象比較耗時,並且如果存活對象較多的時候,需要擔保機制確保複製區有足夠的空間可完成複製;
標記整理
標記過程同標記清除法,結束後將存活對象壓縮至一端,然後清除邊界外的內容
- 優點:解決了內存碎片的問題,也不像標記複製法那樣需要擔保機制,存活對象較多的場景也使適用;
- 缺點:性能低,因爲在移動對象的時候不僅需要移動對象還要維護對象的引用地址,可能需要對內存經過幾次掃描才能完成;
分代式
將對象根據存活時間的長短進行分類,存活時間小於某個值的爲年輕代,存活時間大於某個值的爲老年代,永遠不會參與回收的對象爲永久代。並根據分代假設(如果一個對象存活時間不長則傾向於被回收,如果一個對象已經存活很長時間則傾向於存活更長時間)對對象進行回收。
Golang的垃圾回收(GC)算法
Golang的垃圾回收(GC)算法使用的是無無分代(對象沒有代際之分)、不整理(回收過程中不對對象進行移動與整理)、併發(與用戶代碼併發執行)的三色標記清掃算法。原因在於:
-
對象整理的優勢是解決內存碎片問題以及“允許”使用順序內存分配器。但 Go 運行時的分配算法基於
tcmalloc
,基本上沒有碎片問題。 並且順序內存分配器在多線程的場景下並不適用。Go 使用的是基於tcmalloc
的現代內存分配算法,對對象進行整理不會帶來實質性的性能提升。 -
分代
GC
依賴分代假設,即GC
將主要的回收目標放在新創建的對象上(存活時間短,更傾向於被回收),而非頻繁檢查所有對象。 -
Go 的編譯器會通過逃逸分析將大部分新生對象存儲在棧上(棧直接被回收),只有那些需要長期存在的對象纔會被分配到需要進行垃圾回收的堆中。也就是說,分代
GC
回收的那些存活時間短的對象在 Go 中是直接被分配到棧上,當goroutine
死亡後棧也會被直接回收,不需要GC
的參與,進而分代假設並沒有帶來直接優勢。 -
Go 的垃圾回收器與用戶代碼併發執行,使得 STW 的時間與對象的代際、對象的 size 沒有關係。Go 團隊更關注於如何更好地讓 GC 與用戶代碼併發執行(使用適當的 CPU 來執行垃圾回收),而非減少停頓時間這一單一目標上。
三色標記法原理
三色標記法將對象分爲三類,並用不同的顏色相稱:
-
白色對象(可能死亡):未被回收器訪問到的對象。在回收開始階段,所有對象均爲白色,當回收結束後,白色對象均不可達。
-
灰色對象(波面):已被回收器訪問到的對象,但回收器需要對其中的一個或多個指針進行掃描,因爲他們可能還指向白色對象。
-
黑色對象(確定存活):已被回收器訪問到的對象,其中所有字段都已被掃描,黑色對象中任何一個指針都不可能直接指向白色對象。
標記過程如下:
(1)起初所有的對象都是白色的;
(2)從根對象出發掃描所有可達對象,標記爲灰色,放入待處理隊列;
(3)從待處理隊列中取出灰色對象,將其引用的對象標記爲灰色並放入待處理隊列中,自身標記爲黑色;
(4)重複步驟(3),直到待處理隊列爲空,此時白色對象即爲不可達的“垃圾”,回收白色對象;
RootSet 根對象在垃圾回收的術語中又叫做根集合,它是垃圾回收器在標記過程時最先檢查的對象,包括:
- 全局變量:程序在編譯期就能確定的那些存在於程序整個生命週期的變量。
- 執行棧:每個 goroutine 都包含自己的執行棧,這些執行棧上包含棧上的變量及指向分配的堆內存區塊的指針。
- 寄存器:寄存器的值可能表示一個指針,參與計算的這些指針可能指向某些賦值器分配的堆內存區塊。
屏障機制
STW
STW 可以是Stop The World的縮寫,也可以是Start The World的縮寫。通常意義上指的是從Stop The World到Start The World這一段時間間隔。垃圾回收過程中爲了保證準確性、防止無止境的內存增長等問題而不可避免的需要停止賦值器進一步操作對象圖以完成垃圾回收。STW時間越長,對用戶代碼造成的影響越大。
No STW 存在的問題
假設下面的場景,已經被標記爲灰色的對象2,未被標記的對象3被對象2用指針p引用;此時已經被標記爲黑色的對象4創建指針q 指向未被標記的對象3,同時對象2將指針p移除;對象4已經被標記爲黑色,對象3未被引用,對象2刪除與對象3的引用,導致最後對象3被誤清除;
-
垃圾回收的原則是不應出現對象的丟失,也不應錯誤的回收還不需要回收的對象。如果同時滿足下面兩個條件會破壞回收器的正確性:
-
條件 1: 賦值器修改對象圖,導致某一黑色對象引用白色對象;(通俗的說就是A突然持有了B的指針,而B在併發標記的過程中已經被判定爲白色對象要被清理掉的)
-
條件 2: 從灰色對象出發,到達白色對象且未經訪問過的路徑被賦值器破壞;(通俗的說就是A持有B的指針,這個持有關係被釋放)
-
只要能夠避免其中任何一個條件,則不會出現對象丟失的情況,因爲:
- 如果條件 1被避免,則所有白色對象均被灰色對象引用,沒有白色對象會被遺漏;
- 如果條件 2 被避免,即便白色對象的指針被寫入到黑色對象中,但從灰色對象出發,總存在一條沒有訪問過的路徑,從而找到到達白色對象的路徑,白色對象最終不會被遺漏。
可能的解決方法: 整個過程STW,浪費資源,且對用戶程序影響較大,由此引入了屏障機制;
屏障機制
把回收器視爲對象,把賦值器視爲影響回收器這一對象的實際行爲(即影響 GC 週期的長短),從而引入賦值器的顏色:
- 黑色賦值器:已經由回收器掃描過,不會再次對其進行掃描。
- 灰色賦值器:尚未被回收器掃描過或儘管已經掃描過,但仍需要重新掃描。
插入屏障(Dijkstra)- 灰色賦值器
寫入前,對指針所要指向的對象進行着色
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// 灰色賦值器 Dijkstra 插入屏障 func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) { shade(ptr) //先將新下游對象 ptr 標記爲灰色 *slot = ptr } //說明: 添加下游對象(當前下游對象slot, 新下游對象ptr) { //step 1 標記灰色(新下游對象ptr) //step 2 當前下游對象slot = 新下游對象ptr } //場景: A.添加下游對象(nil, B) //A 之前沒有下游, 新添加一個下游對象B, B被標記爲灰色 A.添加下游對象(C, B) //A 將下游對象C 更換爲B, B被標記爲灰色 |
避免條件1( 賦值器修改對象圖,導致某一黑色對象引用白色對象;)因爲在對象A 引用對象B 的時候,B 對象被標記爲灰色
Dijkstra 插入屏障的好處在於可以立刻開始併發標記。但存在兩個缺點:
-
由於 Dijkstra 插入屏障的“保守”,在一次回收過程中可能會殘留一部分對象沒有回收成功,只有在下一個回收過程中才會被回收;
-
在標記階段中,每次進行指針賦值操作時,都需要引入寫屏障,這無疑會增加大量性能開銷;爲了避免造成性能問題,
Go
團隊在最終實現時,沒有爲所有棧上的指針寫操作,啓用寫屏障,而是當發生棧上的寫操作時,將棧標記爲灰色,但此舉產生了灰色賦值器,將會需要標記終止階段 STW 時對這些棧進行重新掃描。
刪除屏障 (Yuasa)- 黑色賦值器
寫入前,對指針所在對象進行着色
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// 黑色賦值器 Yuasa 屏障 func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) { shade(*slot) 先將*slot標記爲灰色 *slot = ptr } //說明: 添加下游對象(當前下游對象slot, 新下游對象ptr) { //step 1 if (當前下游對象slot是灰色 || 當前下游對象slot是白色) { 標記灰色(當前下游對象slot) //slot爲被刪除對象, 標記爲灰色 } //step 2 當前下游對象slot = 新下游對象ptr } //場景 A.添加下游對象(B, nil) //A對象,刪除B對象的引用。B被A刪除,被標記爲灰(如果B之前爲白) A.添加下游對象(B, C) //A對象,更換下游B變成C。B被A刪除,被標記爲灰(如果B之前爲白) |
避免條件2(從灰色對象出發,到達白色對象的、未經訪問過的路徑被賦值器破壞),因爲被刪除對象,如果自身是灰色或者白色,則被標記爲灰色
特點:標記結束不需要STW,但是回收精度低,GC 開始時STW 掃描堆棧記錄初始快照,保護開始時刻的所有存活對象;且容易產生“冗餘”掃描;
混合屏障
大大縮短了 STW 時間
- GC 開始將棧上的對象全部掃描並標記爲黑色;
- GC 期間,任何在棧上創建的新對象,均爲黑色;
- 被刪除的堆對象標記爲灰色;
- 被添加的堆對象標記爲灰色;
1 2 3 4 5 6 |
// 混合寫屏障 func HybridWritePointerSimple(slot *unsafe.Pointer, ptr unsafe.Pointer) { shade(*slot) shade(ptr) *slot = ptr } |
插入寫屏障和刪除寫屏障的短板:
- 插入寫屏障:結束時需要STW來重新掃描棧,標記棧上引用的白色對象的存活;
- 刪除寫屏障:回收精度低,GC開始時STW掃描堆棧來記錄初始快照,這個過程會保護開始時刻的所有存活對象。
Go V1.8版本引入了混合寫屏障機制(hybrid write barrier),避免了對棧re-scan的過程,極大的減少了STW的時間。結合了兩者的優點。
混合寫屏障規則
具體操作
:
1、GC開始將棧上的對象全部掃描並標記爲黑色(之後不再進行第二次重複掃描,無需STW),
2、GC期間,任何在棧上創建的新對象,均爲黑色。
3、被刪除的對象標記爲灰色。
4、被添加的對象標記爲灰色。
滿足
: 變形的弱三色不變式.
僞代碼:
添加下游對象(當前下游對象slot, 新下游對象ptr) {
//1
標記灰色(當前下游對象slot) //只要當前下游對象被移走,就標記灰色
//2
標記灰色(新下游對象ptr)
//3
當前下游對象slot = 新下游對象ptr
}
這裏我們注意, 屏障技術是不在棧上應用的,因爲要保證棧的運行效率。
(2) 混合寫屏障的具體場景分析
接下來,我們用幾張圖,來模擬整個一個詳細的過程, 希望您能夠更可觀的看清晰整體流程。
注意混合寫屏障是Gc的一種屏障機制,所以只是當程序執行GC的時候,纔會觸發這種機制。
GC開始:掃描棧區,將可達對象全部標記爲黑
場景一: 對象被一個堆對象刪除引用,成爲棧對象的下游
僞代碼
//前提:堆對象4->對象7 = 對象7; //對象7 被 對象4引用
棧對象1->對象7 = 堆對象7; //將堆對象7 掛在 棧對象1 下游
堆對象4->對象7 = null; //對象4 刪除引用 對象7
場景二: 對象被一個棧對象刪除引用,成爲另一個棧對象的下游
僞代碼
new 棧對象9;
對象8->對象3 = 對象3; //將棧對象3 掛在 棧對象9 下游
對象2->對象3 = null; //對象2 刪除引用 對象3
場景三:對象被一個堆對象刪除引用,成爲另一個堆對象的下游
僞代碼
堆對象10->對象7 = 堆對象7; //將堆對象7 掛在 堆對象10 下游
堆對象4->對象7 = null; //對象4 刪除引用 對象7
場景四:對象從一個棧對象刪除引用,成爲另一個堆對象的下游
僞代碼
堆對象10->對象7 = 堆對象7; //將堆對象7 掛在 堆對象10 下游
堆對象4->對象7 = null; //對象4 刪除引用 對象7
Golang中的混合寫屏障滿足弱三色不變式
,結合了刪除寫屏障和插入寫屏障的優點,只需要在開始時併發掃描各個goroutine的棧,使其變黑並一直保持,這個過程不需要STW,而標記結束後,因爲棧在掃描後始終是黑色的,也無需再進行re-scan操作了,減少了STW的時間。
總結
GoV1.3- 普通標記清除法,整體過程需要啓動STW,效率極低。
GoV1.5- 三色標記法, 堆空間啓動寫屏障,棧空間不啓動,全部掃描之後,需要重新掃描一次棧(需要STW),效率普通
GoV1.8-三色標記法,混合寫屏障機制, 棧空間不啓動,堆空間啓動。整個過程幾乎不需要STW,效率較高。
Golang GC過程
標記清理
Marking setup
爲了打開寫屏障,必須停止每個goroutine,讓垃圾收集器觀察並等待每個goroutine進行函數調用, 等待函數調用是爲了保證goroutine停止時處於安全點。
1 2 3 4 5 6 7 8 |
// 如果goroutine4 處於如下循環中,運行時間取決於slice numbers的大小 func add(numbers []int) int { var v int for _, n := range numbers { v += n } return v } |
下面的代碼中,由於for{}
循環所在的goroutine 永遠不會中斷,導致始終無法進入STW階段,資源浪費;Go 1.14 之後,此類goroutine 能被異步搶佔,使得進入STW的時間不會超過搶佔信號觸發的週期,程序也不會因爲僅僅等待一個goroutine的停止而停頓在進入STW之前的操作上。
1 2 3 4 5 6 7 8 9 |
func main() { go func() { for { } }() time.Sleep(time.Milliecond) runtime.GC() println("done") } |
Marking
一旦寫屏障打開,垃圾收集器就開始標記階段,垃圾收集器所做的第一件事是佔用25%CPU。
標記階段需要標記在堆內存中仍然在使用中的值。首先檢查所有現goroutine的堆棧,以找到堆內存的根指針。然後收集器必須從那些根指針遍歷堆內存圖,標記可以回收的內存。
當存在新的內存分配時,會暫停分配內存過快的那些 goroutine,並將其轉去執行一些輔助標記(Mark Assist)的工作,從而達到放緩繼續分配、輔助 GC 的標記工作的目的。
Mark終止
關閉寫屏障,執行各種清理任務(STW - optional )
Sweep (清理)
清理階段用於回收標記階段中標記出來的可回收內存。當應用程序goroutine嘗試在堆內存中分配新內存時,會觸發該操作,清理導致的延遲和吞吐量降低被分散到每次內存分配時。
階段 | 說明 | 賦值器狀態 |
---|---|---|
SweepTermination | 清掃終止階段,爲下一階段的併發標記做準備工作,啓動寫屏障 | STW |
Mark | 掃描標記階段,與賦值器併發執行,寫屏障開啓 | 併發 |
MarkTermination | 標記終止階段,保證一個週期內標記任務完成,停止寫屏障 | STW |
GCoff | 內存清掃階段,將需要回收的內存歸還到堆中,寫屏障關閉 | 併發 |
GCoff | 內存歸還階段,將需要回收的內存歸還給操作系統,寫屏障關閉 | 併發 |
前面提到Golang的GC屬於併發式垃圾回收(意味着不需要長時間的STW,GC大部分執行過程是和用戶代碼並行的),它可以分爲四個階段:
- 清除終止
Sweep Termination
:- 暫停程序
- 清掃未被回收的內存管理單元span,當上一輪GC的清掃工作完成後纔可以開始新一輪的GC
- 標記
Mark
:- 切換至
_GCmark
,開啓寫屏障和用戶程序協助Mutator Assiste
並將根對象添加到三色標記法隊列 - 恢復程序,標記進程和
Mutator Assiste
進程會開始併發標記內存中的對象,混合寫屏障將被刪除的指針和新加入的指針都標記成灰色,新創建的對象標記成黑色 - 掃描根對象(包括所有goroutine的棧、全局對象以及不在堆中的運行時數據結構),掃描goroutine棧期間會暫停當前處理器
- 依次處理三色標記法隊列,將掃描過的對象標記爲黑色並將它們指向的對象標記成灰色
- 使用分佈式終止算法檢查剩餘的工作,發現標記階段完成後進入標記終止階段
- 切換至
- 標記終止
Mark Termination
- 暫停程序,切換至
_GCmarktermination
並關閉輔助標記的用戶程序 - 清理處理器上的線程緩存
- 暫停程序,切換至
- 清除
Sweep
- 將狀態切換至
_GCoff
,關閉混合寫屏障 - 恢復用戶程序,所有新創建的對象標記爲白色
- 後臺併發清理所有的內存管理單元span,當goroutine申請新的內存管理單元時就會觸發清理
- 將狀態切換至
在GC過程中會有兩種後臺任務(G),包括標記任務和清掃任務。可以同時執行的標記任務約是P數量的四分之一,即go所說的25%CPU用於GC的依據。清掃任務會在程序啓動後運行,進入清掃階段時喚醒。
輔助GC
由於Golang使用了併發式的垃圾回收,將原本需要STW較長時間的GC過程分散到多次小規模的GC。當用戶分配內存的速度超過GC回收速度時,Golang會通過輔助GC暫停用戶程序進行垃圾回收,防止內存因分配對象速度過快消耗殆盡的問題。
清除階段出現新對象:
清除階段是掃描整個堆內存,可以知道當前清除到什麼位置,創建的新對象判定下,如果新對象的指針位置已經被掃描過了,那麼就不用作任何操作,不會被誤清除,如果在當前掃描的位置的後面,把該對象的顏色標記爲黑色,這樣就不會被誤清除了
什麼時候進行清理?
主動觸發(runtime.GC()) 被動觸發 (GC百分比、定時)
- 每次內存分配時檢查當前內存分配量是否已達到閾值(環境變量GOGC):默認100%,此值表示在下一次垃圾收集必須啓動之前可以分配多少新內存的比率。將GC百分比設置爲100意味着:基於在垃圾收集完成後標記爲活動的堆內存量,下次垃圾收集前,堆內存使用可以增加100%。
- 定時觸發:當最近2分鐘未觸發過GC時,會觸發一次GC
- 通過runtime.GC()手動觸發
觸發垃圾回收首先要滿足三個前提條件:
memstats.enablegc
:允許垃圾回收panicking == 0
:程序沒有panicgcphase == _GCoff
:處於_Gcoff
階段
對應的觸發時機包括:
gcTriggerHeap
:堆內存的大小達到一定閾值gcTriggerTime
:距離上一次垃圾回收超過一定閾值時gcTriggerCycle
:如果當前沒有啓動GC則開始新一輪的GC
GC過程演示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
package main import ( "os" "runtime" "runtime/trace" ) func gcfinished() *int { p := 1 runtime.SetFinalizer(&p, func(_ *int) { println("gc finished") }) return &p } func allocate() { _ = make([]byte, int((1<<20)*0.25)) } func main() { f, _ := os.Create("trace.out") defer f.Close() trace.Start(f) defer trace.Stop() gcfinished() // 當完成 GC 時停止分配 for n := 1; n < 50; n++ { println("#allocate: ", n) allocate() } println("terminate") } |
運行程序
1 2 3 |
liangyaopei> > $ GODEBUG=gctrace=1 go run main.go gc 1 @0.005s 3%: 0.023+0.87+0.059 ms clock, 0.19+0.80/0.42/0+0.47 ms cpu, 4->4->0 MB, 5 MB goal, 8 P |
棧分析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
gc 1 : 第一個GC週期 @0.005s : 從程序開始運行到第一次GC時間爲0.001 秒 5% : 此次GC過程中CPU 佔用率 wall clock 0.023+0.87+0.059 ms clock 0.023 ms : STW,Marking Start, 開啓寫屏障 0.87 ms : Marking階段 0.059 ms : STW,Marking終止,關閉寫屏障 CPU time 0.19+0.80/0.42/0+0.47 ms cpu 0.19 ms : STW,Marking Start 0.80 ms : 輔助標記時間 0.42 ms : 併發標記時間 0 ms : GC 空閒時間 0.47 ms : Mark 終止時間 4->4->0 MB, 5 MB goal 4 MB :標記開始時,堆大小實際值 4 MB :標記結束時,堆大小實際值 0 MB :標記結束時,標記爲存活對象大小 5 MB :標記結束時,堆大小預測值 8 P 8P :本次GC過程中使用的goroutine 數量 |
關注指標與調優示例
關注指標
Go 的 GC 被設計爲成比例觸發、大部分工作與賦值器併發、不分代、無內存移動且會主動向操作系統歸還申請的內存。因此最主要關注的、能夠影響賦值器的性能指標有:
- CPU 利用率:回收算法會在多大程度上拖慢程序?有時候,這個是通過回收佔用的 CPU 時間與其它 CPU 時間的百分比來描述的。
- GC 停頓時間:回收器會造成多長時間的停頓?目前的 GC 中需要考慮 STW 和 Mark Assist 兩個部分可能造成的停頓。
- GC 停頓頻率:回收器造成的停頓頻率是怎樣的?目前的 GC 中需要考慮 STW 和 Mark Assist 兩個部分可能造成的停頓。
- GC 可擴展性:當堆內存變大時,垃圾回收器的性能如何?但大部分的程序可能並不一定關心這個問題。
調優示例
合理化內存分配的速度、提高賦值器的 CPU 利用率
goroutine 的執行時間佔其生命週期總時間非常短的一部分,但大部分時間都花費在調度器的等待上了,說明同時創建大量 goroutine 對調度器產生的壓力確實不小,我們不妨將這一產生速率減慢,一批一批地創建 goroutine。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
func concat() { for n := 0; n < 100; n++ { for i := 0; i < 8; i++ { go func() { s := "Go GC" s += " " + "Hello" s += " " + "World" _ = s }() } } } //改進 func concat() { wg := sync.WaitGroup{} for n := 0; n < 100; n++ { wg.Add(8) for i := 0; i < 8; i++ { go func() { s := "Go GC" s += " " + "Hello" s += " " + "World" _ = s wg.Done() }() } wg.Wait() } } |
降低並複用已經申請的內存
newBuf()
產生的申請的內存過多, sync.Pool 是內存複用的一個最爲顯著的例子
1 2 3 4 5 6 7 8 9 10 11 12 |
func newBuf() []byte { return make([]byte, 10<<20) } b := newBuf() //改進 var bufPool = sync.Pool{ New: func() interface{} { return make([]byte, 10<<20) }, } b := bufPool.Get().([]byte) |
調整 GOGC
降低收集器的啓動頻率(提高GC百分比)無法幫助垃圾收集器更快完成收集工作。降低頻率會導致垃圾收集器在收集期間完成更多的工作。 可以通過減少新分配對象數量來幫助垃圾收集器更快完成收集工作
常見方法
- 儘量使用小數據類型,比如使用
int8
代替int
。 - 少使用
+
連接string
:go語言中string是一個只讀類型,針對string的每一個操作都會創建一個新的string。大量小文本拼接時優先使用strings.Join
,大量大文本拼接時使用bytes.Buffer
。 -
分配的對象越多,GC性能就越差,所以需要減少對象分配的個數,比如對象複用,使用sync.Pool
注意:sync.Pool類似於緩存,其中的對象會被定期清理(GC時清理),不能放置像是數據庫連接這樣需要穩定存儲的數據
參考
[典藏版]Golang三色標記、混合寫屏障GC模式圖文全分析