一、什么是GC
垃圾回收(Garbage Collection,簡稱GC)是編程語言中提供的自動(dòng)的內(nèi)存管理機(jī)制,自動(dòng)釋放不需要的對象,讓出存儲器資源,無需程序員手動(dòng)執(zhí)行。
Golang中的垃圾回收主要應(yīng)用三色標(biāo)記法,GC過程和其他用戶goroutine可并發(fā)運(yùn)行,但需要一定時(shí)間的STW(stop the world),STW的過程中,CPU不執(zhí)行用戶代碼,全部用于垃圾回收,這個(gè)過程的影響很大,Golang進(jìn)行了多次的迭代優(yōu)化來解決這個(gè)問題。
1.1 Golang GC 發(fā)展史:
- go1.1,提高效率和垃圾回收精確度。
標(biāo)記-清除(mark and sweep)算法
- go1.3,提高了垃圾回收的精確度。
- go1.4,之前版本的runtime大部分是使用C寫的,這個(gè)版本大量使用Go進(jìn)行了重寫,讓GC有了掃描stack的能力,進(jìn)一步提高了垃圾回收的精確度。
- go1.5,目標(biāo)是降低GC延遲,采用了并發(fā)標(biāo)記和并發(fā)清除,三色標(biāo)記,write barrier,以及實(shí)現(xiàn)了更好的回收器調(diào)度,設(shè)計(jì)文檔1,文檔2,以及2015 版的Go talk。
三色標(biāo)記——“強(qiáng)-弱” 三色不變式、插入屏障、刪除屏障
- go1.6,小優(yōu)化,當(dāng)程序使用大量內(nèi)存時(shí),GC暫停時(shí)間有所降低。
- go1.7,小優(yōu)化,當(dāng)程序有大量空閑goroutine,stack大小波動(dòng)比較大時(shí),GC暫停時(shí)間有顯著降低。
- go1.8,write barrier切換到hybrid write barrier,以消除STW中的re-scan,把STW的最差情況降低到50us,設(shè)計(jì)文檔。
三色標(biāo)記——混合屏障
-
go1.9,提升指標(biāo)比較多,(1)過去
runtime.GC
,debug.SetGCPercent
, 和debug.FreeOSMemory
都不能觸發(fā)并發(fā)GC,他們觸發(fā)的GC都是阻塞的,go1.9可以了,變成了在垃圾回收之前只阻塞調(diào)用GC的goroutine。(2)debug.SetGCPercent
只在有必要的情況下才會觸發(fā)GC。 - go.1.10,小優(yōu)化,加速了GC,程序應(yīng)當(dāng)運(yùn)行更快一點(diǎn)點(diǎn)。
- go1.12,顯著提高了堆內(nèi)存存在大碎片情況下的sweeping性能,能夠降低GC后立即分配內(nèi)存的延遲。
- go1.13,著手解決向操作系統(tǒng)歸還內(nèi)存的,提出了新的 Scavenger
- go1.14,替代了僅存活了一個(gè)版本的 Scavenger,全新的頁分配器,優(yōu)化分配內(nèi)存過程的速率與現(xiàn)有的擴(kuò)展性問題,并引入了異步搶占,解決了由于密集循環(huán)導(dǎo)致的 STW 時(shí)間過長的問題
- ......
二、常見的GC算法
- 引用計(jì)數(shù)法
根據(jù)對象自身的引用計(jì)數(shù)來回收,當(dāng)引用計(jì)數(shù)歸零時(shí)進(jìn)行回收,但是計(jì)數(shù)頻繁更新會帶來更多開銷,且無法解決循環(huán)引用的問題。
- 優(yōu)點(diǎn):簡單直接,回收速度快
- 缺點(diǎn):需要額外的空間存放計(jì)數(shù),無法處理循環(huán)引用的情況;
- 標(biāo)記清除法
標(biāo)記出所有不需要回收的對象,在標(biāo)記完成后統(tǒng)一回收掉所有未被標(biāo)記的對象。
- 優(yōu)點(diǎn):簡單直接,速度快,適合可回收對象不多的場景
- 缺點(diǎn):會造成不連續(xù)的內(nèi)存空間(內(nèi)存碎片),導(dǎo)致有大的對象創(chuàng)建的時(shí)候,明明內(nèi)存中總內(nèi)存是夠的,但是空間不是連續(xù)的造成對象無法分配;
- 復(fù)制法
復(fù)制法將內(nèi)存分為大小相同的兩塊,每次使用其中的一塊,當(dāng)這一塊的內(nèi)存使用完后,將還存活的對象復(fù)制到另一塊去,然后再把使用的空間一次清理掉。
- 優(yōu)點(diǎn):解決了內(nèi)存碎片的問題,每次清除針對的都是整塊內(nèi)存,但是因?yàn)橐苿?dòng)對象需要耗費(fèi)時(shí)間,效率低于標(biāo)記清除法;
- 缺點(diǎn):有部分內(nèi)存總是利用不到,資源浪費(fèi),移動(dòng)存活對象比較耗時(shí),并且如果存活對象較多的時(shí)候,需要擔(dān)保機(jī)制確保復(fù)制區(qū)有足夠的空間可完成復(fù)制;
- 標(biāo)記整理
標(biāo)記過程同標(biāo)記清除法,結(jié)束后將存活對象壓縮至一端,然后清除邊界外的內(nèi)容。
- 優(yōu)點(diǎn):解決了內(nèi)存碎片的問題,也不像標(biāo)記復(fù)制法那樣需要擔(dān)保機(jī)制,存活對象較多的場景也使適用;
- 缺點(diǎn):性能低,因?yàn)樵谝苿?dòng)對象的時(shí)候不僅需要移動(dòng)對象還要維護(hù)對象的引用地址,可能需要對內(nèi)存經(jīng)過幾次掃描才能完成;
- 分代式
將對象根據(jù)存活時(shí)間的長短進(jìn)行分類,存活時(shí)間小于某個(gè)值的為年輕代,存活時(shí)間大于某個(gè)值的為老年代,永遠(yuǎn)不會參與回收的對象為永久代。并根據(jù)分代假設(shè)(如果一個(gè)對象存活時(shí)間不長則傾向于被回收,如果一個(gè)對象已經(jīng)存活很長時(shí)間則傾向于存活更長時(shí)間)對對象進(jìn)行回收。
三、go 1.3之前的標(biāo)記-清除(mark and sweep)算法
此算法主要有兩個(gè)主要的步驟:
- 標(biāo)記(Mark phase)
- 清除(Sweep phase)
第一步,暫停程序業(yè)務(wù)邏輯, 找出不可達(dá)的對象,然后做上標(biāo)記。第二步,回收標(biāo)記好的對象。
操作非常簡單,但是有一點(diǎn)需要額外注意:mark and sweep算法在執(zhí)行的時(shí)候,需要程序暫停!即 STW(stop the world)
。也就是說,這段時(shí)間程序會卡在哪兒。
第二步, 開始標(biāo)記,程序找出它所有可達(dá)的對象,并做上標(biāo)記。如下圖所示:
第三步, 標(biāo)記完了之后,然后開始清除未標(biāo)記的對象. 結(jié)果如下.
第四步, 停止暫停,讓程序繼續(xù)跑。然后循環(huán)重復(fù)這個(gè)過程,直到process程序生命周期結(jié)束。
3.1 標(biāo)記-清掃(mark and sweep)的缺點(diǎn)
- STW,stop the world;讓程序暫停,程序出現(xiàn)卡頓 (重要問題)。
- 標(biāo)記需要掃描整個(gè)heap
- 清除數(shù)據(jù)會產(chǎn)生heap碎片
所以Go V1.3版本之前就是以上來實(shí)施的, 流程是
Go V1.3 做了簡單的優(yōu)化,將STW提前, 減少STW暫停的時(shí)間范圍.如下所示
這里面最重要的問題就是:mark-and-sweep 算法會暫停整個(gè)程序 。
Go是如何面對并這個(gè)問題的呢?接下來G V1.5版本 就用三色并發(fā)標(biāo)記法來優(yōu)化這個(gè)問題.
四、go 1.5的三色并發(fā)標(biāo)記法
三色標(biāo)記法 實(shí)際上就是通過三個(gè)階段的標(biāo)記來確定清楚的對象都有哪些. 我們來看一下具體的過程.
第一步 , 就是只要是新創(chuàng)建的對象,默認(rèn)的顏色都是標(biāo)記為“白色”.
這里面需要注意的是, 所謂“程序”, 則是一些對象的跟節(jié)點(diǎn)集合.
所以上圖,可以轉(zhuǎn)換如下的方式來表示.
第二步, 每次GC回收開始, 然后從根節(jié)點(diǎn)開始遍歷所有對象,把遍歷到的對象從白色集合放入“灰色”集合。
第三步, 遍歷灰色集合,將灰色對象引用的對象從白色集合放入灰色集合,之后將此灰色對象放入黑色集合
第四步, 重復(fù)第三步, 直到灰色中無任何對象.
第五步: 回收所有的白色標(biāo)記表的對象. 也就是回收垃圾.
以上便是三色并發(fā)標(biāo)記法
, 不難看出,我們上面已經(jīng)清楚的體現(xiàn)三色
的特性, 那么又是如何實(shí)現(xiàn)并行的呢?
Go是如何解決標(biāo)記-清除(mark and sweep)算法中的卡頓(stw,stop the world)問題的呢?
4.1 沒有STW的三色標(biāo)記法
我們還是基于上述的三色并發(fā)標(biāo)記法來說, 他是一定要依賴STW的. 因?yàn)槿绻粫和3绦? 程序的邏輯改變對象引用關(guān)系, 這種動(dòng)作如果在標(biāo)記階段做了修改,會影響標(biāo)記結(jié)果的正確性。我們舉一個(gè)場景.
如果三色標(biāo)記法, 標(biāo)記過程不使用STW將會發(fā)生什么事情?
可以看出,有兩個(gè)問題, 在三色標(biāo)記法中,是不希望被發(fā)生的
- 條件1: 一個(gè)白色對象被黑色對象引用(白色被掛在黑色下)
- 條件2: 灰色對象與它之間的可達(dá)關(guān)系的白色對象遭到破壞(灰色同時(shí)丟了該白色)
當(dāng)以上兩個(gè)條件同時(shí)滿足時(shí), 就會出現(xiàn)對象丟失現(xiàn)象!
當(dāng)然, 如果上述中的白色對象3, 如果他還有很多下游對象的話, 也會一并都清理掉.
為了防止這種現(xiàn)象的發(fā)生,最簡單的方式就是STW,直接禁止掉其他用戶程序?qū)ο笠藐P(guān)系的干擾,但是STW的過程有明顯的資源浪費(fèi),對所有的用戶程序都有很大影響,如何能在保證對象不丟失的情況下合理的盡可能的提高GC效率,減少STW時(shí)間呢?
答案就是, 那么我們只要使用一個(gè)機(jī)制,來破壞上面的兩個(gè)條件就可以了.
4.2 屏障機(jī)制
我們讓GC回收器,滿足下面兩種情況之一時(shí),可保對象不丟失. 所以引出兩種方式.
4.2.1 “強(qiáng)-弱” 三色不變式
- 強(qiáng)三色不變式
不存在黑色對象引用到白色對象的指針。
- 弱三色不變式
所有被黑色對象引用的白色對象都處于灰色保護(hù)狀態(tài).
為了遵循上述的兩個(gè)方式,Golang團(tuán)隊(duì)初步得到了如下具體的兩種屏障方式“插入屏障”, “刪除屏障”.
4.2.2 插入屏障
具體操作
: 在A對象引用B對象的時(shí)候,B對象被標(biāo)記為灰色。(將B掛在A下游,B必須被標(biāo)記為灰色)
滿足
: 強(qiáng)三色不變式. (不存在黑色對象引用白色對象的情況了, 因?yàn)榘咨珪?qiáng)制變成灰色)
偽碼如下:
添加下游對象(當(dāng)前下游對象slot, 新下游對象ptr) {
//1
標(biāo)記灰色(新下游對象ptr)
//2
當(dāng)前下游對象slot = 新下游對象ptr
}
場景:
A.添加下游對象(nil, B) //A 之前沒有下游, 新添加一個(gè)下游對象B, B被標(biāo)記為灰色
A.添加下游對象(C, B) //A 將下游對象C 更換為B, B被標(biāo)記為灰色
這段偽碼邏輯就是寫屏障,. 我們知道,黑色對象的內(nèi)存槽有兩種位置, 棧
和堆
. 棧空間的特點(diǎn)是容量小,但是要求相應(yīng)速度快,因?yàn)楹瘮?shù)調(diào)用彈出頻繁使用, 所以“插入屏障”機(jī)制,在棧空間的對象操作中不使用. 而僅僅使用在堆空間對象的操作中.
接下來,我們用幾張圖,來模擬整個(gè)一個(gè)詳細(xì)的過程, 希望您能夠更可觀的看清晰整體流程。
但是如果棧不添加,當(dāng)全部三色標(biāo)記掃描之后,棧上有可能依然存在白色對象被引用的情況(如上圖的對象9). 所以要對棧重新進(jìn)行三色標(biāo)記掃描, 但這次為了對象不丟失, 要對本次標(biāo)記掃描啟動(dòng)STW暫停. 直到棧空間的三色標(biāo)記結(jié)束.
最后將棧和堆空間 掃描剩余的全部 白色節(jié)點(diǎn)清除. 這次STW大約的時(shí)間在10~100ms間.
4.2.3 刪除屏障
具體操作
: 被刪除的對象,如果自身為灰色或者白色,那么被標(biāo)記為灰色。
滿足
: 弱三色不變式. (保護(hù)灰色對象到白色對象的路徑不會斷)
偽代碼:
添加下游對象(當(dāng)前下游對象slot, 新下游對象ptr) {
//1
if (當(dāng)前下游對象slot是灰色 || 當(dāng)前下游對象slot是白色) {
標(biāo)記灰色(當(dāng)前下游對象slot) //slot為被刪除對象, 標(biāo)記為灰色
}
//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之前為白)
接下來,我們用幾張圖,來模擬整個(gè)一個(gè)詳細(xì)的過程, 希望您能夠更可觀的看清晰整體流程。
這種方式的回收精度低,一個(gè)對象即使被刪除了最后一個(gè)指向它的指針也依舊可以活過這一輪,在下一輪GC中被清理掉。
五、go 1.8的混合寫屏障(hybrid write barrier)機(jī)制
插入寫屏障和刪除寫屏障的短板:
- 插入寫屏障:結(jié)束時(shí)需要STW來重新掃描棧,標(biāo)記棧上引用的白色對象的存活;
- 刪除寫屏障:回收精度低,GC開始時(shí)STW掃描堆棧來記錄初始快照,這個(gè)過程會保護(hù)開始時(shí)刻的所有存活對象。
Go V1.8版本引入了混合寫屏障機(jī)制(hybrid write barrier),避免了對棧re-scan的過程,極大的減少了STW的時(shí)間。結(jié)合了兩者的優(yōu)點(diǎn)。
5.1 混合寫屏障規(guī)則
具體操作
:
1、GC開始將棧上的對象全部掃描并標(biāo)記為黑色(之后不再進(jìn)行第二次重復(fù)掃描,無需STW),
2、GC期間,任何在棧上創(chuàng)建的新對象,均為黑色。
3、被刪除的對象標(biāo)記為灰色。
4、被添加的對象標(biāo)記為灰色。
滿足
: 變形的弱三色不變式.
偽代碼:
添加下游對象(當(dāng)前下游對象slot, 新下游對象ptr) {
//1
標(biāo)記灰色(當(dāng)前下游對象slot) //只要當(dāng)前下游對象被移走,就標(biāo)記灰色
//2
標(biāo)記灰色(新下游對象ptr)
//3
當(dāng)前下游對象slot = 新下游對象ptr
}
這里我們注意, 屏障技術(shù)是不在棧上應(yīng)用的,因?yàn)橐WC棧的運(yùn)行效率。
5.2 混合寫屏障的具體場景分析
接下來,我們用幾張圖,來模擬整個(gè)一個(gè)詳細(xì)的過程, 希望您能夠更可觀的看清晰整體流程。
注意混合寫屏障是Gc的一種屏障機(jī)制,所以只是當(dāng)程序執(zhí)行GC的時(shí)候,才會觸發(fā)這種機(jī)制。
GC開始:掃描棧區(qū),將可達(dá)對象全部標(biāo)記為黑
5.2.1 場景一: 對象被一個(gè)堆對象刪除引用,成為棧對象的下游
偽代碼
//前提:堆對象4->對象7 = 對象7; //對象7 被 對象4引用
棧對象1->對象7 = 堆對象7; //將堆對象7 掛在 棧對象1 下游
堆對象4->對象7 = null; //對象4 刪除引用 對象7
5.2.2 場景二: 對象被一個(gè)棧對象刪除引用,成為另一個(gè)棧對象的下游
偽代碼
new 棧對象9;
對象8->對象3 = 對象3; //將棧對象3 掛在 棧對象9 下游
對象2->對象3 = null; //對象2 刪除引用 對象3
5.2.3 場景三:對象被一個(gè)堆對象刪除引用,成為另一個(gè)堆對象的下游
偽代碼
堆對象10->對象7 = 堆對象7; //將堆對象7 掛在 堆對象10 下游
堆對象4->對象7 = null; //對象4 刪除引用 對象7
5.2.4 場景四:對象從一個(gè)棧對象刪除引用,成為另一個(gè)堆對象的下游
偽代碼
堆對象10->對象7 = 堆對象7; //將堆對象7 掛在 堆對象10 下游
堆對象4->對象7 = null; //對象4 刪除引用 對象7
Golang中的混合寫屏障滿足弱三色不變式
,結(jié)合了刪除寫屏障和插入寫屏障的優(yōu)點(diǎn),只需要在開始時(shí)并發(fā)掃描各個(gè)goroutine的棧,使其變黑并一直保持,這個(gè)過程不需要STW,而標(biāo)記結(jié)束后,因?yàn)闂T趻呙韬笫冀K是黑色的,也無需再進(jìn)行re-scan操作了,減少了STW的時(shí)間。
六、垃圾回收過程
6.1 Marking setup
為了打開寫屏障,必須停止每個(gè)goroutine,讓垃圾收集器觀察并等待每個(gè)goroutine進(jìn)行函數(shù)調(diào)用, 等待函數(shù)調(diào)用是為了保證goroutine停止時(shí)處于安全點(diǎn)。
// 如果goroutine4 處于如下循環(huán)中,運(yùn)行時(shí)間取決于slice numbers的大小
func add(numbers []int) int {
var v int
for _, n := range numbers {
v += n
}
return v
}
下面的代碼中,由于for{}
循環(huán)所在的goroutine 永遠(yuǎn)不會中斷,導(dǎo)致始終無法進(jìn)入STW階段,資源浪費(fèi);Go 1.14 之后,此類goroutine 能被異步搶占,使得進(jìn)入STW的時(shí)間不會超過搶占信號觸發(fā)的周期,程序也不會因?yàn)閮H僅等待一個(gè)goroutine的停止而停頓在進(jìn)入STW之前的操作上。
func main() {
go func() {
for {
}
}()
time.Sleep(time.Milliecond)
runtime.GC()
println("done")
}
6.2 Marking
一旦寫屏障打開,垃圾收集器就開始標(biāo)記階段,垃圾收集器所做的第一件事是占用25%CPU。
標(biāo)記階段需要標(biāo)記在堆內(nèi)存中仍然在使用中的值。首先檢查所有現(xiàn)goroutine的堆棧,以找到堆內(nèi)存的根指針。然后收集器必須從那些根指針遍歷堆內(nèi)存圖,標(biāo)記可以回收的內(nèi)存。
當(dāng)存在新的內(nèi)存分配時(shí),會暫停分配內(nèi)存過快的那些 goroutine,并將其轉(zhuǎn)去執(zhí)行一些輔助標(biāo)記(Mark Assist)的工作,從而達(dá)到放緩繼續(xù)分配、輔助 GC 的標(biāo)記工作的目的。
6.3 Mark終止
關(guān)閉寫屏障,執(zhí)行各種清理任務(wù)(STW - optional )
6.4 Sweep (清理)
清理階段用于回收標(biāo)記階段中標(biāo)記出來的可回收內(nèi)存。當(dāng)應(yīng)用程序goroutine嘗試在堆內(nèi)存中分配新內(nèi)存時(shí),會觸發(fā)該操作,清理導(dǎo)致的延遲和吞吐量降低被分散到每次內(nèi)存分配時(shí)。
清除階段出現(xiàn)新對象:
清除階段是掃描整個(gè)堆內(nèi)存,可以知道當(dāng)前清除到什么位置,創(chuàng)建的新對象判定下,如果新對象的指針位置已經(jīng)被掃描過了,那么就不用作任何操作,不會被誤清除,如果在當(dāng)前掃描的位置的后面,把該對象的顏色標(biāo)記為黑色,這樣就不會被誤清除了
什么時(shí)候進(jìn)行清理?
主動(dòng)觸發(fā)(runtime.GC()) 被動(dòng)觸發(fā) (GC百分比、定時(shí))
七、關(guān)注指標(biāo)與調(diào)優(yōu)示例
7.1 關(guān)注指標(biāo)
Go 的 GC 被設(shè)計(jì)為成比例觸發(fā)、大部分工作與賦值器并發(fā)、不分代、無內(nèi)存移動(dòng)且會主動(dòng)向操作系統(tǒng)歸還申請的內(nèi)存。因此最主要關(guān)注的、能夠影響賦值器的性能指標(biāo)有:
- CPU 利用率:回收算法會在多大程度上拖慢程序?有時(shí)候,這個(gè)是通過回收占用的 CPU 時(shí)間與其它 CPU 時(shí)間的百分比來描述的。
- GC 停頓時(shí)間:回收器會造成多長時(shí)間的停頓?目前的 GC 中需要考慮 STW 和 Mark Assist 兩個(gè)部分可能造成的停頓。
- GC 停頓頻率:回收器造成的停頓頻率是怎樣的?目前的 GC 中需要考慮 STW 和 Mark Assist 兩個(gè)部分可能造成的停頓。
- GC 可擴(kuò)展性:當(dāng)堆內(nèi)存變大時(shí),垃圾回收器的性能如何?但大部分的程序可能并不一定關(guān)心這個(gè)問題。
7.2 調(diào)優(yōu)示例
7.2.1 合理化內(nèi)存分配的速度、提高賦值器的 CPU 利用率
goroutine 的執(zhí)行時(shí)間占其生命周期總時(shí)間非常短的一部分,但大部分時(shí)間都花費(fèi)在調(diào)度器的等待上了,說明同時(shí)創(chuàng)建大量 goroutine 對調(diào)度器產(chǎn)生的壓力確實(shí)不小,我們不妨將這一產(chǎn)生速率減慢,一批一批地創(chuàng)建 goroutine。
func concat() {
for n := 0; n < 800; n++ {
go func() {
s := "Go GC"
s += " " + "Hello"
s += " " + "World"
_ = s
}()
}
}
//改進(jìn)
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()
}
}
7.2.2 降低并復(fù)用已經(jīng)申請的內(nèi)存
newBuf()產(chǎn)生的申請的內(nèi)存過多, sync.Pool 是內(nèi)存復(fù)用的一個(gè)最為顯著的例子
func newBuf() []byte {
return make([]byte, 10<<20)
}
b := newBuf()
//改進(jìn)
var bufPool = sync.Pool{
New: func() interface{} {
return make([]byte, 10<<20)
},
}
b := bufPool.Get().([]byte)
7.2.3 調(diào)整 GOGC
降低收集器的啟動(dòng)頻率(提高GC百分比)無法幫助垃圾收集器更快完成收集工作。降低頻率會導(dǎo)致垃圾收集器在收集期間完成更多的工作。 可以通過減少新分配對象數(shù)量來幫助垃圾收集器更快完成收集工作。
7.3 小結(jié)
控制內(nèi)存分配的速度,限制 goroutine 的數(shù)量,從而提高賦值器對 CPU 的利用率。
減少并復(fù)用內(nèi)存,例如使用 sync.Pool 來復(fù)用需要頻繁創(chuàng)建臨時(shí)對象,例如提前分配足夠的內(nèi)存來降低多余的拷貝。
需要時(shí),增大 GOGC 的值,降低 GC 的運(yùn)行頻率。