Golang-垃圾回收原理解析

go 垃圾回收

本文基於整個go的gc發展,來研究其gc的演變過程,不單打針對某個版本的gc,因爲go的gc一直在演變

一.GO GC的發展歷史

  • go v1.1 : 標記清除法,整個過程都需要STW
  • go v1.3 : 標記清除法,標記過程仍然需要STW,但是清除過程並行化,gc pause約爲幾百ms
  • go v1.5 : 引入插入寫屏障技術的三色標記法,僅在堆空間啓動插入寫屏障,全部掃描後需要STW重新掃描棧空間,gc pause耗時降到10ms以下
  • go v1.8 : 引入混合寫屏障技術的三色標記法,僅在堆空間啓動混合寫屏障,不需要在GC 結束後對棧空間重新掃描,gc pause時間降至0.5ms以下
  • go 1.1.4 引入新的頁分配器用於優化內存分配的速度

二. 常用GC算法

總共分爲三類:

  • 引用計數法
  • 追蹤式法
    • 可達性分析法
    • 標記-複製法
    • 標記-清除法
    • 標記-整理法
    • 三色標記法
  • 分代收集法

1.引用計數

引用計數會爲每個對象維護一個計數器,當該對象被其他對象引用時加1,引用失效時減1,當引用次數爲0後即可回收對象

優點:

  • 原理和實現都比較簡單
  • 回收及時性高,引用計數爲0則立即回收,不需要想其他GC機制需要等待特定的時間回收
  • 不需要暫停應用即可完成回收

缺點:

  • 無法解決循環引用的問題
  • 時間和空間成本高:每個對象需要額外的空間來存儲引用計數,在棧上修改引用計數的時間成本高(因爲需要額外的原子操作來保證線程安全)
  • 無法保證耗時:引用計數是一種攤銷算法,會將內存的回收分攤到整個程序的運行過程,當銷燬一個很大的樹形結構時無法保證響應時間

2.可達性分析算法

該算法屬於追蹤式算法,目的是回收不可達對象,可達對象主要包括兩類:

  • GC root對象: 包括全局對象,棧上對象
  • 與GC root對象通過引用鏈相連的對象
    對於不可達對象,我們認爲該對象爲垃圾對象,應該被回收

同上述的引用計數法相比,追蹤式算法具有如下優點:

  • 解決了循環引用的問題
  • 佔用的空間少了

和引用計數法相比,也有以下缺點:

  • 無法立刻識別出垃圾對象,需要依賴GC線程
  • 算法在標記時必須暫停整個程序,即STW(stop the world),否則其他線程有可能會修改對象的狀態從而回收不該被回收的對象

3.標記-複製算法

主要分爲標記和複製兩個步驟:

  • 標記: 記錄需要回收的垃圾對象
  • 複製: 將內存分爲大小相同的兩塊,當某一塊的內存使用完了之後就將使用中的對象挨個複製到另一塊內存中,最後將當前內存恢復爲未使用狀態

優點:

  • 不用進行大量垃圾對象的掃描:標記-複製算法需要從GC-root對象出發,將可達的對象複製到另外一塊內存後直接清理當前這塊的內存即可
  • 解決了內存碎片問題,防止分配大空間對象時提前gc的問題

缺點:

  • 複製成本問題:在可達對象佔用內存高的時候,複製成本會很高
  • 內存利用率低:相當於可利用的內存僅有一半

4.標記-清除算法

主要分爲標記和清除兩個步驟

  • 標記:記錄需要回收的垃圾對象
  • 清除:在標記完成後回收垃圾對象的內存空間

優點:

  • 算法吞吐量高,即運行用戶代碼時間/(運行用戶代碼時間+運行垃圾收集時間)
  • 空間利用率高:和標記複製相比,不需要額外的空間複製對象,和引用計數法相比,也不需要額外的空間設置計數器

缺點:

  • 內存碎片問題:清除後會產生大量的內存碎片,導致程序在運行時無法爲大對象分配內存空間,從而導致提前進行下一次GC

5.標記-整理算法

標記出所有可達對象,然後將可達對象移動到空間的另外一段,最後清理掉邊界以外的內存

優點:

  • 避免了內存碎片化的問題
  • 適合老年代算法:老年代對象存活率高的情況下,標記整理算法由於不需要複製對象,效率更高

缺點:

  • 整理的過程複雜:需要多長遍歷內存,導致STW時間比標記清除算法高

6.三色標記算法

前面的標記-x類算法都有一個問題,那就是STW(即gc時暫停整個應用程序),三色標記法是對標記階段進行改進的算法
目的是在不暫停程序的情況下即可完成對象的可達性分析,gc線程將所有對象分爲三類:

  • 白色對象:未搜索的對象,在回收週期開始時所有對象都是白色,在回收週期結束時,所有對象都是垃圾回收對象
  • 灰色對象:正在搜索的對象,但是對象身上還有一個或多個引用沒有掃描
  • 黑色對象:已搜索完成的對象,所有的引用已被掃描完

三色標記算法屬於增量式GC算法,回收器首先將所有對象着色成白色,然後從gc root出發,逐步把所有可達的對象變成灰色再到黑色,最終所有的白色對象都是不可達對象

具體實現:

  • 初始時所有對象都是白色的
  • 從gc root對象出發,掃描所有可達對象並標記爲灰色,放入待處理隊列
  • 從隊列取出一個灰色對象並標記爲黑色,將其引用對象標記爲灰色,放入隊列
  • 重複上一步驟,直到灰色對象隊列爲空
  • 此時剩下的所有白色對象都是垃圾對象

優點:

  • 不需要STW

