GO GC

從內(nèi)存說起

我們知道程序運行時使用的常量變量其實都是存儲在內(nèi)存中的,所謂垃圾回收也就是將程序占用了,但現(xiàn)在已經(jīng)不再使用的內(nèi)存空間進行回收。那內(nèi)存中都存儲了些什么東西呢

程序內(nèi)存

代碼區(qū):存儲給cpu運行時讀取用的我們編譯好的代碼
數(shù)據(jù)區(qū):存儲全局變量

我們的程序在運行的時候,與我們接觸更多的是堆區(qū)和棧區(qū),大家都知道我們在程序中聲明的變量會分配到這兩個區(qū)域中
對于go程序來說,我們一般不需要關心一個變量到底被go分配到了堆區(qū)還是棧區(qū),但這里姑且還是提一下go的分配原則

  • 是否有在其他地方(非局部)被引用。只要有可能被引用了,那么它一定分配到堆上。否則分配到棧上
  • 即使沒有被外部引用,但對象過大,無法存放在棧區(qū)上。依然有可能分配到堆上
  • go基本上每個版本對逃逸分析都會有所優(yōu)化,所以不必死記,領會思想即可,需要的話直接通過 go build -gcflags '-m -l' 就可以看到逃逸分析的過程和結果

通過上面的原則我們可以看到,其實在分配內(nèi)存時,是優(yōu)先分配到棧上的,滿足一些條件的時候才會逃逸到堆上,那么這是為什么呢?

我們寫代碼時,除了全局變量的聲明,我們實際編寫的邏輯,全部都是包在一個個的函數(shù)中的,比如程序是從main()函數(shù)開始執(zhí)行的,而函數(shù)之間的調(diào)用,總體上是

  1. A函數(shù)調(diào)用B函數(shù)
  2. B函數(shù)調(diào)用D函數(shù)
  3. D函數(shù)執(zhí)行完畢返回到B函數(shù)
  4. B函數(shù)執(zhí)行完畢返回到A函數(shù)
  5. A函數(shù)調(diào)用C函數(shù)
  6. C函數(shù)執(zhí)行完畢返回到A函數(shù)
  7. A函數(shù)執(zhí)行完畢結束調(diào)用

這樣的一個流程,每個函數(shù)都有它自己需要存儲的變量,A函數(shù)在調(diào)用了B函數(shù)之后,CPU會將A函數(shù)掛起轉而去執(zhí)行B函數(shù),在B函數(shù)返回之前,A函數(shù)實際上是阻塞等待的,而C函數(shù)和B函數(shù)執(zhí)行完畢后,他們各自占用的內(nèi)存空間會被釋放掉,所以函數(shù)對??臻g的占用看起來就像下圖這樣

函數(shù)調(diào)用棧

從上面的描述大家應該也能夠理解,棧這種先進后出的特性天然的適用這種函數(shù)互相調(diào)用的場景,因此??臻g是函數(shù)優(yōu)先使用的內(nèi)存空間
但同時,如果函數(shù)執(zhí)行完畢,該函數(shù)的棧空間就會被標記為釋放,如果我們在函數(shù)中創(chuàng)建了全局變量,或著我們的函數(shù)返回了某個對象給調(diào)用方,這些變量就不適合放在本函數(shù)的??臻g中,他們就會被放在堆空間,以便于其他函數(shù)訪問

那函數(shù)在棧上都放些啥呢

棧區(qū)詳情

假設現(xiàn)在,函數(shù)A調(diào)用了函數(shù)B,則CPU執(zhí)行的內(nèi)容從函數(shù)A轉移到了函數(shù)B,要實現(xiàn)這個轉移,CPU需要知道

  • 函數(shù)B的第一條機器指令的地址
  • 函數(shù)A的機器指令執(zhí)行到的位置,好在函數(shù)B執(zhí)行結束后返回

在我們的代碼執(zhí)行函數(shù)調(diào)用時,機器指令會調(diào)用call指令,指向函數(shù)B的地址,同時將函數(shù)A的下一條機器指令的地址push進函數(shù)A的棧幀中【即圖中的0x40037b】,這樣CPU在執(zhí)行完成函數(shù)B后,可以讀取這個地址繼續(xù)執(zhí)行函數(shù)A

