一文學會 Java 垃圾回收機制

wingjay發表於2019-04-11

《億級Android架構》小專欄文章列表:

《億級 Android 架構》專欄隨談》

《Android 架構之網路連線與加速》

《Android 架構之長連線技術》

《Android 架構之高可用行動網路連線》

《Android 架構之網路安全演進》

《Android 架構之高效能移動端日誌系統》

《Android 架構之秒級移動配置中心》

正文

垃圾回收機制是 Java 非常重要的特性之一,也是面試題的常客。它讓開發者無需關注空間的建立和釋放,而是以守護程式的形式在後臺自動回收垃圾。這樣做不僅提高了開發效率,更改善了記憶體的使用狀況。

今天本文來對垃圾回收機制進行講解,主要涉及下面幾個問題:

  • 什麼是堆記憶體?
  • 什麼是垃圾?
  • 有哪些方法回收這些垃圾?
  • 什麼是分代回收機制?

什麼是 Java 堆記憶體

堆是在 JVM 啟動時建立的,主要用來維護執行時資料,如執行過程中建立的物件和陣列都是基於這塊記憶體空間。Java 堆是非常重要的元素,如果我們動態建立的物件沒有得到及時回收,持續堆積,最後會導致堆空間被佔滿,記憶體溢位。

因此,Java 提供了一種垃圾回收機制,在後臺建立一個守護程式。該程式會在記憶體緊張的時候自動跳出來,把堆空間的垃圾全部進行回收,從而保證程式的正常執行。

那什麼是垃圾呢?

所謂“垃圾”,就是指所有不再存活的物件。常見的判斷是否存活有兩種方法:引用計數法和可達性分析。

引用計數法

為每一個建立的物件分配一個引用計數器,用來儲存該物件被引用的個數。當該個數為零,意味著沒有人再使用這個物件,可以認為“物件死亡”。但是,這種方案存在嚴重的問題,就是無法檢測“迴圈引用”:當兩個物件互相引用,即時它倆都不被外界任何東西引用,它倆的計數都不為零,因此永遠不會被回收。而實際上對於開發者而言,這兩個物件已經完全沒有用處了。

因此,Java 裡沒有采用這樣的方案來判定物件的“存活性”。

可達性分析

這種方案是目前主流語言裡採用的物件存活性判斷方案。基本思路是把所有引用的物件想象成一棵樹,從樹的根結點 GC Roots 出發,持續遍歷找出所有連線的樹枝物件,這些物件則被稱為“可達”物件,或稱“存活”物件。其餘的物件則被視為“死亡”的“不可達”物件,或稱“垃圾”。

參考下圖,object5,object6和object7便是不可達物件,視為“死亡狀態”,應該被垃圾回收器回收。

一文學會 Java 垃圾回收機制

GC Roots 究竟指誰呢?

我們可以猜測,GC Roots 本身一定是可達的,這樣從它們出發遍歷到的物件才能保證一定可達。那麼,Java 裡有哪些物件是一定可達呢?主要有以下四種:

  • 虛擬機器棧(幀棧中的本地變數表)中引用的物件。
  • 方法區中靜態屬性引用的物件。
  • 方法區中常量引用的物件。
  • 本地方法棧中JNI引用的物件。

不少讀者可能對這些 GC Roots 似懂非懂,這涉及到 JVM 本身的記憶體結構等等,未來的文章會再做深入講解。這裡只要知道有這麼幾種型別的 GC Roots,每次垃圾回收器會從這些根結點開始遍歷尋找所有可達節點。

有哪些方式來回收這些垃圾呢?

上面已經知道,所有GC Roots不可達的物件都稱為垃圾,參考下圖,黑色的表示垃圾,灰色表示存活物件,綠色表示空白空間。

一文學會 Java 垃圾回收機制

那麼,我們如何來回收這些垃圾呢?

標記-清理

第一步,所謂“標記”就是利用可達性遍歷堆記憶體,把“存活”物件和“垃圾”物件進行標記,得到的結果如上圖; 第二步,既然“垃圾”已經標記好了,那我們再遍歷一遍,把所有“垃圾”物件所佔的空間直接清空即可。

結果如下:

一文學會 Java 垃圾回收機制

這便是標記-清理方案,簡單方便,但是容易產生記憶體碎片

標記-整理

既然上面的方法會產生記憶體碎片,那好,我在清理的時候,把所有存活物件扎堆到同一個地方,讓它們待在一起,這樣就沒有記憶體碎片了。

結果如下:

一文學會 Java 垃圾回收機制
這兩種方案適合存活物件多,垃圾少的情況,它只需要清理掉少量的垃圾,然後挪動下存活物件就可以了。

複製

這種方法比較粗暴,直接把堆記憶體分成兩部分,一段時間內只允許在其中一塊記憶體上進行分配,當這塊記憶體被分配完後,則執行垃圾回收,把所有存活物件全部複製到另一塊記憶體上,當前記憶體則直接全部清空。

參考下圖:

一文學會 Java 垃圾回收機制
起初時只使用上面部分的記憶體,直到記憶體使用完畢,才進行垃圾回收,把所有存活物件搬到下半部分,並把上半部分進行清空。

這種做法不容易產生碎片,也簡單粗暴;但是,它意味著你在一段時間內只能使用一部分的記憶體,超過這部分記憶體的話就意味著堆記憶體裡頻繁的複製清空

這種方案適合存活物件少,垃圾多的情況,這樣在複製時就不需要複製多少物件過去,多數垃圾直接被清空處理。

Java 的分代回收機制

上面我們看到有至少三種方法來回收記憶體,那麼 Java 裡是如何選擇利用這三種回收演算法呢?是隻用一種還是三種都用呢?

Java 的堆結構

在選擇回收演算法前,我們先來看一下 Java 堆的結構。

一塊 Java 堆空間一般分成三部分,這三部分用來儲存三類資料:

  • 剛剛建立的物件。在程式碼執行時會持續不斷地創造新的物件,這些新建立的物件會被統一放在一起。因為有很多區域性變數等在新建立後很快會變成不可達的物件,快速死去,因此這塊區域的特點是存活物件少,垃圾多。形象點描述這塊區域為:新生代
  • 存活了一段時間的物件。這些物件早早就被建立了,而且一直活了下來。我們把這些存活時間較長的物件放在一起,它們的特點是存活物件多,垃圾少。形象點描述這塊區域為:老年代
  • 永久存在的物件。比如一些靜態檔案,這些物件的特點是不需要垃圾回收,永遠存活。形象點描述這塊區域為:永久代。(不過在 Java 8 裡已經把永久代刪除了,把這塊記憶體空間給了元空間,後續文章再講解。)

也就是說,常規的 Java 堆至少包括了 新生代老年代 兩塊記憶體區域,而且這兩塊區域有很明顯的特徵:

  • 新生代:存活物件少、垃圾多
  • 老年代:存活物件多、垃圾少

結合新生代/老年代的存活物件特點和之前提過的幾種垃圾回收演算法,可以得到如下的回收方案:

新生代-複製回收機制

對於新生代區域,由於每次 GC 都會有大量新物件死去,只有少量存活。因此採用複製回收演算法,GC 時把少量的存活物件複製過去即可。

那麼如何設計這個複製演算法比較好呢?有以下幾種方式:

思路1. 把記憶體均分成 1:1 兩等份

如下圖拆分記憶體。

一文學會 Java 垃圾回收機制
每次只使用一半的記憶體,當這一半滿了後,就進行垃圾回收,把存活的物件直接複製到另一半記憶體,並清空當前一半的記憶體。

這種分法的缺陷是相當於只有一半的可用記憶體,對於新生代而言,新物件持續不斷地被建立,如果只有一半可用記憶體,那顯然要持續不斷地進行垃圾回收工作,反而影響到了正常程式的執行,得不償失。

思路2. 把記憶體按 9:1

既然上面的分法導致可用記憶體只剩一半,那麼我做些調整,把 1:1變成9:1

一文學會 Java 垃圾回收機制
最開始在 9 的記憶體區使用,當 9 快要滿時,執行復制回收,把 9 內仍然存活的物件複製到 1 區,並清空 9 區。

這樣看起來是比上面的方法好了,但是它存在比較嚴重的問題。

當我們把 9 區存活物件複製到 1 區時,由於記憶體空間比例相差比較大,所以很有可能 1 區放不滿,此時就不得不把物件移到 老年區。而這就意味著,可能會有一部分 並不老9 區物件由於 1 區放不下了而被放到了 老年區,可想而知,這破壞了 老年區 的規則。或者說,一定程度上的 老年區 並不一定全是 老年物件

那應該如何才能把真正比較 的物件挪到 老年區 呢?

思路3. 把記憶體按 8:1:1

一文學會 Java 垃圾回收機制
既然 9:1 有可能把年輕物件放到 老年區,那就換成 8:1:1,依次取名為 EdenSurvivor ASurvivor B區,其中Eden意為伊甸園,形容有很多新生物件在裡面建立;Survivor區則為倖存者,即經歷 GC 後仍然存活下來的物件。

工作原理如下:

  1. 首先,Eden區最大,對外提供堆記憶體。當 Eden 區快要滿了,則進行 Minor GC,把存活物件放入Survivor A區,清空 Eden 區;
  2. Eden區被清空後,繼續對外提供堆記憶體;
  3. Eden區再次被填滿,此時對Eden區和Survivor A區同時進行 Minor GC,把存活物件放入Survivor B區,同時清空Eden 區和Survivor A區;
  4. Eden區繼續對外提供堆記憶體,並重覆上述過程,即在Eden區填滿後,把Eden區和某個Survivor區的存活物件放到另一個Survivor區;
  5. 當某個Survivor區被填滿,且仍有物件未被複制完畢時,或者某些物件在反覆Survive 15 次左右時,則把這部分剩餘物件放到Old區;
  6. Old 區也被填滿時,進行 Major GC,對 Old 區進行垃圾回收。

[注意,在真實的 JVM 環境裡,可以通過引數 SurvivorRatio 手動配置Eden區和單個Survivor區的比例,預設為8。]

那麼,所謂的 Old 區垃圾回收,或稱Major GC,應該如何執行呢?

老年代-標記整理回收機制

根據上面我們知道,老年代一般存放的是存活時間較久的物件,所以每一次 GC 時,存活物件比較較大,也就是說每次只有少部分物件被回收。

因此,根據不同回收機制的特點,這裡選擇存活物件多,垃圾少標記整理回收機制,僅僅通過少量地移動物件就能清理垃圾,而且不存在記憶體碎片化。

至此,我們已經瞭解了 Java 堆記憶體的分代原理,並瞭解了不同代根據各自特點採用了不同的回收機制,即新生代採用回收機制,老年代採用標記整理機制。

小結

垃圾回收是 Java 非常重要的特性,也是高階 Java 工程師的必經之路。

如有問題歡迎與我聯絡。

參考文章:

PS:本文原創釋出於微信公眾號「wingjay」,回覆關鍵字「程式設計師」獲取一份 15 本程式設計師經典電子書。

Android架構、技術感悟、個人成長

相關文章