缺點:

  • 如果產生垃圾速度大於回收速度時,可能會導致程序中垃圾對象越來越多而無法及時收集
  • 線程切換和上下文轉換的消耗會使得垃圾回收的總體成本上升,從而降低系統吞吐量

三色標記法存在併發性問題,

  • 可能會出現野指針(指向沒有合法地址的指針),從而造成嚴重的程序錯誤
  • 錯誤的回收非垃圾對象

7.分代收集算法

分代收集算法按照對象生命週期的長短劃分到不同分區

  • 對於生命週期短的新生代區域,每次回收僅需要考慮如何保留少量存活對象,因此可以採用標記-複製法完成GC
  • 對於生命週期長的老年代區域,可以通過減少gc的頻率來提供效率,同時由於對象存活率高沒有額外的空間用於複製,因此一般可以使用標記清除法或標記整理法

這樣劃分,堆就分成了Young和Old兩個分區,因此GC也分爲新生代GC和老年代GC

對象的分配策略:

  • 對象優先在新生代上Eden區域分配
  • 大對象直接進入老年代
  • 新生代中週期較長的對象在s0或s1區每經過一次新生代Gc,就增加一歲,增加到一定閾值的時候,就進入老年代區域

三.屏障技術

要解決三色標記法的併發性問題,有兩種思路

  • STW,保證標記過程不受干擾
  • 使用賦值器屏障技術,在進行指針寫操作時同步垃圾回收器

內存讀寫屏障技術:指編譯器在編譯期間會生成一段代碼,該代碼在運行期間,用戶讀取,創建或更新對象指針時會攔截內存讀寫操作,相當於一個hook調用,根據hook時機的不同可以分爲不同的屏障技術

  • 讀屏障技術:在讀操作中插入代碼片段(會影響用戶程序性能,不建議使用)
  • 寫屏障技術:在寫操作中插入代碼片段

1.迪傑斯特拉 插入寫屏障

  • 防止黑色對象指向白色對象(把所有黑色對象指向的灰色對象和白色對象都變爲黑色)
  • 不適用於棧空間(棧容量小,要求速度高)

可以實現GC和用戶程序並行,但是仍存在兩個缺點:

  • 過於保守,可能會導致某些垃圾對象不被回收
  • 對棧上對象來說,迪傑斯特拉插入寫屏障要麼在用戶程序執行內存寫操作時爲棧上對象插入寫屏障,要麼在一輪三色標記完成後使用STW重新對棧上對象進行三色標記,前者會降低棧空間響應速度,後者會暫停應用程序

2.Yuasa 刪除寫屏障

  • 防止丟失灰色對象到白色對象的可達路徑(當刪除對象A指向對象B的指針時,將被刪除對象標記爲灰色)

和迪傑斯特拉插入寫屏障相比,Yuasa刪除寫屏障的優點,在於不需要在第一輪三色標記後對棧上空間對象重新掃描,其缺點在於會悲觀的認爲所有刪除的對象都可能被黑色對象引用,會導致本該刪除的垃圾對象會在本輪存活

3.混合寫屏障

引入混合寫屏障的原因:

在go v1.8引入混合寫屏障之前,由於GC root對象包括了棧對象,如果運行時所有GC root對象上開啓插入寫屏障,意味着需要在數量龐大的Goroutine的棧上都開啓迪傑斯特拉寫屏障從而嚴重影響用戶程序的性能,
之前的做法是在標記階段結束後暫停整個程序,對棧上對象重新進行三色標記

回顧一下前面提到的兩種屏障算法的劣勢:

  • 迪傑斯特拉插入寫屏障,一輪標記結束後需要STW重新掃描棧上對象
  • Yuasa 刪除寫屏障,回收精度低

混合寫屏障也僅是在堆上啓動

混合寫屏障邏輯:

  • GC開始時將棧上所有對象標記爲黑色,無須STW
  • GC期間在棧上創建的新對象均標記爲黑色
  • 將被刪除的下游對象標記爲灰色
  • 將被添加的下游對象標記爲灰色

四.增量和併發式垃圾回收

前面提到的傳統GC算法都會STW,這存在兩個嚴重的弊端

  • 對實時性程序來說,很致命
  • 對多核計算機來說,會造成計算資源的浪費

三色標記法結合寫屏障技術使得GC避免了STW,因此後面的增量式GC和併發式GC都是基於三色標記和寫屏障技術的

1.增量式GC

  • 分攤GC時間,避免程序長時間暫停
  • 內存屏障技術,需要額外時間開銷,並且由於內存屏障技術的保守性,一些垃圾對象不會被回收,會增加一輪gc的總時長

2.併發式GC

  • 運行GC和用戶程序並行
  • 一定程度上利用多核計算機的優勢減少了對用戶程序的干擾,不該寫屏障的額外開銷和保守性問題仍然存在,這是不可避免的

go v1.5至今都是基於三色標記法實現的併發式GC,將長時間的STW分爲分割爲多段短的STW,GC大部分執行過程都是和用戶代碼並行的

關於輔助GC

  • 當用戶分配內存的速度超過gc回收速度時,golang會通過輔助GC暫停用戶程序進行gc,避免內存耗盡問題

關於gc觸發時間

  • 堆內存到達一定閾值
  • 距離上次gc超過一定閾值
  • 如果當前沒有啓動gc,則開始新一輪gc

關於gc調優

  • 儘量將小對象組合成大對象
  • 儘量使用小數據類型
  • 大量string拼接時使用string.join,而不是+號(go中string只讀,每一個針對string的操作都會創建一個新的string)

五.寫在最後

雖然go有gc,但gc也不是萬能的,儘量手動釋放不需要的內存,比如對象置爲nil(輔助進行gc),比如將slice置爲nil,就可以釋放其底層引用的數組,或者在合適的時候調用runtime.GC()來觸發gc

Reference

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章