一般函數(shù)調(diào)用時,我們會有一些參數(shù)需要傳進被調(diào)用函數(shù)中,這些參數(shù)會存儲在寄存器中,由被調(diào)用函數(shù)從寄存器中讀??;但是寄存器是有數(shù)量限制的,如果參數(shù)多于寄存器數(shù)量的話,多出來的參數(shù)會被直接放進調(diào)用函數(shù)的棧幀中,這樣被調(diào)用函數(shù)就可以從調(diào)用函數(shù)的棧幀中獲取參數(shù)

函數(shù)內(nèi)部定義的局部變量,默認也是存儲在寄存器中的,但如果寄存器放不下的話,也會存放在函數(shù)的棧幀中

寄存器是共享資源可以被任意函數(shù)使用,既然函數(shù)A把局部變量寫進了寄存器,當函數(shù)A調(diào)用函數(shù)B的時候,函數(shù)B也把自己的局部變量寫進寄存器,那當函數(shù)B執(zhí)行完畢回來執(zhí)行函數(shù)A的時候,函數(shù)A的局部變量不就被函數(shù)B改過了嘛,這樣會有問題吧?
的確會有問題,所以當我們要往寄存器中寫入函數(shù)B的局部變量前,要先將函數(shù)A的寄存器中值保存下來,保存在哪里呢,還是保存在函數(shù)A的棧幀中,這樣當函數(shù)B執(zhí)行完畢,就會從函數(shù)A的棧幀中讀取寄存器初始值,恢復到寄存器中,這樣函數(shù)A才能正常地繼續(xù)執(zhí)行

垃圾回收

上面講了我們都往內(nèi)存里存些什么,那存儲空間我們只存不釋放的話總有寫滿的一天,將已經(jīng)不再使用的內(nèi)存空間釋放的行為稱為垃圾回收,也就是GC。在go語言的早期版本中,垃圾回收的性能是非常糟糕的,歷經(jīng)數(shù)年多次迭代,如今的goGC性能已經(jīng)有了相當大的優(yōu)化

Go 1.3之前的標記清除算法

啟動STW,將程序暫停,遍歷所有對象,將所有不可達對象標記出來,清除這些不可達對象,結束STW

1.3之前的GC

邏輯非常簡單易懂,算法的問題也很大,那就是mark標記階段需要掃描整個堆,而且STW的時間很長,帶來的卡頓人可感知,這必然是不好的,當時主要是通過在代碼上及時手動釋放內(nèi)存及減少大內(nèi)存的頻繁申請和釋放來盡量規(guī)避這個問題

Go 1.3的標記清除算法

go1.3版本時將sweep清除的行為改在停止STW之后,起一個協(xié)程來與其他邏輯并發(fā)執(zhí)行sweep,如果程序運行在多核CPU上,go還會盡可能的將這個協(xié)程調(diào)度到一顆單獨的CPU上去執(zhí)行,以盡量減少對現(xiàn)有邏輯的影響。此次優(yōu)化后按go團隊的說法,減少了50%-70%的STW時間。

1.3的GC

Go 1.5的三色并發(fā)標記法

三色標記法的操作邏輯其實也不復雜

  1. GC開始時,所有對象標記為白色
  2. 從根節(jié)點開始遍歷所有白色對象,將遍歷到的對象從白色改為灰色
  3. 從灰色對象作為根節(jié)點開始遍歷所有白色對象,將遍歷到的對象從白色改為灰色,并將灰色的根節(jié)點改為黑色
  4. 重復第三步,直到所有灰色對象都變成黑色對象
  5. 清除白色對象

簡單圖例

GC開始.png
第一次置灰.png
第二次置灰 第一次置黑.png
第三次置灰 第二次置黑.png
第四次置灰 第三次置黑.png
第四次置黑 標記完成.png
將白色對象回收.png

上述操作很自然地給大家解釋了為啥叫三色標記法,那并發(fā)又是怎么個并發(fā)呢
我們知道之前的GC都需要STW,那三色標記法如果不STW會怎么樣呢

