JVM:這是一份全面 & 詳細的 (GC)垃圾收集演算法 講解攻略

前言
- 不同的記憶體區域採用不同的垃圾收集演算法
- 而不同垃圾收集演算法決定了垃圾收集的效率 & 效果
- 本文將全面講解垃圾收集演算法,包括標記-清除、複製、標記-整理等,希望你們會喜歡
在接下來的日子,我會推出一系列講解 JVM
的文章,具體如下;感興趣可持續關注 ofollow,noindex">Carson_Ho的安卓開發筆記

示意圖
目錄

示意圖
1. 垃圾收集演算法 型別
- 垃圾收集演算法 型別 分為4類,如下圖:

垃圾收集演算法型別
- 下面我將對每個進行詳細講解。
2. 標記-清除 演算法
這是 垃圾收集演算法中 最最基礎的演算法。
2.1 演算法思想
演算法分為兩個階段:
- 標記階段:標記出所有需要回收的物件;
- 清除階段:統一清除(回收)所有被標記的物件。
下面主要講解標記階段。標記階段主要分為:(先進行可達性分析)
- 第一次標記 & 篩選
- 第二次標記 & 篩選
a. 可達性分析
閱讀前請看文章
b. 第一次標記 & 篩選
i. 方式描述
物件 在 可達性分析中 被判斷為不可達後, 會被第一次標記 & 篩選
a. 不篩選 = 繼續留在 ”即將回收“的集合裡,準備被回收
b. 篩選 = 從 ”即將回收“的集合取出
ii. 篩選的標準
該物件是否有必要執行 finalize()
方法
- 若有必要執行(人為設定),則篩選出來,進入下一階段:第二次標記 & 篩選;
- 若沒必要執行,判斷該物件死亡,不篩選 並等待回收
當物件無 finalize()
方法 或 finalize()
已被虛擬機器呼叫過,則視為“沒必要執行”
c. 第二次標記 & 篩選
當物件經過了第一次的標記 & 篩選,會被進行第二次標記,並被進行 篩選
i. 方式描述
該物件會被放到一個 F-Queue
佇列中,並由 虛擬機器自動建立、優先順序低的 Finalizer
執行緒去執行 佇列中該物件的 finalize()
-
finalize()
只會被執行一次 - 但並不承諾等待
finalize()
執行結束。這是為了防止finalize()
執行緩慢 / 停止 使得F-Queue
佇列其他物件永久等待。
ii. 判斷標準
在執行 finalize()
過程中, 若物件依然沒與引用鏈上的 GC Roots
直接關聯 或 間接關聯(即關聯上與 GC Roots
關聯的物件) ,那麼該物件將被判斷死亡,不篩選(留在”即將回收“集合裡) 並 等待回收
總結

總結圖
2.2 優點
演算法簡單、實現簡單
2.3 缺點
- 效率問題:即 標記和清除 兩個過程效率不高
- 空間問題:標記 - 清除後,會產生大量不連續的記憶體碎片。

示意圖
這導致 以後程式 需要分配較大空間物件時 無法找到足夠大的連續記憶體 而被迫 觸發另外一次垃圾收集行為,這導致非常浪費資源。
下面繼續介紹的演算法就是為了解決上述兩個問題的。
2.4 應用場景
物件存活率較低 & 垃圾回收行為頻率低 的場景
如老年代區域,因為老年代區域回收頻次少、回收數量少,所以對於效率問題 & 空間問題不會很明顯。
3. 複製演算法
該演算法的出現是為了解決 標記-清除演算法中 效率 & 空間問題的。
3.1 演算法思想
- 將記憶體分為大小相等的兩塊,每次使用其中一塊;
- 當 使用的這塊記憶體 用完,就將 這塊記憶體上還存活的物件 複製到另一塊還沒試用過的記憶體上
- 最終將使用的那塊記憶體一次清理掉。
示意圖如下:

示意圖
3.2 優點
- 解決了標記-清除演算法中 清除效率低的問題
每次僅回收記憶體的一半區域
- 解決了標記-清除演算法中 空間產生不連續記憶體碎片的問題
將已使用記憶體上的存活物件 移動到棧頂的指標,按順序分配記憶體即可。
3.3 缺點
- 每次使用的記憶體縮小為原來的一半。
- 當物件存活率較高的情況下需要做很多複製操作,即效率會變低
3.4 應用場景
物件存活率較低 & 需要頻繁進行垃圾回收 的區域
如新生代區域
3.5 特別注意
a. 背景
新生代區域在進行垃圾回收時,98%物件都必須得回收
b. 問題
複製演算法中 每次使用的記憶體縮小為原來的一半 利用率低 & 代價太高
c. 解決方案
- 不 按
1:1
的比例 劃分記憶體,而是 按8:1:1
比例 將記憶體劃分為一塊較大的Eden
和兩塊較小的Survivor
區域(From Survivor
、To Survivor
)

示意圖
- 每次使用
Eden
、From Survivor
區域; - 用完後就 將上述兩塊區域存活的物件 複製到
To Survivor
區域上 - 最終一次清理掉
Eden
、From Survivor
區域
使用邏輯 同 改進前
很多同學會問,假如 Eden
、 From Survivor
區域上存活物件所需記憶體大小 > To Survivor
區域怎麼辦?
解決方案: 依賴老年代記憶體區域 做 記憶體分配擔保。
即 To Survivor
區域 存不下來的物件 會通過 記憶體分配擔保機制 暫時儲存在老年代
4. 標記 - 整理 演算法
此演算法類似於第一種標記 - 清除 演算法,只是在中間加多了一步:整理記憶體。
4.1 演算法思路
演算法分為三個階段:
- 標記階段:標記出所有需要回收的物件;
- 整理階段:讓所有存活的物件都向一端移動
- 清除階段:統一清除(回收)端以外的物件。
示意圖如下:

示意圖
4.2 優點
- 解決了標記-清除演算法中 清除效率低的問題:一次清楚端外區域
- 解決了標記-清除演算法中 空間產生不連續記憶體碎片的問題:將已使用記憶體上的存活物件 移動到棧頂的指標,按順序分配記憶體即可。
4.3 應用場景
物件存活率較低 & 垃圾回收行為頻率低 的場景
如老年代區域,因為老年代區域回收頻次少、回收數量少,所以對於效率問題 & 空間問題不會很明顯。
5. 分代收集演算法
主流的虛擬機器基本都採用該演算法,下面會著重講解。
5.1 演算法思路
- 根據 物件存活週期的不同 將
Java
堆記憶體 分為:新生代 & 老年代 。分配比例如下:

示意圖
- 根據 兩塊區域特點 選擇 對應的垃圾收集演算法(即上面介紹的演算法),具體細節請看下圖

示意圖
5.2 具體儲存過程
- 新建的物件 一般會被優先分配到新生代的
Eden
區、From Survivor
區
- 大物件(如很長的字串以及陣列)會直接分配到老年代 ,這是為了避免在
Eden
區 和Survivor
區之間發生大量的記憶體複製(因為新生代會採用複製演算法進行垃圾收集)
- 這些物件經過第一次
Minor GC
後,若仍然存活,將會被移到To Survivor
區
一次清理掉 Eden
、 From Survivor
區域
- 在
To Survivor
區每經過一輪Minor GC
,該物件的年齡就+1 - 當物件年齡達到一定時(閾值預設=15),就會被移動到老年代。
- 即新生代的物件在存活一定時間後,會被移動儲存到老年代區域。
- 還有一種 新生代物件被移懂到老年代區域 的情況是: 動態物件年齡判定 。即如果在
Survivor
區中 所有相同年齡物件的大小總和 大於
Survivor
區記憶體大小一半時,所有大於或等於該年齡的物件都會直接進入老年代。
特別注意
From Survivor
和 To Survivor
之間會經常互換角色。
每次發生GC時,把 Eden
區和 From Survivor
區中 存活且沒超過年齡閾值的物件 複製到 To Survivor
區中(此時 To Survivor
變成了 From Survivor
),然後 From Survivor
清空(此時 From Survivor
變成了 To Survivor
)
5.2 優點
效率高、空間利用率高
根據不同區域特點 選擇 不同的垃圾收集演算法
5.3 應用場景
現在主流的虛擬機器基本都採用 分代收集演算法 ,即根據不同區域特點選擇不同垃圾收集演算法。
- 新生代 區域:採用 複製演算法
- 老年代 區域:採用 標記-清除 演算法、標記 - 整理 演算法
6. 總結
- 用一張圖總結上述4個垃圾收集演算法

總結
- 在接下來的日子,我會推出一系列講解
JVM
的文章,具體如下;感興趣可持續關注 Carson_Ho的安卓開發筆記

示意圖