垃圾回收(GC)淺談

關於內存

計算機經過兩個機制,去實現內存的高效使用。java

第一種機制是虛擬內存。硬盤的容量實際上是遠遠大於內存的(RAM),虛擬內存會在內存不足的時候,把不常常訪問的內存的數據寫到硬盤裏。雖說硬盤容量比較大,可是它的訪問速度卻很慢。若是內存和硬盤交換數據過於頻繁,處理速度就會降低,計算機就會看上去像卡死了同樣,這種現象被叫作抖動(Thrushing)。形成電腦藍屏的主要緣由之一就是抖動。程序員

第二種機制就是垃圾回收(GC)。golang

虛擬內存的東西在計算機組成原理和操做系統的教科書裏有相關的章節去講。因爲內容不少我就很少敘述了。主要來說一下GC的事情。面試

GC

以前學習java以及參加java相關的面試,被問到關於相關GC的事情一直非常頭疼,看了好多遍仍是記不住,腦殼裏只有隱隱約約的一些關鍵字,什麼老年代、新生代、full GC什麼的。具體流程一被問就GG。算法

如今想一想,其實GC就是計算機幫助你去進行內存的回收。你用不到的數據若是一直佔着內存,那麼你的程序能用的內存就愈來愈少,因此須要進行內存的管理。好比你在寫C、C++程序的時候,須要本身去管理內存,在堆上申請的內存最後須要本身手動釋放,釋放的內存會被操做系統從新利用。若是你認爲某些內存空間「可能還會被用到」,或者是乾脆忘記釋放內存,這些沒法訪問的內存空間就會一直保留下來,形成內存浪費,最終致使性能降低和產生抖動。管理大量分配的內存空間,對於人來講實際上是很困難的。編程

對於像Java、go這些語言,它們有本身的一套內存管理的機制,內存空間釋放的自動化也就是GC,從某種程度上解放了程序員的雙手。緩存

術語

垃圾(Garbage)

垃圾(Garbage)就是須要回收的對象。做爲編程人員你知道對象何時不須要,可是計算機沒法判斷。所以,若是程序直接或者間接地引用一個對象,那麼它就會被計算機標記爲「存活」;相反的,沒有被引用到的對象就被視爲「死亡」。把這些「死亡」的對象找出來,而後做爲垃圾進行回收,這就是GC的本質。併發

根(Root)

根(Root),就是判斷對象是否可被引用的起始點。不一樣語言和編譯器對根有不一樣規定,可是基本上是將變量和運行棧空間做爲根。編程語言

GC算法

標記清除方式

標記清除原理很是簡單,首先從根開始,將可能被引用的對象用遞歸的方式進行標記,而後將沒有標記上的對象做爲垃圾進行回收。函數

圖1.1

圖1.1的(1)顯示了隨着程序的運行進而分配出的一些對象的狀態,一個對象能夠對其餘的對象進行引用。

(2)部分GC開始執行,從根開始對可能被引用的對象打上標記。大多數狀況下,這種標記是經過對象內部的標誌(Flag)來實現的。被標記的對象咱們將它塗黑。

(3)中,被標記的對象所可以引用的對象也被打上標記。重複這一步驟,能夠將從根開始可能被間接引用到的對象所有打上標記。到此爲止的操做,稱做標記階段(Mark phase)。標記階段完成是,被標記的對象就被看作是「存活」對象。

(4)中,將所有對象按順序掃描一遍,將沒有標記的對象進行回收。這一步操做稱做清除階段(Sweep phase)。在掃描的同時,還須要將存活對象的標記清除掉,以便下一次GC去處理。

標記清除算法的處理時間,是和存活對象數與對象總數二者的和相關的。

標記清除方式的優勢:能夠處理循環引用的對象。缺點:在分配了大量對象,而且其中只有一小部分存活的狀況下,因爲清除階段還要對大量「死亡」的對象進行掃描,會致使消耗大量沒必要要的時間。

複製收集方式

複製收集(Copy and Collection)方式試圖解決標記清除的缺點。這種算法中,會從根開始,被引用的對象複製到另外的空間中,而後再將複製的對象所可以引用的對象遞歸的方式不斷複製下去。

圖1.2

(1)是GC開始前的狀態。(2)部分中,舊對象空間以外,準備出一塊新空間,而後將可能從根被引用的對象複製到新空間中。