對象6引用對象3.png

剛剛掃描完對象1和對象6,對象2和對象7為灰色,還未開始對對象2和對象7進行掃描
此時黑色對象6創(chuàng)建對白色對象3的引用

對象2取消引用對象3.png

并且灰色對象2取消對白色對象3的引用

繼續(xù)掃描.png

掃描結果如圖所屬,因為對象6已經(jīng)是黑色對象,所以不會重復去掃描,從而導致對象3最終沒有被掃描到,在回收階段連帶對象3引用的對象4都會被錯誤地回收掉。

可見,會出現(xiàn)這個錯誤,需要同時滿足兩個條件

  • 一個黑色對象引用一個白色對象
  • 灰色對象與此白色對象之間的可達關系被破壞

只要這兩個條件同時滿足,就會出現(xiàn)對象丟失的情況

要解決這個問題,最簡單的做法就是在掃描階段STW,但是STW的性能損耗太大,怎么樣能做到不STW地對對象的引用關系進行掃描呢

答案是,我們想辦法破壞掉上面的兩個條件就可以了

為了破壞掉上述兩個條件,go團隊提出了強/弱三色不變性兩個補充規(guī)則,分別用于破壞其中一個規(guī)則

強弱三色不變性表

Go 1.8的混合寫屏障機制

在go的1.8版本中,對寫屏障這塊的邏輯進行了大幅優(yōu)化,在優(yōu)化思想上是希望結合插入屏障和刪除屏障各自的優(yōu)點,綜合提升GC性能。

  1. GC開始時,STW,對棧進行掃描,將所有可達對象置黑
  2. GC期間,任何棧上新創(chuàng)建的對象,皆置黑【1和2配合,可避免插入屏障對棧區(qū)的二次三色標記】
  3. 被刪除的對象置灰
  4. 新掛載的對象置灰

與插入屏障對比,可以看到混合寫屏障的思路中,將GC大致分為3個階段【本質(zhì)上還是 標記-清掃 兩階段】,

  • 首先是開始時的STW,專門掃描棧區(qū)【標記】
  • 然后是對堆進行并發(fā)三色標記,通過將堆上所有新增掛載和刪除掛載的對象全部置灰,棧上所有新增對象全部置黑來保證不出現(xiàn)對象丟失的問題【標記】
  • 最后是并發(fā)清理白色對象【清掃】

具體的GC實現(xiàn)細節(jié)隨著go版本的迭代是不斷在優(yōu)化的,但總體的實現(xiàn)思路就是這樣的三階段
要注意的是機制3和機制4在棧上是不生效的,棧由機制1和機制2控制

GC開始.png
先掃描棧區(qū).png

GC開始先對棧區(qū)進行掃描,將所有可達對象置黑

對象7引用對象2.png

這時將堆區(qū)的對象7掛載到棧區(qū)的對象2下
因為寫屏障在棧區(qū)是不生效的,所以這個掛載行為不會改變對象7的顏色

對象6取消引用對象7.png
觸發(fā)屏障 對象7置灰.png

對象7刪除對對象6的掛載
觸發(fā)混合寫屏障機制,對象7被刪除掛載,故將對象7置灰

繼續(xù)標記.png

標記完成.png

繼續(xù)對堆區(qū)進行三色標記,直到將所有可達對象置黑

Go 1.12的GC細節(jié)

本質(zhì)還是【標記-清掃】,其中標記有三個階段,其中兩個階段會STW導致程序暫停,標記完成才會進入并發(fā)清掃階段

  1. 標記開始階段【需要STW】
  2. 并發(fā)標記階段【至少占用25%的CPU】
  3. 標記終止階段【需要STW】
  4. 并發(fā)清掃階段

標記開始階段

標記開始階段也就是GC的開始階段,在此階段需要先打開混合寫屏障機制,而打開混合寫屏障機制需要STW,需要將所有的goroutine停下來。
打開混合寫屏障的速度很快,平均在10-30微秒
但是因為STW要求把所有的goroutine停下來,而為了保證goroutine停下時處于一個比較安全的狀態(tài),go的垃圾收集器會觀察goroutine,一般是在goroutine進行函數(shù)調(diào)用時將其暫停

