Java的垃圾回收機制

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

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

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

什麼是 Java 堆內存

堆是在 JVM 啓動時創建的,主要用來維護運行時數據,如運行過程中創建的對象和數組都是基於這塊內存空間。Java 堆是非常重要的元素,如果我們動態創建的對象沒有得到及時回收,持續堆積,最後會導致堆空間被佔滿,內存溢出。

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

那什麼是垃圾呢?

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

引用計數法

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

因此,Java 裏沒有采用這樣的方案來判定對象的“存活性”。

可達性分析

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

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

GC Roots 究竟指誰呢?

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

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

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

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

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

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

標記-清理

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

結果如下:

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

標記-整理

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

結果如下:

這兩種方案適合 存活對象多,垃圾少 的情況,它只需要清理掉少量的垃圾,然後挪動下存活對象就可以了。

複製

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

參考下圖:

起初時只使用上面部分的內存,直到內存使用完畢,才進行垃圾回收,把所有存活對象搬到下半部分,並把上半部分進行清空。

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

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

Java 的分代回收機制

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

Java 的堆結構

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

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

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

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

  • 新生代:存活對象少、垃圾多
  • 老年代:存活對象多、垃圾少

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

新生代-複製 回收機制

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

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

思路 1. 把內存均分成 1:1 兩等份

如下圖拆分內存。

每次只使用一半的內存,當這一半滿了後,就進行垃圾回收,把存活的對象直接複製到另一半內存,並清空當前一半的內存。

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

思路 2. 把內存按 9:1 分

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

最開始在 9 的內存區使用,當 9 快要滿時,執行復制回收,把 9 內仍然存活的對象複製到 1 區,並清空 9 區。

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

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

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

思路 3. 把內存按 8:1:1 分

既然 9:1 有可能把年輕對象放到 老年區 ,那就換成 8:1:1,依次取名爲 Eden、Survivor A、Survivor 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 工程師的必經之路。

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