Go 語言內(nèi)存管理(四):垃圾回收

介紹

編寫 Go 代碼不需要像寫 C/C++ 那樣手動(dòng)的 mallocfree內(nèi)存吃媒,因?yàn)?malloc 操作由 Go 編譯器的逃逸分析機(jī)制幫我們加上了漾月,而 free 動(dòng)作則是有 GC 機(jī)制來完成钮热。

雖說 GC 是一個(gè)很好的特性性置,大大降低了編程門檻西采,但這是以損耗性能為代價(jià)的。Go 的 GC 機(jī)制是不斷進(jìn)化提升的链沼,到現(xiàn)在也沒有停止乱豆。其進(jìn)化過程中主要有一下幾個(gè)重要的里程碑:

  • 1.1 版本: 標(biāo)記+清除方式疲酌,整個(gè)過程需要 STW(stop the world怀浆,掛起所有用戶 goroutine)
  • 1.3 版本: 標(biāo)記過程 STW首懈,清除過程并行
  • 1.5 版本: 標(biāo)記過程使用三色標(biāo)記法
  • 1.8 版本: Hibrid Write Barrier
  • 未來: 類似 JVM 的分代機(jī)制?

下面詳細(xì)介紹下這整個(gè)演進(jìn)過程。

標(biāo)記清除

垃圾回收的算法很多称开,比如最常見的引用計(jì)數(shù)鳖轰,節(jié)點(diǎn)復(fù)制等等。Go 采用的是標(biāo)記清除方式。當(dāng) GC 開始時(shí),從 root 開始一層層掃描场仲,這里的 root 區(qū)值當(dāng)前所有 goroutine 的棧和全局?jǐn)?shù)據(jù)區(qū)的變量(主要是這 2 個(gè)地方)燕差。掃描過程中把能被觸達(dá)的 object 標(biāo)記出來,那么堆空間未被標(biāo)記的 object 就是垃圾了;最后遍歷堆空間所有 object 對(duì)垃圾(未標(biāo)記)的 object 進(jìn)行清除旋廷,清除完成則表示 GC 完成。清除的 object 會(huì)被放回到 mcache 中以備后續(xù)分配使用礼搁。

我在 Go 語言內(nèi)存管理(二):Go 內(nèi)存管理 提到過饶碘,Go 的內(nèi)存區(qū)域中有一個(gè) bitmap 區(qū)域,就是用來存儲(chǔ) object 標(biāo)記的馒吴。

最開始 Go 的整個(gè) GC 過程需要 STW扎运,因?yàn)橛脩暨M(jìn)程如果在 GC 過程中修改了變量的引用關(guān)系,可能會(huì)導(dǎo)致清理錯(cuò)誤饮戳。舉個(gè)例子豪治,我們假設(shè)下面的變量使用堆空間:

A := new(struct {
    B *int
})

如果 GC 已經(jīng)掃描完了變量 A,并對(duì) AB 進(jìn)行了標(biāo)記扯罐,如果沒有 STW负拟,在執(zhí)行清除之前,用戶線程有可能會(huì)執(zhí)行 A.B = new(int)歹河,那么這個(gè)新對(duì)象 new(int) 會(huì)因?yàn)闆]有標(biāo)記而被清除掩浙。

Go GC 的 STW 曾經(jīng)是大家吐槽的焦點(diǎn),因?yàn)樗?jīng)常使你的系統(tǒng)卡住秸歧,造成幾百毫秒延遲厨姚。

并行清除

這個(gè)優(yōu)化很簡(jiǎn)單,如上面所述键菱,STW 是為了阻止標(biāo)記的錯(cuò)誤遣蚀,那么只需對(duì)標(biāo)記過程進(jìn)行 STW,確保標(biāo)記正確纱耻。清除過程是不需要 STW 的。

標(biāo)記清除算法致命的缺點(diǎn)就在 STW 上险耀,所以 Golang 后期的很多優(yōu)化都是針對(duì) STW 的弄喘,盡可能縮短它的時(shí)間,避免出現(xiàn) Go 服務(wù)的卡頓甩牺。