這里有個問題,就是如果有什么邏輯導致一個goroutine一直在運行沒有進入想函數(shù)調(diào)用這種可以安全暫停的狀態(tài)【比如你寫個流程極長的循環(huán)累加什么的】就會導致其他goroutine都被停下來等待這一個沒能暫停的goroutine,但是這個goroutine又一直未能進入暫停狀態(tài),從而導致程序運行嚴重卡頓
此問題在go1.14版本被優(yōu)化,加入了搶占機制

并發(fā)標記階段

混合寫屏障打開之后,就要開始并發(fā)標記了
垃圾收集器會占用25%的CPU資源,也就是說如果程序跑在4線程的機器上,此時垃圾收集器會直接占用掉一個線程的資源,一個P將會專用于垃圾收集。
先掃描所有goroutine的堆棧,找到堆內(nèi)存的根指針,然后從跟指針開始遍歷遍歷整個堆,通過三色標記法對內(nèi)存進行標記,此標記過程中混合寫屏障一直生效。

這里也有個問題,就是如果我們的程序一直在持續(xù)不斷的大量分配內(nèi)存,可能會出現(xiàn)垃圾收集器掃描和標記的速度跟不上我們程序分配內(nèi)存的速度,從而導致這個并發(fā)標記階段永遠不會結束【除非內(nèi)存被占滿】
所以如果真的出現(xiàn)這樣的情況,垃圾收集器會評估當前在運行的goroutine,將內(nèi)存分配最多的那個goroutine暫停,轉化為Mark Assist【協(xié)助標記】,轉化的時間與此goroutine申請的內(nèi)存大小成正比。通過這種方式可以實現(xiàn)【開源節(jié)流】的效果加快并發(fā)標記
但很顯然這種【借調(diào)】行為會增加垃圾收集對程序運行性能的影響,所以垃圾收集器會努力減少Mark Assist的使用,具體表現(xiàn)在,如果一次GC使用了大量的Mark Assist,則垃圾收集器會提前開始下一次GC周期,通過加快頻率來減少對Mark Assist的使用

標記終止階段

對堆上的對象遍歷標記完成以后,就會進入標記終止階段,此階段需要再一次進行STW以關閉混合寫屏障機制,執(zhí)行各項清理任務,并計算下一次GC的周期。此次STW平均花費60-90微秒
和標記開始階段一樣,如果出現(xiàn)一直難以暫停的goroutine將會導致本階段的STW延長

并發(fā)清掃階段

在并發(fā)清掃階段,并不會一次性將標記出來的白色對象全部清理掉,而是在goroutine嘗試在堆中分配內(nèi)存時觸發(fā),這樣可以將清掃帶來的延遲分散到每一次內(nèi)存分配的時候,避免程序出現(xiàn)過于明顯的卡頓

GC Percentage

運行時中有一個GC Percentage的配置項,默認為100,這個配置的意思是在下次GC必須啟動前,可以分配多少新內(nèi)存的比例。
比如說某次GC后,堆內(nèi)存占用為2M,此設置為100則意思是,當堆內(nèi)存占用達到4M時觸發(fā)GC
那如果我們把設置改為200,那就是堆內(nèi)存占用達到6M時觸發(fā)GC,以此類推
需要注意的是,將這個值調(diào)大并不一定是一個好的選擇,確實我們允許程序占用的空間會變多,這能減少GC的頻率,但也導致每次GC需要掃描和清理的內(nèi)存變大,這會導致GC的耗時變長
一般來說我們還是不建議去改這個配置——事實上大部分情況下你程序的性能瓶頸都不會是他

參考文檔

函數(shù)運行時在內(nèi)存中是什么樣子?
神秘!申請內(nèi)存時底層發(fā)生了什么?
靈魂拷問 Go 語言:這個變量到底分配到哪里了?
[典藏版]Golang三色標記、混合寫屏障GC模式圖文全分析
GO的垃圾回收器
每位 Gopher 都應該了解的 Golang 語言的垃圾回收算法
關于Golang GC的一些誤解--真的比Java GC更領先嗎?

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容