(3)部分中,從已經複製的對象開始,再將能夠被引用的對象複製到新空間中(串到了後面),「死亡」對象就被留存在了舊空間中。

(4)中,將舊空間廢棄掉,就能夠將這部分所佔的空間一會兒所有釋放掉,而沒有必要再掃描每一個對象。下次GC的時候,如今的新空間就當作了將來的舊空間。

經過圖1.2能夠發現,複製收集方式,只有相似標記清除的標記階段,而不存在須要掃描全部對象的狀況。可是相比之下,複製收集將對象複製一份所須要的開銷會比較大,所以在「存活」對象比例較高的狀況下,反而會比較不利。

這種算法的另外一個好處就是它具備局部性(Locality)。在複製收集過程當中,會按照對象被引用的順序將對象複製到新空間中。所以關係較近的對象被放在距離較近的內存空間中的可能性會提供,這被稱爲局部性。局部性高的狀況,內存緩存會更容易有效運做,程序的性能也會獲得提升。

引用計數方式

引用計數的基本原理是,在每個對象中保存該對象的引用計數,當引用發生增減時對計數進行更新。這讓我想到了C++裏的智能指針(shared_ptr),曾經被面試手寫shared_ptr的場景歷歷在目啊。

引用計數的增減,通常發生在變量賦值、對象內容更新、函數結束(局部變量再也不被引用)等時間點。當一個對象的引用計數變爲0的時候,則說明它未來不會再被引用,所以能夠釋放相應的內存空間。

圖1.3

(1)裏面,全部對象中都保存着本身被多少其餘的對象進行引用的數量(引用計數)。

(2)中,當對象引用發生變化的時候,引用計數也跟着變。這裏因爲B到D的引用失效了,因而對象D的引用計數變爲0,因爲D的引用計數爲0,所以由D到對象C和E的引用數也分別相應減小。結果,對象E的引用計數變爲0,因而E也對應釋放掉了。

(3)中,引用計數爲0的對象被釋放,「存活」的對象保留了下來。可以注意到,在整個GC的過程當中,並不須要對全部對象進行掃描。

這種方式最大優勢,就是容易實現。它的另一個優勢就是,當對象再也不被引用的瞬間就會被釋放。其它的GC機制很難預測對象何時會被釋放,而這種方式是當即被釋放的。所以,由GC產生的中斷時間(Pause time)就比較短。

固然這種方式也有缺點。最大的缺點就是,沒法釋放循環引用的對象。圖1.4中A、B、C之間互相循環引用,它的引用計數永遠不會爲0,也就永遠不會被釋放。

圖1.4

它的另一個缺點就是,若是在必要的增減計數的時候遺漏掉了增減操做,或者是增減計數出錯,就會產生內存錯誤。若是是手動管理計數就很容易產生bug。

它的最後一個缺點就是引用計數管理不適合並行處理。若是多個線程同時對引用計數進行增減,引用數值就會產生不一致的問題從而致使內存錯誤。所以引用計數必須採用獨佔的方式。若是引用計數頻繁發生,每次須要使用加鎖等併發控制機制的話,也會形成很大的額外開銷。

改良版GC

GC的基本算法,大致上都是上面的三種方式或者是它們的衍生品。如今經過三種方式進行融合,出現一些其餘高級的GC方式,即分代回收、增量回收和並行回收。有些狀況也會對這些改良版的gc方式進行組合使用。

分代回收(Generational GC)

分代回收的目的,就是在程序運行期間,將GC所消耗的時間儘可能的縮短。

它基於這樣一個通常程序的性質,即大部分對象都會在短期內成爲垃圾,通過必定時間依然存活的對象每每有較長的壽命。對於剛分配不久的「年輕」對象進行重點掃描,應該能夠更有效的回收大部分垃圾。

分代回收中,按照對象生成時間進行分代。剛剛生成不久的對象劃分爲新生代(Young generation),而存活了較長時間的對象劃分爲舊生代(Old generation)。不一樣實現可能還會有更多劃分。若是上面的對象壽命假說成立的話,只要掃描回收新生代對象,就能夠回收掉廢棄對象中的很大一部分。

這種只掃描新生代對象的回收操做,被稱做小回收(Minor GC)。它的具體步驟以下。