三色標(biāo)記法

為了能讓標(biāo)記過程也能并行蘑志,Go 采用了三色標(biāo)記 + 寫屏障的機(jī)制。它的步驟大致如下:

  1. GC 開始時(shí)贬派,認(rèn)為所有 object 都是白色急但,即垃圾。
  2. 從 root 區(qū)開始遍歷搞乏,被觸達(dá)的 object 置成灰色波桩。
  3. 遍歷所有灰色 object,將他們內(nèi)部的引用變量置成 灰色请敦,自身置成 黑色
  4. 循環(huán)第 3 步镐躲,直到?jīng)]有灰色 object 了储玫,只剩下了黑白兩種,白色的都是垃圾萤皂。
  5. 對(duì)于黑色 object撒穷,如果在標(biāo)記期間發(fā)生了寫操作,寫屏障會(huì)在真正賦值前將新對(duì)象標(biāo)記為灰色裆熙。
  6. 標(biāo)記過程中端礼,mallocgc 新分配的 object,會(huì)先被標(biāo)記成黑色再返回入录。

示意圖:

還有一種情況蛤奥,標(biāo)記過程中,堆上的 object 被賦值給了一個(gè)棧上指針纷跛,導(dǎo)致這個(gè) object 沒有被標(biāo)記到喻括。因?yàn)閷?duì)棧上指針進(jìn)行寫入,寫屏障是檢測(cè)不到的贫奠。下圖展示了整個(gè)流程(其中 L 是棧上指針):

為了解決這個(gè)問題唬血,標(biāo)記的最后階段,還會(huì)回頭重新掃描一下所有的椈秸福空間拷恨,確保沒有遺漏。而這個(gè)過程就需要啟動(dòng) STW 了谢肾,否則并發(fā)場(chǎng)景會(huì)使上述場(chǎng)景反復(fù)重現(xiàn)腕侄。

整個(gè) GC 流程如下圖所示:

解釋下:

  1. 正常情況下,寫操作就是正常的賦值芦疏。
  2. GC 開始冕杠,開啟寫屏障等準(zhǔn)備工作。開啟寫屏障等準(zhǔn)備工作需要短暫的 STW酸茴。
  3. Stack scan 階段分预,從全局空間和 goroutine 棧空間上收集變量薪捍。
  4. Mark 階段笼痹,執(zhí)行上述的三色標(biāo)記法,直到?jīng)]有灰色對(duì)象酪穿。
  5. Mark termination 階段凳干,開啟 STW,回頭重新掃描 root 區(qū)域新變量被济,對(duì)他們進(jìn)行標(biāo)記救赐。
  6. Sweep 階段,關(guān)閉 STW 和 寫屏障只磷,對(duì)白色對(duì)象進(jìn)行清除净响。

Hibrid Write Barrier

三色標(biāo)記方式少欺,需要在最后重新掃描一下所有全局變量和 goroutine 棧空間馋贤,如果系統(tǒng)的 goroutine 很多赞别,這個(gè)階段耗時(shí)也會(huì)比較長,甚至?xí)L達(dá) 100ms配乓。畢竟 Goroutine 很輕量仿滔,大型系統(tǒng)中,上百萬的 Goroutine 也是常有的事兒犹芹。

上面說對(duì)棧上指針進(jìn)行寫入崎页,寫屏障是檢測(cè)不到,實(shí)際上并不是做不到腰埂,而是代價(jià)非常高飒焦,Go 的寫屏障故意沒去管它,而是采取了再次掃描的方案屿笼。

Go 在 1.8 版本引入了混合寫屏障牺荠,其會(huì)在賦值前,對(duì)舊數(shù)據(jù)置灰驴一,再視情況對(duì)新值進(jìn)行置灰休雌。大致如下圖所示:

