淺談JVM垃圾內存回收算法

前言

首先,我們要講的是JVM的垃圾回收機制,我默認準備閱讀本篇的人都知道以下兩點:

  • JVM是做什麼的
  • Java堆是什麼

因爲我們即將要講的就是發生在JVM的Java堆上的垃圾回收,爲了突出核心,其他的一些與本篇不太相關的東西我就一筆略過了

衆所周知,Java堆上保存着對象的實例,而Java堆的大小是有限的,所以我們只能把一些已經用完的,無法再使用的垃圾對象從內存中釋放掉,就像JVM幫助我們手動在代碼中添加一條類似於C++的free語句的行爲

然而這些垃圾對象是怎麼回收的,現在不知道沒關係,我們馬上就會講到

怎麼判斷對象爲垃圾對象

在瞭解具體的GC(垃圾回收)算法之前,我們先來了解一下JVM是怎麼判斷一個對象是垃圾對象的

顧名思義,垃圾對象,就是沒有價值的對象,用更嚴謹的語句來說,就是沒有被訪問的對象,也就是說沒有被其他對象引用,這就牽引出我們的第一個判斷方案:引用計數法

引用計數法

這種算法的原理是,每有一個其他對象產生對A對象的引用,則A對象的引用計數值就+1,反之,每有一個對象對A對象的引用失效的時候,A對象的引用計數值就-1,當A對象的引用計數值爲0的時候,其就被標明爲垃圾對象

這種算法看起來很美好,瞭解C++的應該知道,C++的智能指針也有類似的引用計數,但是在這種看起來“簡單”的方法,並不能用來判斷一個對象爲垃圾對象,我們來看以下場景:
例圖1
在這個場景中,A對象有B對象的引用,B對象也有A對象的引用,所以這兩個對象的引用計數值均不爲0,但是,A、B兩個對象明明就沒有任何外部的對象引用,就像大海上兩個緊挨着的孤島,即使他們彼此依靠着,但仍然是孤島,其他人過不去,而且由於引用計數不爲0,也無法判斷爲垃圾對象,如果JVM中存在着大量的這樣的垃圾對象,最終就會頻繁拋出OOM異常,導致系統頻繁崩潰

總而言之,如果有人問你爲什麼JVM不採用引用計數法來判斷垃圾對象,只需要記住這一句話:引用計數法無法解決對象循環依賴的問題

可達性分析法

引用計數法已經很接近結果了,但是其問題是,爲什麼每有一個對象來引用就要給引用計數值+1,就好像有人來敲門就開一樣,我們應該只給那些我們認識的、重要的人開門,也就是說,只有重要的對象來引用時,纔給引用計數值+1

但是這樣還不行,因爲重要的對象來引用只要有一個就夠了,並不需要每有一個引用就+1,所以我們可以將引用計數法優化爲以下形式:

給對象設置一個標記,每有一個“重要的對象”來引用時,就將這個標記設爲true,當沒有任何“重要的對象”引用時,就將標記設爲false,標記爲false的對象爲垃圾對象

這就是可達性分析法的雛形,我們可以繼續進行修正,我們並不需要主動標記對象,而只需要等待垃圾回收時找到這些“重要的對象”,然後從它們出發,把我們能找到的對象都標記爲非垃圾對象,其餘的自然就是垃圾對象

我們將上文提到的“重要的對象”命名爲GC Roots,這樣就得到了最終的可達性分析算法的概念:

創建垃圾回收時的根節點,稱爲GC Roots,從GC Roots出發,不能到達的對象就被標記爲垃圾對象

其中,可以作爲GC Roots的區域有:

  • 虛擬機棧的棧幀中的局部變量表
  • 方法區的類屬性和常量所引用的對象
  • 本地方法棧中引用的對象

換句話說,GC Roots就是方法中的局部變量類屬性,以及常量

垃圾回收算法

終於到本文的重點了,我們剛剛分析瞭如何判斷一個對象屬於垃圾對象,接下來我們就要重點分析如何將這些垃圾對象回收掉

標記-清除算法

標記-清除很容易理解,該算法有兩個過程,標記過程和清除過程,標記過程中通過上文提到的可達性分析法來標記出所有的非垃圾對象,然後再通過清除過程進行清理

比方說,我們現在有下面的這樣的一個Java堆,已經通過可達性分析法來標記出所有的垃圾對象(用橙色表明,藍色的是普通對象):
例圖2
然後我們通過清除階段進行清理,結果是下圖:
例圖3
發現什麼問題了嗎,沒錯,清理完後的空間是不連續的,也就是說,整個算法最大的缺點就是:

  • 會出現大量的空間碎片,當需要分配大對象時,會觸發FGC,非常消耗性能

這裏引出一個FGC的概念,爲了避免主題跑偏,本文中暫時不進行深入,只需要知道垃圾回收分爲YGC(年輕代垃圾回收)和FGC(完全垃圾回收),可以把YGC理解爲掃掃地,倒倒垃圾,把FGC理解爲給家裏來個大掃除

複製算法