首先從根開始一次常規掃描,找到「存活」對象。可使用複製收集算法或者標記清除算法,可是大部分使用了複製收集。須要注意的是,在掃描的過程當中,若是遇到屬於舊生代的對象,則不對該對象進行遞歸掃描。這樣須要掃描的對象就會大量減小。

而後,將第一次掃描後殘留下來的對象劃分到舊生代。具體來講,若是使用複製收集算法,只要將複製目標空間設置爲舊生代就能夠了;標記清除方式的話,則採用在對象上設置某種標誌的方式。

如今有一個問題,若是有從舊生代到新生代對象的引用怎麼辦?若是隻掃描新生代,那麼舊生代對新生代的引用就不會被檢測到。這樣一來,若是一個年輕的對象只有來自舊生代的引用,就會被誤認爲「死亡」。所以在分代回收中,會對對象的更新進行監視,將從舊生代對新生代的引用,記錄在記錄集(remembered set)的表中。在執行小回收過程當中,這個記錄集也做爲一個根(root)來對待。

圖1.5

沒有引用任何其它對象的舊生代F對象,會經過大回收(Full gc)操做進行回收。保證分代回收正確工做,必須使記錄集的內容保持更新。

記錄引用的子程序工做方式以下。設有兩個對象A、B,當對A內容進行改寫,並加入對B的引用時,若是A屬於舊生代,B屬於新生代,則將該引用添加到記錄集中。

這種檢查程序須要對全部涉及修改對象內容的地方進行保護,所以也被稱爲寫屏障(Write barrier)。寫屏障也用在不少其餘GC算法中,好比Go的gc算法也用到了寫屏障。

雖然舊生代中的對象壽命通常比較長,可是最終也會「死亡」。隨着程序運行,舊生代中「死亡」的對象不斷增長。爲了不這些「死亡」的舊生代對象白佔內存空間,偶爾須要對包括舊生代在內的所有區域進行一次掃描回收。這種以所有區域爲對象的GC操做叫Full GC

分代收集經過減小GC中掃描的對象數量進而縮短GC帶來的平均中斷時間,可是最終仍是須要一次Full GC,所以最大中斷時間並無改善。GC的性能也會被程序行爲、分代數量、Full GC的觸發條件等因素大幅左右。

增量回收

實時性要求很高的程序中,相比縮短GC平均中斷時間,縮短GC的最大中斷時間更加劇要。

GC最大的中斷時間要限定在一個時間範圍內,可是通常GC算法沒辦法保證,由於GC產生的中斷時間和對象的數量和狀態有關係。所以爲了維持程序的實時性,不等到GC所有完成,而是將GC操做細分紅多個部分逐一執行。這種方式就是增量回收(Incremental GC)

增量回收過程當中程序自己會繼續運行,爲了不對象之間的引用關係改變而致使GC回收出錯,增量回收也使用了寫屏障,來說新被引用的對象做爲掃描的起始點記錄下來。

因爲增量回收過程式分步漸進式的,能夠將中斷時間控制在必定長度以內。可是相應的中斷操做須要消耗必定時間,GC消耗的總時間會相應增長。

並行回收

如今的計算機基本上都有多個CPU核心。而並行回收方式正是經過最大限度利用多CPU的處理能力來進行GC操做。

並行回收的基本原理是,在原有程序運行的同時進行GC操做,這點和增量回收是類似的。相對於在一個CPU上進行任務分割的增量回收方式,並行回收能夠利用多CPU的性能,儘量讓這些GC任務並行(同時)進行。因爲程序功能運行和GC操做是同時發生的,就會遇到和前面相同的問題,因此並行回收也須要利用寫屏障來對對象當前的狀態信息保持更新。

可是讓GC徹底並行,而不影響原有程序運行時作不到的,在GC的某些特定階段仍是須要暫停原有程序的運行。

總結

目前的GC算法基本上都是上述算法的變體或者是組合,例如java裏的分代回收,golang中的三色標記法。有時候由於對象之間的引用狀態發生了變化,結果致使了原本是「存活」對象卻被回收掉,這時候須要寫屏障(Write barrier)來進行記錄和保護。當咱們理解了上面的事情,其餘具體編程語言的GC算法就不難理解了。

參考資料

  1. 《代碼的將來》-- 做者:松本行弘 譯者:周自恆
  2. juejin.im/post/5a15be…
  3. legendtkl.com/2017/04/28/…
相關文章
相關標籤/搜索