談談 JVM 垃圾回收機制

fuxing.發表於2024-05-15

前言


垃圾回收需要思考三件事情,哪些記憶體需要回收?什麼時候回收?如何回收?


一、哪些記憶體需要回收


JVM 的記憶體區域中,程式計數器、虛擬機器棧和本地方法棧的生命週期是隨執行緒而生,隨執行緒而滅的。這幾個區域的記憶體分配和回收都具有確定性不需要過多考慮回收問題,當方法或執行緒結束時,記憶體自然就跟著回收了。


Java 堆和方法區是具有不確定性的,比如一個方法根據不同的條件執行可能需要的記憶體是不同的。只有處於執行期才能知道需要建立哪些物件,建立多少物件,這部分的記憶體分配和回收是動態的,垃圾回收所關注的就是這部分記憶體


二、物件何時會“死亡”


Java 堆中存放了幾乎所有的物件例項,垃圾回收器在進行回收前,需要判斷哪些物件“存活”,哪些物件“死亡”,“死亡”的物件才會被回收。


1. 引用計數法

給物件中新增一個引用計數器

  • 每當有一個地方引用它,計數器就加 1;
  • 當引用失效,計數器就減 1;
  • 任何時候計數器為 0 的物件就是不可能再被使用的。

這個方法實現簡單,效率高,但是目前主流的虛擬機器中並沒有選擇這個演算法來管理記憶體,其最主要的原因是它很難解決物件之間相互迴圈引用的問題。

2. 可達性分析演算法


透過一系列被稱為GC Roots的根物件作為起始節點集,從這些節點開始,透過引用關係向下搜尋,搜尋走過的路徑稱為“引用鏈”,如果某個物件到GC Roots沒有任何“引用鏈”相連,就說明該物件不可達,即可以被回收。


如下圖,Object 5、Object 6、Object 7 雖有關聯,但是到GC Roots是不可達的,因此會被判定“死亡”。

image.png


Java 中固定可作為GC Root物件的有:

  • 虛擬機器棧的棧幀中的本地變數表中引用的物件,如,引數、區域性變數、臨時變數等。
  • 方法區中類靜態屬性引用的物件,如,類中的靜態變數。
  • 方法區中常量引用的物件。
  • 本地方法棧中 JNI(也就是native方法)引用的物件。
  • Java 虛擬機器內部的引用,如基本資料型別對應的 Class 物件、常駐異常、系統類載入器等。
  • 被同步鎖持有的物件。

除了這些固定的還有一些臨時性加入的,有興趣的可以看下《深入理解 Java 虛擬機器》。

上述兩種方法都需要了解引用,詳細介紹見Java引用

3. 方法區的回收

不要求虛擬機器在方法區進行垃圾回收,在 Java 堆中,尤其是在新生代中,常規進行一次垃圾收集通常可以回收70%至99%的記憶體空間,相比之下,在方法區進行回收的“價效比”較低。該區域的垃圾回收主要是兩個部分,廢棄的常量和不再使用的類

3.1 廢棄常量

假如在字串常量池中曾存在字串 "java",如果當前沒有任何字串物件引用該字串常量的話,就說明常量 "java" 就是廢棄常量,如果這時發生記憶體回收的話而且有必要的話,"java" 就會被系統清理出常量池了。

3.2 不再被使用的類

類需要同時滿足下面 3 個條件才能算是 “不在被使用的類” :

  • 該類所有的例項都已經被回收,也就是 Java 堆中不存在該類的任何例項。
  • 載入該類的 ClassLoader 已經被回收。
  • 該類對應的 java.lang.Class 物件沒有在任何地方被引用,無法在任何地方透過反射訪問該類的方法。

三、垃圾回收演算法

這裡整理的均為“追蹤式垃圾回收“,也成為”間接垃圾回收“。

1. 分代回收

Java 堆的區域劃分如下圖,被分為新生代、老年代、永久代或元空間,具體劃分為五大塊區域,不同的 GC 會針對不同的區域進行垃圾回收。

image.png

GC型別一般有以下幾大類:

分類 說明
Minor GC 也稱“Young GC”,只針對新生代進行的垃圾回收。
Major GC 也稱“Old GC”,只針對老年代進行的垃圾回收
Mixed GC 針對新生代和部分老年代進行垃圾回收,部分垃圾收集器才支援。
Full GC 針對整個Java堆和方法區進行的垃圾回收,耗時最久的GC

2. 標記-清除演算法

最早出現的垃圾回收演算法,分為“標記”、“清除”兩個階段。可以標記存活的物件,也可以標記要回收的物件。
該演算法有兩個缺點:

  1. 是執行效率不穩定,隨著物件的增多,標記效率會越來越低;
  2. 記憶體空間產生很多碎拼,浪費空間,分配大物件時可能需要重新觸發垃圾回收。

