前言
垃圾回收需要思考三件事情,哪些記憶體需要回收?什麼時候回收?如何回收?
一、哪些記憶體需要回收
JVM 的記憶體區域中,程式計數器、虛擬機器棧和本地方法棧的生命週期是隨執行緒而生,隨執行緒而滅的。這幾個區域的記憶體分配和回收都具有確定性,不需要過多考慮回收問題,當方法或執行緒結束時,記憶體自然就跟著回收了。
Java 堆和方法區是具有不確定性的,比如一個方法根據不同的條件執行可能需要的記憶體是不同的。只有處於執行期才能知道需要建立哪些物件,建立多少物件,這部分的記憶體分配和回收是動態的,垃圾回收所關注的就是這部分記憶體。
二、物件何時會“死亡”
Java 堆中存放了幾乎所有的物件例項,垃圾回收器在進行回收前,需要判斷哪些物件“存活”,哪些物件“死亡”,“死亡”的物件才會被回收。
1. 引用計數法
給物件中新增一個引用計數器:
- 每當有一個地方引用它,計數器就加 1;
- 當引用失效,計數器就減 1;
- 任何時候計數器為 0 的物件就是不可能再被使用的。
這個方法實現簡單,效率高,但是目前主流的虛擬機器中並沒有選擇這個演算法來管理記憶體,其最主要的原因是它很難解決物件之間相互迴圈引用的問題。
2. 可達性分析演算法
透過一系列被稱為GC Roots
的根物件作為起始節點集,從這些節點開始,透過引用關係向下搜尋,搜尋走過的路徑稱為“引用鏈”,如果某個物件到GC Roots
沒有任何“引用鏈”相連,就說明該物件不可達,即可以被回收。
如下圖,Object 5、Object 6、Object 7 雖有關聯,但是到GC Roots
是不可達的,因此會被判定“死亡”。
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 會針對不同的區域進行垃圾回收。
GC型別一般有以下幾大類:
分類 | 說明 |
---|---|
Minor GC | 也稱“Young GC”,只針對新生代進行的垃圾回收。 |
Major GC | 也稱“Old GC”,只針對老年代進行的垃圾回收 |
Mixed GC | 針對新生代和部分老年代進行垃圾回收,部分垃圾收集器才支援。 |
Full GC | 針對整個Java堆和方法區進行的垃圾回收,耗時最久的GC |
2. 標記-清除演算法
最早出現的垃圾回收演算法,分為“標記”、“清除”兩個階段。可以標記存活的物件,也可以標記要回收的物件。
該演算法有兩個缺點:
- 是執行效率不穩定,隨著物件的增多,標記效率會越來越低;
- 記憶體空間產生很多碎拼,浪費空間,分配大物件時可能需要重新觸發垃圾回收。
標記-清除演算法執行過程如下圖:
3. 標記-複製演算法
簡稱複製演算法,為了解決標記-清除演算法面對大量可回收物件執行效率低的問題,就是將記憶體分成兩個區域,每次只使用其中一個區域,當該區域記憶體滿了之後,會將還存活的物件複製到另一個區域,然後將原區域直接清理掉。
該演算法有兩個缺點:
- 物件存活率過多時,會影響複製的效率;
- 一半記憶體不使用,浪費了大量空間。
標記-複製演算法執行過程如下圖:
由於 Java 的新生代物件存活率不高,所以一般針對新生代的垃圾回收使用標記-複製演算法。
如下圖,將新生代分為記憶體較大的Eden
,和兩塊記憶體較小的Survivor
。每次分配記憶體只使用Eden
和其中一個Survivor
,我們假設第一次分配記憶體是Eden
、Survivor 0
。
- 發生 GC 時,將使用的
Eden
、Survivor 0
中存活的物件一次性複製到Survivor 1
中,然後清理掉Eden
、Survivor 0
記憶體。 - 這時分配的記憶體就變成了
Eden
、Survivor 1
,週而復始。
注意:當Survivor
不足以容納輕 GC 之後的物件時,就需要依賴老年代來進行記憶體分配了。
4. 標記-整理演算法
標記完存活物件以後,讓所有存活物件都向記憶體空間的一端移動,然後在清理掉邊界以外的記憶體。
標記-整理演算法執行過程如下圖:
這種涉及到了物件的移動,如果移動存活物件,尤其是老年代這種每次回收都有大量物件存活的區域,會耗時很多,並且物件移動操作會全部暫停使用者應用程式才能進行,這樣會產生停頓時間。
這種停頓被稱為
Stop The World
。
5. 標記-清除-整理演算法
先使用標記-清除演算法進行垃圾回收,暫時容忍記憶體碎片的存在,直到碎片過多影響物件分配時,在進行標記-整理演算法進行回收,獲得規整空間。
四、經典垃圾收集器
下圖展示了 7 種經典垃圾收集器,若兩兩出現互連情況,則表明兩者它們可以搭配使用。
- 單執行緒收集器 Serial、Serial old
- 並行收集器 Par New、Parallel Scavenge、Parallel old
- 併發收集器 CMS、G1
1. Serial 收集器
最基本、歷史最早的垃圾收集器了。單執行緒的收集器,收集垃圾時,必須暫停其他所有工作執行緒,也就是必有停頓,使用複製演算法。
2. Par New 收集器
多執行緒並行版本的 Serial 收集器,收集垃圾時,必須暫停其他所有工作執行緒,也就是必有停頓,使用複製演算法。
3. Parallel Scavenge 收集器
類似 Par New 收集器,但關注點是達到一個可控的吞葉量,使用複製演算法。
吞吐量公式:
吞吐量示例:
程式碼執行 95 秒 , 垃圾收集器執行 5 秒 , 那麼吞吐量就是 $\frac{95}{95 + 5} = 0.95$
4. Serial old 收集器
Serial 收集器的老年代版本,單執行緒收集器,使用標記-整理演算法。它主要有兩大用途:
- 在JDK1.5及以前的版本中與 Parallel Scavenge 收集器搭配使用;
- 作為CMS收集器的後備方案。
5. Parallel old 收集器
Parallel Scavenge收集器的老年代版本。使用多執行緒和標記-整理演算法。在注重吞吐量以及CPU資源的場合,都可以優先考慮 Parallel Scavenge收集器和Parallel Old收集器(JDK8預設的新生代和老年代收集器)。
6. CMS 收集器
是一種以獲取最短回收停頓時間為目標的收集器。它非常符合在注重使用者體驗的應用上使用,它是HotSpot虛擬機器第一款真正意義上的併發收集器,它第一次實現了讓垃圾收集執行緒與使用者執行緒(基本上)同時工作。
整個過程分為5個步驟:初始標記→併發標記→重新標記→併發清理→併發重置。
7. G1 收集器
基於標記整理演算法實現,運作流程主要包括以下:初始標→併發標記→最終標記→篩選回收,不會產生空間碎片,可以精確地控制停頓,可以支援使用者設定期望停頓時間。
不追求一次性將 Java 堆清理乾淨,只要垃圾收集的速度趕得上物件分配的速度即可。
參考:
[1] 周志明. 深入理解 Java 虛擬機器(第3版).