這樣就不需要在最后回頭重新掃描所有 Goroutine 的棧空間了肝断,這使得整個(gè) GC 過程 STW 幾乎可以忽略不計(jì)了杈曲。

寫屏障的偽代碼如下(看不懂可忽略):

writePointer(slot, ptr):  // 1.8 之前
    shade(ptr)
    *slot = ptr

writePointer(slot, ptr): // 1.8 之后
    shade(*slot)
    if current stack is grey:
        shade(ptr)
    *slot = ptr

混合寫屏障會(huì)有一點(diǎn)小小的代價(jià),就是上例中如果 C 沒有賦值給 L胸懈,用戶執(zhí)行 B.next = nil 后担扑,C 的的確確變成了垃圾,而我們卻把置灰了趣钱,使得 C 只能等到下一輪 GC 才能被回收了涌献。

GC 過程創(chuàng)建的新對(duì)象直接標(biāo)記成黑色也會(huì)帶來這個(gè)問題,即使新 object 在掃描結(jié)束前變成了垃圾羔挡,這次 GC 也不會(huì)回收它,只能等下輪间唉。

何時(shí)觸發(fā) GC

一般是當(dāng) Heap 上的內(nèi)存達(dá)到一定數(shù)值后绞灼,會(huì)觸發(fā)一次 GC,這個(gè)數(shù)值我們可以通過環(huán)境變量 GOGC 或者 debug.SetGCPercent() 設(shè)置呈野,默認(rèn)是 100低矮,表示當(dāng)內(nèi)存增長 100% 執(zhí)行一次 GC。如果當(dāng)前堆內(nèi)存使用了 10MB被冒,那么等到它漲到 20MB 的時(shí)候就會(huì)觸發(fā) GC军掂。

再就是每隔 2 分鐘轮蜕,如果期間內(nèi)沒有觸發(fā) GC,也會(huì)強(qiáng)制觸發(fā)一次蝗锥。

最后就是用戶手動(dòng)觸發(fā)了跃洛,也就是調(diào)用 runtime.GC() 強(qiáng)制觸發(fā)一次。

其他優(yōu)化

掃描過程最多使用 25% 的 CPU 進(jìn)行標(biāo)記终议,這是為了盡可能降低 GC 過程對(duì)用戶的影響汇竭。而如果 GC 未完成,下一輪 GC 又觸發(fā)了穴张,系統(tǒng)會(huì)等待上一輪 GC 結(jié)束细燎。

對(duì)于 tiny 對(duì)象,標(biāo)記階段是直接標(biāo)記成黑色了皂甘,沒有灰色階段玻驻。因?yàn)?tiny 對(duì)象是不存放引用類型數(shù)據(jù)(指針)的,這個(gè)在 Go 語言內(nèi)存管理(二):Go 內(nèi)存管理 提到過偿枕,沒必要標(biāo)記成灰色再檢查一遍璧瞬。

結(jié)論

Go 的 GC 會(huì)不斷演進(jìn),盡管現(xiàn)在1.12版本跟幾年前的版本已經(jīng)有了很大的提升了益老,但 GC 仍然是大家吐槽的焦點(diǎn)之一彪蓬。作為用戶能做的就是盡可能在代碼上避開 GC(如果有這個(gè)必要),比如盡量少用存在多級(jí)引用的數(shù)據(jù)結(jié)構(gòu)捺萌,比如 chan map[string][]*string 這種糟糕的數(shù)據(jù)結(jié)構(gòu)档冬。引用層級(jí)越多,GC 的成本也就越高桃纯。

估計(jì) Go 后續(xù)也會(huì)引入分代機(jī)制的酷誓,個(gè)人認(rèn)為這會(huì)很大程度提升 GC 效率。我在 Go 語言內(nèi)存管理(二):Go 內(nèi)存管理 提到過金字塔模型态坦,分代機(jī)制本質(zhì)上就是構(gòu)造金字塔結(jié)構(gòu)盐数,將 GC 工作分成幾級(jí)來完成。像 JVM 那樣將內(nèi)存分成新生代伞梯,老生代玫氢,永生代,不同生代投入不同的計(jì)算資源谜诫。
現(xiàn)在這樣每次都要全局掃描所有對(duì)象漾峡,進(jìn)行標(biāo)記回收,效率確實(shí)不怎么高喻旷。

我曾在一些項(xiàng)目中使用全局對(duì)象池的方案生逸,企圖降低內(nèi)存分配回收壓力,但效果一般,雖然 mallocgcgcSweep 不怎么吃 CPU 了槽袄,但 gcMark 壓力變大烙无,成了無解的存在。如果可以將對(duì)象池放到老生代中遍尺,不讓 GC 頻繁的對(duì)其掃描截酷,相信性能會(huì)有較大的提升。

還有種方法是直接申請(qǐng)一塊大內(nèi)存空間(大于32K)狮鸭,這樣對(duì)于 GC 來說它就是一個(gè) largespan合搅;但對(duì)這個(gè)大空間的分配使用就需要我們自己寫代碼管理了,我們將會(huì)遇到和操作系統(tǒng)內(nèi)存管理類似的問題歧蕉,比如內(nèi)存碎片灾部,指針問題,并發(fā)問題等等惯退,非常麻煩赌髓,寫得不好性能反而會(huì)更差。好在已有成熟的開源項(xiàng)目 freecachebigcache 可直接使用催跪。

參考

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末锁蠕,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子懊蒸,更是在濱河造成了極大的恐慌荣倾,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,372評(píng)論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件骑丸,死亡現(xiàn)場(chǎng)離奇詭異舌仍,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)通危,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門铸豁,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人菊碟,你說我怎么就攤上這事节芥。” “怎么了逆害?”我有些...
    開封第一講書人閱讀 162,415評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵头镊,是天一觀的道長。 經(jīng)常有香客問我魄幕,道長相艇,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,157評(píng)論 1 292
  • 正文 為了忘掉前任梅垄,我火速辦了婚禮厂捞,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘队丝。我一直安慰自己靡馁,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,171評(píng)論 6 388
  • 文/花漫 我一把揭開白布机久。 她就那樣靜靜地躺著臭墨,像睡著了一般。 火紅的嫁衣襯著肌膚如雪膘盖。 梳的紋絲不亂的頭發(fā)上胧弛,一...
    開封第一講書人閱讀 51,125評(píng)論 1 297
  • 那天,我揣著相機(jī)與錄音侠畔,去河邊找鬼结缚。 笑死,一個(gè)胖子當(dāng)著我的面吹牛软棺,可吹牛的內(nèi)容都是我干的红竭。 我是一名探鬼主播,決...
    沈念sama閱讀 40,028評(píng)論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼喘落,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼茵宪!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起瘦棋,我...
    開封第一講書人閱讀 38,887評(píng)論 0 274
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后滚婉,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體豁陆,經(jīng)...
    沈念sama閱讀 45,310評(píng)論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,533評(píng)論 2 332
  • 正文 我和宋清朗相戀三年箕慧,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了服球。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,690評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡颠焦,死狀恐怖斩熊,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情伐庭,我是刑警寧澤粉渠,帶...
    沈念sama閱讀 35,411評(píng)論 5 343
  • 正文 年R本政府宣布,位于F島的核電站圾另,受9級(jí)特大地震影響霸株,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜集乔,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,004評(píng)論 3 325
  • 文/蒙蒙 一去件、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦尤溜、人聲如沸倔叼。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽丈攒。三九已至,卻和暖如春授霸,著一層夾襖步出監(jiān)牢的瞬間巡验,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評(píng)論 1 268
  • 我被黑心中介騙來泰國打工碘耳, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留显设,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 47,693評(píng)論 2 368
  • 正文 我出身青樓辛辨,卻偏偏與公主長得像敷硅,于是被迫代替她去往敵國和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子愉阎,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,577評(píng)論 2 353

推薦閱讀更多精彩內(nèi)容