標記-清除演算法執行過程如下圖:

image.png

3. 標記-複製演算法

簡稱複製演算法,為了解決標記-清除演算法面對大量可回收物件執行效率低的問題,就是將記憶體分成兩個區域,每次只使用其中一個區域,當該區域記憶體滿了之後,會將還存活的物件複製到另一個區域,然後將原區域直接清理掉
該演算法有兩個缺點:

  1. 物件存活率過多時,會影響複製的效率;
  2. 一半記憶體不使用,浪費了大量空間。

標記-複製演算法執行過程如下圖:

image.png

由於 Java 的新生代物件存活率不高,所以一般針對新生代的垃圾回收使用標記-複製演算法


如下圖,將新生代分為記憶體較大的Eden,和兩塊記憶體較小的Survivor。每次分配記憶體只使用Eden和其中一個Survivor,我們假設第一次分配記憶體是EdenSurvivor 0

  1. 發生 GC 時,將使用的EdenSurvivor 0中存活的物件一次性複製到Survivor 1中,然後清理掉EdenSurvivor 0記憶體。
  2. 這時分配的記憶體就變成了EdenSurvivor 1,週而復始。

注意:Survivor不足以容納輕 GC 之後的物件時,就需要依賴老年代來進行記憶體分配了

image.png

4. 標記-整理演算法

標記完存活物件以後,讓所有存活物件都向記憶體空間的一端移動,然後在清理掉邊界以外的記憶體。

標記-整理演算法執行過程如下圖:

image.png

這種涉及到了物件的移動,如果移動存活物件,尤其是老年代這種每次回收都有大量物件存活的區域,會耗時很多,並且物件移動操作會全部暫停使用者應用程式才能進行,這樣會產生停頓時間。

這種停頓被稱為Stop The World

5. 標記-清除-整理演算法

先使用標記-清除演算法進行垃圾回收,暫時容忍記憶體碎片的存在,直到碎片過多影響物件分配時,在進行標記-整理演算法進行回收,獲得規整空間。

四、經典垃圾收集器


下圖展示了 7 種經典垃圾收集器,若兩兩出現互連情況,則表明兩者它們可以搭配使用

  • 單執行緒收集器 Serial、Serial old
  • 並行收集器 Par New、Parallel Scavenge、Parallel old
  • 併發收集器 CMS、G1

image.png

1. Serial 收集器

最基本、歷史最早的垃圾收集器了。單執行緒的收集器,收集垃圾時,必須暫停其他所有工作執行緒,也就是必有停頓,使用複製演算法

2. Par New 收集器

多執行緒並行版本的 Serial 收集器,收集垃圾時,必須暫停其他所有工作執行緒,也就是必有停頓,使用複製演算法

3. Parallel Scavenge 收集器

類似 Par New 收集器,但關注點是達到一個可控的吞葉量,使用複製演算法


吞吐量公式:



吞吐量示例:

程式碼執行 95 秒 , 垃圾收集器執行 5 秒 , 那麼吞吐量就是 $\frac{95}{95 + 5} = 0.95$

4. Serial old 收集器

Serial 收集器的老年代版本,單執行緒收集器,使用標記-整理演算法。它主要有兩大用途:

  1. 在JDK1.5及以前的版本中與 Parallel Scavenge 收集器搭配使用;
  2. 作為CMS收集器的後備方案。

5. Parallel old 收集器

Parallel Scavenge收集器的老年代版本。使用多執行緒和標記-整理演算法。在注重吞吐量以及CPU資源的場合,都可以優先考慮 Parallel Scavenge收集器和Parallel Old收集器(JDK8預設的新生代和老年代收集器)。

6. CMS 收集器

是一種以獲取最短回收停頓時間為目標的收集器。它非常符合在注重使用者體驗的應用上使用,它是HotSpot虛擬機器第一款真正意義上的併發收集器,它第一次實現了讓垃圾收集執行緒與使用者執行緒(基本上)同時工作。


整個過程分為5個步驟:初始標記→併發標記→重新標記→併發清理→併發重置。

7. G1 收集器

基於標記整理演算法實現,運作流程主要包括以下:初始標→併發標記→最終標記→篩選回收,不會產生空間碎片,可以精確地控制停頓,可以支援使用者設定期望停頓時間。


不追求一次性將 Java 堆清理乾淨,只要垃圾收集的速度趕得上物件分配的速度即可。



參考:

[1] 周志明. 深入理解 Java 虛擬機器(第3版).

相關文章