《億級Android架構》小專欄文章列表:
正文
垃圾回收機制是 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 後仍然存活下來的物件。
工作原理如下:
- 首先,
Eden
區最大,對外提供堆記憶體。當Eden
區快要滿了,則進行Minor GC
,把存活物件放入Survivor A
區,清空Eden
區; Eden
區被清空後,繼續對外提供堆記憶體;- 當
Eden
區再次被填滿,此時對Eden
區和Survivor A
區同時進行Minor GC
,把存活物件放入Survivor B
區,同時清空Eden
區和Survivor A
區; Eden
區繼續對外提供堆記憶體,並重覆上述過程,即在Eden
區填滿後,把Eden
區和某個Survivor
區的存活物件放到另一個Survivor
區;- 當某個
Survivor
區被填滿,且仍有物件未被複制完畢時,或者某些物件在反覆Survive
15
次左右時,則把這部分剩餘物件放到Old
區; - 當
Old
區也被填滿時,進行Major GC
,對Old
區進行垃圾回收。
[注意,在真實的 JVM 環境裡,可以通過引數 SurvivorRatio
手動配置Eden
區和單個Survivor
區的比例,預設為8。]
那麼,所謂的 Old
區垃圾回收,或稱Major GC
,應該如何執行呢?
老年代-標記整理
回收機制
根據上面我們知道,老年代一般存放的是存活時間較久的物件,所以每一次 GC 時,存活物件比較較大,也就是說每次只有少部分物件被回收。
因此,根據不同回收機制的特點,這裡選擇存活物件多,垃圾少
的標記整理
回收機制,僅僅通過少量地移動物件就能清理垃圾,而且不存在記憶體碎片化。
至此,我們已經瞭解了 Java 堆記憶體的分代原理,並瞭解了不同代根據各自特點採用了不同的回收機制,即新生代
採用回收
機制,老年代
採用標記整理
機制。
小結
垃圾回收是 Java 非常重要的特性,也是高階 Java 工程師的必經之路。
如有問題歡迎與我聯絡。
參考文章:
PS:本文原創釋出於微信公眾號「wingjay」,回覆關鍵字「程式設計師」獲取一份 15 本程式設計師經典電子書。