Golang內(nèi)存管理之垃圾收集器詳解
0. 簡介
和C/C++
等語言使用手動的方式管理堆內(nèi)存不同,Go
和Python
、Java
使用自動的內(nèi)存管理系統(tǒng),包括垃圾收集(Garbage Collection
,縮寫GC
)機制。下面,我們將介紹垃圾收集器的設(shè)計原理以及Golang
垃圾收集器的實現(xiàn)原理。
1. 常見的GC算法
1.1 引用計數(shù)法
為每個對象維護一個引用計數(shù),當(dāng)引用對象銷毀時,引用計數(shù)-1,當(dāng)對象的引用計數(shù)變?yōu)?后,就回收該對象。
- 代表語言:
Python
、PHP
和Swift
; - 優(yōu)點:對象回收快,簡單直接;
- 缺點:不能很好地處理循環(huán)引用問題;實時維護引用計數(shù)是有損耗的。
1.2 標(biāo)記-清除
從根變量開始遍歷所有的引用對象,標(biāo)記引用對象,沒有被標(biāo)記的對象進行回收。
- 代表語言:
Golang
; - 優(yōu)點:解決了引用計數(shù)方式的缺點,較為簡單;
- 缺點:需要
STW(Stop The World)
,影響性能;另外也有可能造成內(nèi)存碎片的問題。
1.3 分代收集
按照對象生命周期長短劃分不同的代空間,生命周期長的放入老年代,短的放入新生代,不同代有不同的回收算法和回收頻率。
- 代表語言:
Java
; - 優(yōu)點:回收性能好;
- 缺點:算法復(fù)雜。
2. Golang GC原理
2.1 算法選擇
Golang
的垃圾回收算法使用的是無分代、不整理、并發(fā)的三色標(biāo)記清除算法:
Go
運行時的內(nèi)存分配基于tcmalloc
算法,基本上沒有碎片問題,從而避免了標(biāo)記-清除算法中容易產(chǎn)生內(nèi)存碎片的問題;Go
的垃圾回收器與用戶代碼并發(fā)執(zhí)行,提升GC效率,降低對用戶代碼的影響。
2.2 三色標(biāo)記
2.2.1 標(biāo)記-清除算法
最簡單的標(biāo)記-清除算法中,分為標(biāo)記和清除階段。在掃描階段,從垃圾回收的根對象出發(fā),掃描整個引用鏈,找到所有可達對象進行標(biāo)記。在清除階段,掃描所有的不可達對象,然后將垃圾對象清除掉。
但是該算法有一個很大的缺點:整個過程必須STW(Stop The World)
。這導(dǎo)致整個應(yīng)用程序必須停止,嚴(yán)重影響程序?qū)崟r性和效率。
2.2.2 三色標(biāo)記算法
為了解決原始標(biāo)記-清除帶來的長時間的STW
,多數(shù)現(xiàn)代的追蹤式垃圾收集器一般都會實現(xiàn)三色標(biāo)記算法以縮短STW
的時間。三色標(biāo)記法將程序中的對象分為白色、黑色和灰色三類:
- 白色對象:潛在的垃圾,其內(nèi)存可能會被垃圾收集器回收;
- 黑色對象:活躍的對象,已經(jīng)被掃描過的對象;
- 灰色對象:活躍的對象,剛好掃描到的對象,但是還需要對其子對象進行掃描,因為可能存在指向白色對象。
三色標(biāo)記法的標(biāo)記過程如下:
- 起初所有的對象都是白色的;
- 從根對象出發(fā)掃描所有可達對象,標(biāo)記為灰色,放入灰色集合;
- 從灰色集合中取出灰色對象,將其引用的對象標(biāo)記為灰色并放入到灰色集合中,自身標(biāo)記為黑色;
- 重復(fù)步驟3,直到灰色集合為空,此時白色對象即為不可達的“垃圾”,回收白色對象。
根對象在垃圾回收的術(shù)語中又叫根集合,它是垃圾回收器在標(biāo)記過程中最先檢查的對象,包括:
- 全局變量:程序在編譯時就能確定的那些在整個程序生命周期都將存活的變量;
- 執(zhí)行棧:每個
goroutine
都有自己的執(zhí)行棧,這些執(zhí)行棧上依舊存活的棧對象以及指向分配的堆內(nèi)存的指針對象。 - 寄存器:寄存器的值可能表示一個指針,參與計算的這些指針可能指向某個分配的內(nèi)存地址。
因為用戶可能會在標(biāo)記的過程中修改對象的指針,比如出現(xiàn)以下情形,在如下所示的三色標(biāo)記過程中,用戶程序建立了從 A 對象到 D 對象的引用,但是因為程序中已經(jīng)不存在灰色對象了,所以 D 對象會被垃圾收集器錯誤地回收。
要想解決以上問題,要么就和“標(biāo)記—清除”算法一樣,STW整個過程,但是這種方式會對用戶程序影響比較大,降低程序性能。
如果要GC和用戶程序并發(fā)執(zhí)行,且保證內(nèi)存安全,那么就需要使用屏障技術(shù)了。
2.2.3 屏障技術(shù)
內(nèi)存屏障技術(shù)是一種屏障指令,它可以讓 CPU 或者編譯器在執(zhí)行內(nèi)存相關(guān)操作時遵循特定的約束,目前多數(shù)的現(xiàn)代處理器都會亂序執(zhí)行指令以最大化性能,但是該技術(shù)能夠保證內(nèi)存操作的順序性,在內(nèi)存屏障前執(zhí)行的操作一定會先于內(nèi)存屏障后執(zhí)行的操作。
想要在并發(fā)和增量的標(biāo)記算法中保證正確性,我們需要滿足以下兩種三色不變性之一:
- 強三色不變性:黑色對象不會指向白色對象,只會指向灰色或者黑色對象;
- 弱三色不變性:黑色對象指向的白色對象必須包含一條從灰色對象經(jīng)由多個白色對象的可達路徑;
插入寫屏障
Dijkstra 于1978年提出的插入寫屏障,通過如下所示的算法,用戶程序和垃圾收集器可以在并行工作的情況下保證內(nèi)存安全:
// 灰色賦值器 Dijkstra 插入屏障 func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) { shade(ptr) //先將新下游對象 ptr 標(biāo)記為灰色 *slot = ptr } //說明: 添加下游對象(當(dāng)前下游對象slot, 新下游對象ptr) { //step 1 標(biāo)記灰色(新下游對象ptr) //step 2 當(dāng)前下游對象slot = 新下游對象ptr } //場景: A.添加下游對象(nil, B) //A 之前沒有下游, 新添加一個下游對象B, B被標(biāo)記為灰色 A.添加下游對象(C, B) //A 將下游對象C 更換為B, B被標(biāo)記為灰色
上述偽代碼很好理解,每當(dāng)執(zhí)行*slot = ptr
表達式時,我們會執(zhí)行上述寫屏障(通過shade
)嘗試改變該指針的顏色,如果該指針原本是白色的,那么通過該函數(shù)將其設(shè)置為灰色,否則保持不變。
如上圖所示的標(biāo)記過程:
- 垃圾收集器將根對象指向 A 對象標(biāo)記成黑色并將 A 對象指向的對象 B 標(biāo)記成灰色;
- 用戶程序修改 A 對象的指針,將原本指向 B 對象的指針指向 C 對象,這時觸發(fā)寫屏障將 C 對象標(biāo)記成灰色;
- 垃圾收集器依次遍歷程序中的其他灰色對象,將它們分別標(biāo)記成黑色;
插入寫屏障是一種相對保守的屏障技術(shù),它有以下兩個缺點:
- 在一次回收過程中可能會殘留一部分對象沒有回收成功,只有下一個回收過程中才會回收;
- 棧對象在垃圾回收中也被認(rèn)為是根對象,為了保證內(nèi)存安全:
- 為棧上的對象增加屏障:大幅增加寫入指針的額外開銷;
- 重新對棧上對象進行掃描:重新掃描棧對象需要STW;
刪除寫屏障
Yuasa 于1990年提出的刪除寫屏障的算法如下:
// 黑色賦值器 Yuasa 屏障 func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) { shade(*slot) 先將*slot標(biāo)記為灰色 *slot = ptr } //說明: 添加下游對象(當(dāng)前下游對象slot, 新下游對象ptr) { //step 1 if (當(dāng)前下游對象slot是灰色 || 當(dāng)前下游對象slot是白色) { 標(biāo)記灰色(當(dāng)前下游對象slot) //slot為被刪除對象, 標(biāo)記為灰色 } //step 2 當(dāng)前下游對象slot = 新下游對象ptr } //場景 A.添加下游對象(B, nil) //A對象,刪除B對象的引用。B被A刪除,被標(biāo)記為灰(如果B之前為白) A.添加下游對象(B, C) //A對象,更換下游B變成C。B被A刪除,被標(biāo)記為灰(如果B之前為白)
上述代碼會在老對象的引用被刪除時,將白色的老對象涂成灰色,這樣刪除寫屏障就可以保證弱三色不變性,老對象引用的下游對象一定可以被灰色對象引用。
如上圖所示的標(biāo)記過程:
- 垃圾收集器將根對象指向 A 對象標(biāo)記成黑色并將 A 對象指向的對象 B 標(biāo)記成灰色;
- 用戶程序?qū)?A 對象原本指向 B 的指針指向 C,觸發(fā)刪除寫屏障,但是因為 B 對象已經(jīng)是灰色的,所以不做改變;
- 用戶程序?qū)?B 對象原本指向 C 的指針刪除,觸發(fā)刪除寫屏障,白色的 C 對象被涂成灰色,避免發(fā)生懸掛指針以保證用戶程序的正確性;
- 垃圾收集器依次遍歷程序中的其他灰色對象,將它們分別標(biāo)記成黑色;
混合寫屏障
分析以上兩種屏障方式,如果采用純粹的插入寫屏障,滿足強三色不變原理,但是棧上的對象不設(shè)置寫屏障的話會導(dǎo)致黑色的??赡苤赶虬咨亩眩员仨歋TW重新掃描棧才能保證不丟對象,而在大量goroutine的環(huán)境下,STW的延遲不可控。
如果單純的使用刪除寫屏障,其基于其實快照的解決方案(snapshot-at-the-begining)。顧名思義,就是在開始 gc 之前,必須 STW ,對整個根做一次起始快照。當(dāng)賦值器(業(yè)務(wù)線程)從灰色或者白色對象中刪除白色指針時候,寫屏障會捕捉這一行為,將這一行為通知給回收器。
在Go v1.8版本引入了混合寫屏障,結(jié)合了二者的優(yōu)點,極大地減少了STW的時間,提升系統(tǒng)性能。
混合寫屏障的具體操作如下:
- GC開始時將棧上的可達對象全部掃描并且標(biāo)記為黑色(之后不再進行第二次重復(fù)掃描,無需STW);
- GC期間,任何在棧上創(chuàng)建的新對象,均為黑色;
- 堆上被刪除的對象標(biāo)記為灰色;
- 堆上新添加的對象標(biāo)記為灰色。
以下是個簡單的流程,圖片來自于詳細(xì)總結(jié): Golang GC、三色標(biāo)記、混合寫屏障機制,侵刪!
其實總結(jié)起來就是,在GC期間:
- 棧上可達對象都標(biāo)記為黑色,包括在此期間新創(chuàng)建的;
- 堆上的對象則會觸發(fā)混合屏障機制,那么在機制生效后,即使有棧上黑色指向白色的堆對象,那也一定有一條從灰色堆對象到此白對象的可達路徑,符合弱三色不變原理。
比如以下,就不會有棧對象能引用堆對象8,因為圖中的8號顯然是不可達的,所以不會出現(xiàn)不滿足弱三色不變原理的情形。那為什么1號對象可以引用7號對象呢?這是因為1號對象在引用7號對象的時候,對象7是在對象6的下游,本身是可達。
總結(jié)下來就是,混合屏障結(jié)合了插入和刪除寫屏障的優(yōu)點:
- 棧上數(shù)據(jù)(存活可達的)直接置黑保證了各個goroutine棧無需多次掃描,優(yōu)化了空間;
- 插入寫屏障保障了堆上的新增數(shù)據(jù)是灰色的;
- 刪除寫屏障保障了堆上被刪除的數(shù)據(jù)是灰色的,避免黑色的棧上數(shù)據(jù)指向時,其未變色被刪;
3. Golang GC過程
Golang垃圾收集的過程有以下四個階段:
- GC開始(STW);
- 并發(fā)掃描與輔助標(biāo)記;
- 標(biāo)記終止;
- 內(nèi)存清理。
3.1 GC開始(STW)
垃圾回收在啟動時都會調(diào)用runtime.gcStart
函數(shù):
func gcStart(trigger gcTrigger) { ... for trigger.test() && sweepone() != ^uintptr(0) { sweep.nbgsweep++ } // Perform GC initialization and the sweep termination // transition. semacquire(&work.startSema) // Re-check transition condition under transition lock. if !trigger.test() { semrelease(&work.startSema) return } ... }
首先檢查是否符合GC條件,在循環(huán)中驗證收集條件的同時還會不斷調(diào)用runtime.sweepone
清理已經(jīng)被標(biāo)記的內(nèi)存單元,完成上一個垃圾收集循環(huán)的收尾工作。
在下一小步之前,會再次check一下是否滿足GC條件。
接下來,調(diào)用gcBgMarkStartWorkers
啟動后臺標(biāo)記任務(wù)、在系統(tǒng)棧中調(diào)用stopTheWorldWithSema
暫停程序并調(diào)用finishsweep_m
保證上一次GC的工作結(jié)束。
func gcStart(trigger gcTrigger) { ... semacquire(&worldsema) gcBgMarkStartWorkers() work.stwprocs, work.maxprocs = gomaxprocs, gomaxprocs ... systemstack(stopTheWorldWithSema) systemstack(func() { finishsweep_m() }) work.cycles++ gcController.startCycle() ... }
func gcStart(trigger gcTrigger) { ... setGCPhase(_GCmark) gcBgMarkPrepare() gcMarkRootPrepare() atomic.Store(&gcBlackenEnabled, 1) systemstack(func() { now = startTheWorldWithSema(trace.enabled) work.pauseNS += now - work.pauseStart work.tMark = now }) semrelease(&work.startSema) }
總結(jié)下來,在GC開啟階段:
- 需要STW暫停程序執(zhí)行;
- 啟動后臺標(biāo)記任務(wù),用于第二階段;
- 啟動寫屏障;
- 將root根對象放入到標(biāo)記隊列(放入就是標(biāo)記為灰色);
- 取消STW,進入第二階段。
3.2 并發(fā)掃描與標(biāo)記輔助
前面說過,調(diào)用gcBgMarkStartWorkers
啟動后臺標(biāo)記任務(wù),該函數(shù)為每個處理器創(chuàng)建用于執(zhí)行后臺任務(wù)的
func gcBgMarkStartWorkers() { // Background marking is performed by per-P G's. Ensure that each P has // a background GC G. // // Worker Gs don't exit if gomaxprocs is reduced. If it is raised // again, we can reuse the old workers; no need to create new workers. for gcBgMarkWorkerCount < gomaxprocs { go gcBgMarkWorker() notetsleepg(&work.bgMarkReady, -1) noteclear(&work.bgMarkReady) // The worker is now guaranteed to be added to the pool before // its P's next findRunnableGCWorker. gcBgMarkWorkerCount++ } }
func gcBgMarkWorker() { gp := getg() gp.m.preemptoff = "GC worker init" node := new(gcBgMarkWorkerNode) gp.m.preemptoff = "" node.gp.set(gp) node.m.set(acquirem()) notewakeup(&work.bgMarkReady) for { gopark(func(g *g, parkp unsafe.Pointer) bool { node := (*gcBgMarkWorkerNode)(nodep) if mp := node.m.ptr(); mp != nil { releasem(mp) } gcBgMarkWorkerPool.push(&node.node) return true }, unsafe.Pointer(node), waitReasonGCWorkerIdle, traceEvGoBlock, 0) ... }
喚醒后,我們根據(jù)處理器gcMarkWorkerMode
選擇不同的標(biāo)記執(zhí)行策略,不同的執(zhí)行策略都會調(diào)用gcDrain
執(zhí)行掃描,這個函數(shù)可以作為分析Goalng三色著色的入口。
func gcBgMarkWorker() { ... // Preemption must not occur here, or another G might see // p.gcMarkWorkerMode. // Disable preemption so we can use the gcw. If the // scheduler wants to preempt us, we'll stop draining, // dispose the gcw, and then preempt. node.m.set(acquirem()) pp := gp.m.p.ptr() // P can't change with preemption disabled. if gcBlackenEnabled == 0 { println("worker mode", pp.gcMarkWorkerMode) throw("gcBgMarkWorker: blackening not enabled") } if pp.gcMarkWorkerMode == gcMarkWorkerNotWorker { throw("gcBgMarkWorker: mode not set") } startTime := nanotime() pp.gcMarkWorkerStartTime = startTime decnwait := atomic.Xadd(&work.nwait, -1) if decnwait == work.nproc { println("runtime: work.nwait=", decnwait, "work.nproc=", work.nproc) throw("work.nwait was > work.nproc") } systemstack(func() { // Mark our goroutine preemptible so its stack // can be scanned. This lets two mark workers // scan each other (otherwise, they would // deadlock). We must not modify anything on // the G stack. However, stack shrinking is // disabled for mark workers, so it is safe to // read from the G stack. casgstatus(gp, _Grunning, _Gwaiting) switch pp.gcMarkWorkerMode { default: throw("gcBgMarkWorker: unexpected gcMarkWorkerMode") case gcMarkWorkerDedicatedMode: gcDrain(&pp.gcw, gcDrainUntilPreempt|gcDrainFlushBgCredit) if gp.preempt { // We were preempted. This is // a useful signal to kick // everything out of the run // queue so it can run // somewhere else. if drainQ, n := runqdrain(pp); n > 0 { lock(&sched.lock) globrunqputbatch(&drainQ, int32(n)) unlock(&sched.lock) } } // Go back to draining, this time // without preemption. gcDrain(&pp.gcw, gcDrainFlushBgCredit) case gcMarkWorkerFractionalMode: gcDrain(&pp.gcw, gcDrainFractional|gcDrainUntilPreempt|gcDrainFlushBgCredit) case gcMarkWorkerIdleMode: gcDrain(&pp.gcw, gcDrainIdle|gcDrainUntilPreempt|gcDrainFlushBgCredit) } casgstatus(gp, _Gwaiting, _Grunning) }) ... }
當(dāng)所有的后臺工作任務(wù)都陷入等待并且沒有剩余工作時,我們就認(rèn)為該輪垃圾收集的標(biāo)記階段結(jié)束了,然后調(diào)用gcMarkDone
通知垃圾收集器。
func gcBgMarkWorker() { ... if incnwait == work.nproc && !gcMarkWorkAvailable(nil) { // We don't need the P-local buffers here, allow // preemption because we may schedule like a regular // goroutine in gcMarkDone (block on locks, etc). releasem(node.m.ptr()) node.m.set(nil) gcMarkDone() } } }
標(biāo)記輔助
為了保證用戶程序分配內(nèi)存的速度不會超出后臺任務(wù)的標(biāo)記速度,運行時還引入了標(biāo)記輔助技術(shù),它遵循一條非常簡單并且樸實的原則,分配多少內(nèi)存就需要完成多少標(biāo)記任務(wù)。
3.3 標(biāo)記終止(STW)
func gcMarkDone() { ... systemstack(stopTheWorldWithSema) ... // Perform mark termination. This will restart the world. gcMarkTermination(nextTriggerRatio) }
可以看到,進入標(biāo)記終止階段之前會STW,然后在gcMarkTermination
中會取消STW,所以此階段會取消STW,所以在此階段是會STW的。值得注意的是,在引入了混合寫屏障之后,即Go v1.8之后就不會在此階段對棧進行re-scan
了。
3.4 內(nèi)存清理
func gcSweep(mode gcMode) { ... //阻塞式 if !_ConcurrentSweep || mode == gcForceBlockMode { // Special case synchronous sweep. ... // Sweep all spans eagerly. for sweepone() != ^uintptr(0) { sweep.npausesweep++ } // Do an additional mProf_GC, because all 'free' events are now real as well. mProf_GC() mProf_GC() return } // 并行式 // Background sweep. lock(&sweep.lock) if sweep.parked { sweep.parked = false ready(sweep.g, 0, true) } unlock(&sweep.lock) }
對于并行式清掃,在 GC 初始化的時候就會啟動 bgsweep()
,然后在后臺一直循環(huán)。不管是阻塞式還是并行式,都是通過 sweepone()
函數(shù)來做清掃工作的。
func bgsweep(c chan int) { sweep.g = getg() lock(&sweep.lock) sweep.parked = true c <- 1 goparkunlock(&sweep.lock, "GC sweep wait", traceEvGoBlock, 1) for { for gosweepone() != ^uintptr(0) { sweep.nbgsweep++ Gosched() } lock(&sweep.lock) if !gosweepdone() { // This can happen if a GC runs between // gosweepone returning ^0 above // and the lock being acquired. unlock(&sweep.lock) continue } sweep.parked = true goparkunlock(&sweep.lock, "GC sweep wait", traceEvGoBlock, 1) } }
GC觸發(fā)時機
后臺觸發(fā)
運行時會在應(yīng)用程序啟動時在后臺開啟一個用于強制觸發(fā)垃圾收集的 Goroutine,該 Goroutine 的職責(zé)非常簡單 — 調(diào)用runtime.gcStart
嘗試啟動新一輪的垃圾收集:
func init() { go forcegchelper() } func forcegchelper() { forcegc.g = getg() for { lock(&forcegc.lock) atomic.Store(&forcegc.idle, 1) goparkunlock(&forcegc.lock, waitReasonForceGGIdle, traceEvGoBlock, 1) gcStart(gcTrigger{kind: gcTriggerTime, now: nanotime()}) } }
為了減少對計算資源的占用,該 Goroutine 會在循環(huán)中調(diào)用runtime.goparkunlock
主動陷入休眠等待其他 Goroutine 的喚醒,runtime.forcegchelper
在大多數(shù)時間都是陷入休眠的,但是它會被系統(tǒng)監(jiān)控器runtime.sysmon
在滿足垃圾收集條件時喚醒:
func sysmon() { ... for { ... if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 { lock(&forcegc.lock) forcegc.idle = 0 var list gList list.push(forcegc.g) injectglist(&list) unlock(&forcegc.lock) } } }
手動觸發(fā)
用戶程序會通過runtime.GC
函數(shù)在程序運行期間主動通知運行時執(zhí)行,該方法在調(diào)用時會阻塞調(diào)用方直到當(dāng)前垃圾收集循環(huán)完成
以上就是Golang內(nèi)存管理之垃圾收集器詳解的詳細(xì)內(nèi)容,更多關(guān)于Golang垃圾收集器的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Golang中 import cycle not allowed 問題
這篇文章主要介紹了Golang中 import cycle not allowed 問題的解決方法,問題從描述到解決都非常詳細(xì),需要的小伙伴可以參考一下2022-03-03golang通過遞歸遍歷生成樹狀結(jié)構(gòu)的操作
這篇文章主要介紹了golang通過遞歸遍歷生成樹狀結(jié)構(gòu)的操作,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2021-04-04