複製算法將Java堆劃分爲兩塊區域,每次只使用其中的一塊區域,當垃圾回收發生時,將所有被標記的對象(GC Roots可達,爲非垃圾對象)複製到另一塊區域,然後進行清理,清理完成後交換兩塊區域的可用性

這種方式因爲每次只需要一整塊一起刪除即可,就不用一個個地刪除了,同時還能保證另一塊區域是連續的,也解決了空間碎片的問題

整個流程我們再來看一遍

  1. 首先我們有兩塊區域S1和S2,標記爲灰色的區域爲當前激活可用的區域:
    例圖4
  2. 對Java堆上的對象進行標記,其中藍色的爲GC Roots可達的對象,其餘的均爲垃圾對象:
    例圖5
  3. 接下來將所有可用的對象複製到另一塊區域中:
    例圖6
  4. 將原區域中所有內容刪除,並將另一塊區域激活
    例圖7

這種方法的優缺點也很明顯:

  • 優點:解決了空間不連續的問題
  • 缺點:空間利用率低(每次只使用一半)

爲了解決這一缺點,就引出了下面這個算法

優化的複製算法

至於爲什麼不另起一個名字,其實是因爲這個算法也叫做複製算法,更確切的說,剛纔介紹的只是優化算法的雛形,沒有虛擬機會使用上面的那種複製算法,所以接下來要講的,就是真正的複製算法

這個算法的思路和剛纔講的一樣,不過這個算法將內存分爲3塊區域:1塊Eden區,和2塊Survivor區,其中,Eden區要佔到80%

這兩塊Survivor區就可以理解爲我們剛纔提到的S1和S2兩塊區域,我們每次只使用整個Eden區和其中一塊Survivor區,整個算法的流程可以簡要概括爲:

  1. 當發生垃圾回收時,將Eden區+Survivor區中仍然存活的對象一次性複製到另一塊Survivor區上
  2. 清理掉Eden區和使用的Survivor區中的所有對象
  3. 交換兩塊Survivor的激活狀態

光看文字描述比較抽象,我們來看圖像的形式:

  1. 我們有以下這樣的一塊Java堆,其中灰色的Survivor區爲激活狀態
    例圖9

  2. 標記所有的GC Roots可達對象(藍色標記)例圖10

  3. 將標記對象全部複製到另一塊Survivor區域中例圖11

  4. 清理掉Eden區和激活的Survivor區中的所有對象,然後交換兩塊區域的激活狀態例圖12

以上就是整個複製算法的全過程了,有人可能會問了,爲什麼Survivor區這麼小,就不怕放不下嗎?其實平均來說,每次垃圾回收的時候基本都會回收98%左右的對象,也就是說,我們完全可以保證大部分情況下剩餘的對象都小於10%,放在一塊Survivor區中是沒問題的。當然,也可能會發生Survivor區不夠用的問題,這時候就需要依賴其他內存給我們提供後備了

這種算法較好地解決了內存利用率低的問題,但是複製算法的兩個問題依然沒有解決:

  • 對象複製採用深度優先的遞歸方式來實現,會消耗棧資源(Cheney改進的GC複製算法解決了該問題)
  • 複製算法無法處理長壽數據,只會頻繁地將其複製來白白消耗資源(重點
標記-整理算法

這種算法可以說是專門針對對象存活率高的程序,具體的流程如下:

  1. GC發生時,將所有被標記的存活對象移動到內存的一端
  2. 移動完成後,清理掉所有移動後的邊界以外的對象

我相信大家在理解了前面幾個算法之後,這個算法也能很方便地理解,我就不畫圖了,用一個例子來解釋:

問題:對於一個長度爲n的數組,我們想要保留其中所有小於10的數字,其餘的數字刪掉
方案:可以遍歷一遍數據,將所有小於10的數字全部放到數組的最左側,最終,數組的0~m(0<=m<=n)位置全部都是小於10的數字,然後我們只需要刪除m+1~n的所有數字即可

這種方法的優點也顯而易見:

  • 實現簡單,執行速度快
  • 針對複製算法處理不佳的長壽數據,標記-整理算法可以選擇不去整理這些對象
  • 沒有空間碎片的問題

但是依然還是有缺點的:

  • 如果堆內存較小,則該算法的速度會下降
  • 遍歷時需要多次訪問類型信息和對象的指針域,開銷很大
  • 記錄新的轉發地址需要佔用額外的空間,導致吞吐量下降
  • 不適合併發回收器
分代收集算法

別急,我們還沒說完,還有最後一個分代收集算法,這個算法將Java堆劃分爲兩塊區域:

  • 年輕代:存放朝生夕滅的對象,即存活率低的對象,大部分對象在一次GC後都會被回收
  • 老年代:存放存活率高的對象

可以看出,分代收集算法按照對象在GC後的存活率將Java堆分爲這樣兩塊區域,針對不同區域採用不同的算法,就能儘可能地做到“揚長補短”,來提高垃圾回收的效率

  • 針對年輕代朝生夕滅的性質,我們採用複製算法
  • 針對老年代存活率高的性質,我們採用標記-整理算法

總結

最後,垃圾回收的幾種常見算法已經爲大家介紹完畢,接下來如果有機會我會再介紹一下幾種常見的垃圾回收器

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