JVM:這是一份全面 & 詳細的 垃圾收集演算法(GC) 學習指南

  • 2019 年 10 月 25 日
  • 筆記

版權聲明:本文為部落客原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處鏈接和本聲明。

本文鏈接:https://blog.csdn.net/carson_ho/article/details/102656696

前言

  • 不同的記憶體區域採用不同的垃圾收集演算法
  • 而不同垃圾收集演算法決定了垃圾收集的效率 & 效果
  • 本文將全面講解垃圾收集演算法,包括標記-清除、複製、標記-整理等,希望你們會喜歡

在接下來的日子,我會推出一系列講解JVM的文章,具體如下;

目錄

1. 垃圾收集演算法 類型

  • 垃圾收集演算法 類型 分為4類,如下圖:
  • 下面我將對每個進行詳細講解。

2. 標記-清除 演算法

這是 垃圾收集演算法中 最最基礎的演算法。

2.1 演算法思想

演算法分為兩個階段:

  1. 標記階段:標記出所有需要回收的對象;
  2. 清除階段:統一清除(回收)所有被標記的對象。

下面主要講解標記階段。標記階段主要分為:(先進行可達性分析)

  1. 第一次標記 & 篩選
  2. 第二次標記 & 篩選

a. 可達性分析

閱讀前請看文章:JVM 引用計數法 & 引用鏈法詳細解析:如何判斷一個Java對象是否存活

b. 第一次標記 & 篩選

i. 方式描述

對象 在 可達性分析中 被判斷為不可達後,會被第一次標記 & 篩選

a. 不篩選 = 繼續留在 」即將回收「的集合里,準備被回收 b. 篩選 = 從 」即將回收「的集合取出

ii. 篩選的標準

該對象是否有必要執行 finalize()方法

  1. 若有必要執行(人為設置),則篩選出來,進入下一階段:第二次標記 & 篩選;
  2. 若沒必要執行,判斷該對象死亡,不篩選 並等待回收

當對象無 finalize()方法 或 finalize()已被虛擬機調用過,則視為「沒必要執行」

c. 第二次標記 & 篩選

當對象經過了第一次的標記 & 篩選,會被進行第二次標記,並被進行 篩選

i. 方式描述

該對象會被放到一個 F-Queue 隊列中,並由 虛擬機自動建立、優先順序低的Finalizer 執行緒去執行 隊列中該對象的finalize()

  1. finalize()只會被執行一次
  2. 但並不承諾等待finalize()運行結束。這是為了防止 finalize()執行緩慢 / 停止 使得 F-Queue隊列其他對象永久等待。

ii. 判斷標準

在執行finalize()過程中,若對象依然沒與引用鏈上的GC Roots 直接關聯 或 間接關聯(即關聯上與GC Roots 關聯的對象),那麼該對象將被判斷死亡,不篩選(留在」即將回收「集合里) 並 等待回收

總結

2.2 優點

演算法簡單、實現簡單

2.3 缺點

  • 效率問題:即 標記和清除 兩個過程效率不高
  • 空間問題:標記 - 清除後,會產生大量不連續的記憶體碎片。

這導致 以後程式 需要分配較大空間對象時 無法找到足夠大的連續記憶體 而被迫 觸發另外一次垃圾收集行為,這導致非常浪費資源。

下面繼續介紹的演算法就是為了解決上述兩個問題的。

2.4 應用場景

對象存活率較低 & 垃圾回收行為頻率低 的場景

如老年代區域,因為老年代區域回收頻次少、回收數量少,所以對於效率問題 & 空間問題不會很明顯。

3. 複製演算法

該演算法的出現是為了解決 標記-清除演算法中 效率 & 空間問題的。

3.1 演算法思想

  • 將記憶體分為大小相等的兩塊,每次使用其中一塊;
  • 當 使用的這塊記憶體 用完,就將 這塊記憶體上還存活的對象 複製到另一塊還沒試用過的記憶體上
  • 最終將使用的那塊記憶體一次清理掉。

示意圖如下:

3.2 優點

  1. 解決了標記-清除演算法中 清除效率低的問題

每次僅回收記憶體的一半區域

  1. 解決了標記-清除演算法中 空間產生不連續記憶體碎片的問題

將已使用記憶體上的存活對象 移動到棧頂的指針,按順序分配記憶體即可。

3.3 缺點

  1. 每次使用的記憶體縮小為原來的一半。
  2. 當對象存活率較高的情況下需要做很多複製操作,即效率會變低

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 演算法思路

演算法分為三個階段:

  1. 標記階段:標記出所有需要回收的對象;
  2. 整理階段:讓所有存活的對象都向一端移動
  3. 清除階段:統一清除(回收)端以外的對象。

示意圖如下:

4.2 優點

  • 解決了標記-清除演算法中 清除效率低的問題:一次清楚端外區域
  • 解決了標記-清除演算法中 空間產生不連續記憶體碎片的問題:將已使用記憶體上的存活對象 移動到棧頂的指針,按順序分配記憶體即可。

4.3 應用場景

對象存活率較低 & 垃圾回收行為頻率低 的場景

如老年代區域,因為老年代區域回收頻次少、回收數量少,所以對於效率問題 & 空間問題不會很明顯。

5. 分代收集演算法

主流的虛擬機基本都採用該演算法,下面會著重講解。

5.1 演算法思路

  • 根據 對象存活周期的不同 將 Java堆記憶體 分為:新生代 & 老年代 。分配比例如下:
  • 根據 兩塊區域特點 選擇 對應的垃圾收集演算法(即上面介紹的演算法),具體細節請看下圖

5.2 具體存儲過程

  1. 新建的對象 一般會被優先分配到新生代的Eden區、From Survivor區

  • 大對象(如很長的字元串以及數組)會直接分配到老年代,這是為了避免在 Eden 區 和 Survivor區之間發生大量的記憶體複製(因為新生代會採用複製演算法進行垃圾收集)
  1. 這些對象經過第一次 Minor GC後,若仍然存活,將會被移到To Survivor區

一次清理掉Eden、From Survivor區域

  1. 在 To Survivor 區每經過一輪 Minor GC ,該對象的年齡就+1
  2. 當對象年齡達到一定時(閾值默認=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 應用場景

現在主流的虛擬機基本都採用 分代收集演算法 ,即根據不同區域特點選擇不同垃圾收集演算法。

  1. 新生代 區域:採用 複製演算法
  2. 老年代 區域:採用 標記-清除 演算法、標記 - 整理 演算法

6. 總結

  • 用一張圖總結上述4個垃圾收集演算法
  • 在接下來的日子,我會推出一系列講解JVM的文章,具體如下;

Tags: