「入門篇」初識JVM (下下) - GC

鬼面笑臉發表於2022-04-01

垃圾收集主要是針對堆和方法區進行;程式計數器、虛擬機器棧和本地方法棧這三個區域屬於執行緒私有的,只存在於> 執行緒的生命週期內,執行緒結束之後也會消失,因此不需要對這三個區域進行垃圾回收。

GC - Java垃圾回收機制

判斷一個物件是否可被回收

1. 引用計數演算法

給物件新增一個引用計數器,當物件增加一個引用時計數器加 1,引用失效時計數器減 1。引用計數為 0 的物件可被回收。

這個方法實現簡單,效率高,但是目前主流的虛擬機器中並沒有選擇這個演算法來管理記憶體,其最主要的原因是它很難解決物件之間相互迴圈引用的問題。 所謂物件之間的相互引用問題,如下面程式碼所示:除了物件 objA 和 objB 相互引用著對方之外,這兩個物件之間再無任何引用。但是他們因為互相引用對方,導致它們的引用計數器都不為 0,於是引用計數演算法無法通知 GC 回收器回收他們。

正因為迴圈引用的存在,因此 Java 虛擬機器不使用引用計數演算法

示例程式碼
public class ReferenceCountingGC {

    public Object instance = null;

    public static void main(String[] args) {
        ReferenceCountingGC objectA = new ReferenceCountingGC();
        ReferenceCountingGC objectB = new ReferenceCountingGC();
        objectA.instance = objectB;
        objectB.instance = objectA;
    }
}

2. 可達性分析演算法

通過 GC Roots 作為起始點進行搜尋,節點所走過的路徑稱為引用鏈,當一個物件到 GC Roots 沒有任何引用鏈相連的話,則證明此物件是不可用的,需要被回收。(能夠到達到的物件都是存活的,不可達的物件可被回收)

哪些物件可以作為 GC Roots 呢?

  • 虛擬機器棧中引用的物件
  • 本地方法棧中引用的物件
  • 方法區中類靜態屬性引用的物件
  • 方法區中的常量引用的物件
  • 所有被同步鎖持有的物件

物件可以被回收,就代表一定會被回收嗎?

即使在可達性分析法中不可達的物件,也並非是“非死不可”的,這時候它們暫時處於“緩刑階段”,要真正宣告一個物件死亡,至少要經歷兩次標記過程;可達性分析法中不可達的物件被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行 finalize方法。當物件沒有覆蓋 finalize方法,或 finalize方法已經被虛擬機器呼叫過時,虛擬機器將這兩種情況視為沒有必要執行。

被判定為需要執行的物件將會被放在一個佇列中進行第二次標記,除非這個物件與引用鏈上的任何一個物件建立關聯,否則就會被真的回收。

3. 方法區的回收

因為方法區主要存放永久代物件,而永久代物件的回收率比新生代低很多,因此在方法區上進行回收價效比不高。

主要是對常量池的回收和對類的解除安裝。
在大量使用反射、動態代理、CGLib 等 ByteCode 框架、動態生成 JSP 以及 OSGi 這類頻繁自定義 ClassLoader 的場景都需要虛擬機器具備類解除安裝功能,以保證不會出現記憶體溢位。
類的解除安裝條件很多,需要滿足以下三個條件,並且滿足了也不一定會被解除安裝:

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

可以通過 -Xnoclassgc 引數來控制是否對類進行解除安裝。

4. finalize()

finalize() 類似 C++ 的解構函式,用來做關閉外部資源等工作。但是 try-finally 等方式可以做的更好,並且該方法執行代價高昂,不確定性大,無法保證各個物件的呼叫順序,因此最好不要使用。

當一個物件可被回收時,如果需要執行該物件的 finalize() 方法,那麼就有可能通過在該方法中讓物件重新被引用,從而實現自救。自救只能進行一次,如果回收的物件之前呼叫了 finalize() 方法自救,後面回收時不會呼叫 finalize() 方法。

測試程式碼
class Resurrection
{
    public static Resurrection obj;

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("Resurrection finalize called !!!");
        obj = this;//在finalize方法中復活物件
    }

    @Override
    public String toString() {
        return "I am Resurrection";
    }

    public static void main(String[] args) throws InterruptedException {
        obj = new Resurrection();
        obj = null; //將obj設為null
        System.gc();//垃圾回收

        Thread.sleep(1000);//
        if(obj == null) {
            System.out.println("obj is null");
        } else {
            System.out.println("obj is alive");
        }

        System.out.println("第2次呼叫gc後");
        obj = null;//由於obj被複活,此處再次將obj設為null
        System.gc();//再次gc
        Thread.sleep(1000);
        if(obj == null) {
            //物件的finalize方法僅僅會被呼叫一次,所以可以預見再次設定obj為null後,obj會被垃圾回收,該語句會被呼叫
            System.out.println("obj is null");
        } else {
            System.out.println("obj is alive");
        }
    }
}
測試結果
Resurrection finalize called !!!
obj is alive
第2次呼叫gc後
obj is null

Object 類中的 finalize 方法一直被認為是一個糟糕的設計,成為了 Java 語言的負擔,影響了 Java 語言> 的安全和 GC 的效能。JDK9 版本及後續版本中各個類中的 finalize 方法會被逐漸棄用移除。忘掉它的存在吧!
參考:

引用型別

無論是通過引用計算演算法判斷物件的引用數量,還是通過可達性分析演算法判斷物件是否可達,判定物件是否可被回收都與引用有關。

Java 具有四種強度不同的引用型別。

1. 強引用

被強引用關聯的物件不會被回收。

使用 new 一個新物件的方式來建立強引用。

Object obj = new Object();

2. 軟引用

被軟引用關聯的物件只有在記憶體不夠的情況下才會被回收。

使用 SoftReference 類來建立軟引用。

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  // 使物件只被軟引用關聯

3.弱引用

被弱引用關聯的物件一定會被回收,也就是說它只能存活到下一次垃圾回收發生之前。

使用 WeakReference 類來實現弱引用。

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;

4. 虛引用

又稱為幽靈引用或者幻影引用。一個物件是否有虛引用的存在,完全不會對其生存時間構成影響,虛引用並不會決定物件的生命週期,也無法通過虛引用取得一個物件。

虛引用主要用來跟蹤物件被垃圾回收的活動。

虛引用與軟引用和弱引用的一個區別在於: 虛引用必須和引用佇列(ReferenceQueue)聯合使用。當垃圾回收器準備回收一個物件時,如果發現它還有虛引用,就會在回收物件的記憶體之前,把這個虛引用加入到與之關聯的引用佇列中。程式可以通過判斷引用佇列中是否已經加入了虛引用,來了解被引用的物件是否將要被垃圾回收。程式如果發現某個虛引用已經被加入到引用佇列,那麼就可以在所引用的物件的記憶體被回收之前採取必要的行動。

為一個物件設定虛引用關聯的唯一目的就是能在這個物件被回收時收到一個系統通知。

使用 PhantomReference 來實現虛引用。

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null;

特別注意,在程式設計中一般很少使用弱引用與虛引用,使用軟引用的情況較多,這是因為軟引用可以加速 JVM 對垃圾記憶體的回收速度,可以維護系統的執行安全,防止記憶體溢位(OutOfMemory)等問題的產生。

垃圾回收演算法

1.標記 - 清除

將存活的物件進行標記,然後清理掉未被標記的物件。

不足:

  • 標記和清除過程效率都不高;
  • 會產生大量不連續的記憶體碎片,導致無法給大物件分配記憶體。

2. 標記 - 整理

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

3.複製

將記憶體劃分為大小相等的兩塊,每次只使用其中一塊,當這一塊記憶體用完了就將還存活的物件複製到另一塊上面,然後再把使用過的記憶體空間進行一次清理。

主要不足是隻使用了記憶體的一半。

現在的商業虛擬機器都採用這種收集演算法來回收新生代,但是並不是將新生代劃分為大小相等的兩塊,而是分為一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 空間和其中一塊 Survivor。在回收時,將 Eden 和 Survivor 中還存活著的物件一次性複製到另一塊 Survivor 空間上,最後清理 Eden 和使用過的那一塊 Survivor。

HotSpot 虛擬機器的 Eden 和 Survivor 的大小比例預設為 8:1,保證了記憶體的利用率達到 90%。如果每次回收有多於 10% 的物件存活,那麼一塊 Survivor 空間就不夠用了,此時需要依賴於老年代進行分配擔保,也就是借用老年代的空間儲存放不下的物件。

4.分代收集

現在的商業虛擬機器採用分代收集演算法,它根據物件存活週期將記憶體劃分為幾塊,不同塊採用適當的收集演算法。

比如在新生代中,每次收集都會有大量物件死去,所以可以選擇”標記-複製“演算法,只需要付出少量物件的複製成本就可以完成每次垃圾收集。而老年代的物件存活機率是比較高的,而且沒有額外的空間對它進行分配擔保,所以我們必須選擇“標記-清除”或“標記-整理”演算法進行垃圾收集。

一般將堆分為新生代和老年代。

  • 新生代使用: 複製演算法
  • 老年代使用: 標記 - 清除 或者 標記 - 整理 演算法

延伸面試問題

  • HotSpot 為什麼要分為新生代和老年代?

垃圾收集器

以上是 HotSpot 虛擬機器中的 7 個垃圾收集器,連線表示垃圾收集器可以配合使用。

  • 單執行緒與多執行緒: 單執行緒指的是垃圾收集器只使用一個執行緒進行收集,而多執行緒使用多個執行緒;
  • 序列與並行: 序列指的是垃圾收集器與使用者程式交替執行,這意味著在執行垃圾收集的時候需要停頓使用者程式;並形指的是垃圾收集器和使用者程式同時執行。除了 CMS 和 G1 之外,其它垃圾收集器都是以序列的方式執行。

1. Serial 收集器

Serial 翻譯為序列,也就是說它以序列的方式執行。

它是單執行緒的收集器,只會使用一個執行緒進行垃圾收集工作。
新生代採用標記-複製演算法,老年代採用標記-整理演算法。
它的優點是簡單高效,對於單個 CPU 環境來說,由於沒有執行緒互動的開銷,因此擁有最高的單執行緒收集效率。

它是 Client 模式下的預設新生代收集器,因為在使用者的桌面應用場景下,分配給虛擬機器管理的記憶體一般來說不會很大。Serial 收集器收集幾十兆甚至一兩百兆的新生代停頓時間可以控制在一百多毫秒以內,只要不是太頻繁,這點停頓是可以接受的。

2. ParNew 收集器

它是 Serial 收集器的多執行緒版本。

是 Server 模式下的虛擬機器首選新生代收集器,除了效能原因外,主要是因為除了 Serial 收集器,只有它能與 CMS 收集器配合工作。

預設開啟的執行緒數量與 CPU 數量相同,可以使用 -XX:ParallelGCThreads 引數來設定執行緒數。

3. Parallel Scavenge 收集器

與 ParNew 一樣是多執行緒收集器。

其它收集器關注點是儘可能縮短垃圾收集時使用者執行緒的停頓時間,而它的目標是達到一個可控制的吞吐量,它被稱為“吞吐量優先”收集器。這裡的吞吐量指 CPU 用於執行使用者程式碼的時間佔總時間的比值。

停頓時間越短就越適合需要與使用者互動的程式,良好的響應速度能提升使用者體驗。而高吞吐量則可以高效率地利用 CPU 時間,儘快完成程式的運算任務,主要適合在後臺運算而不需要太多互動的任務。

縮短停頓時間是以犧牲吞吐量和新生代空間來換取的: 新生代空間變小,垃圾回收變得頻繁,導致吞吐量下降。

可以通過一個開關引數打卡 GC 自適應的調節策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 區的比例、晉升老年代物件年齡等細節引數了。虛擬機器會根據當前系統的執行情況收集效能監控資訊,動態調整這些引數以提供最合適的停頓時間或者最大的吞吐量。

這是 JDK1.8 預設收集器

使用 java -XX:+PrintCommandLineFlags -version 命令檢視

-XX:InitialHeapSize=199822912 -XX:MaxHeapSize=3197166592 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops
 -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
java version "1.8.0_152"
Java(TM) SE Runtime Environment (build 1.8.0_152-b16)
Java HotSpot(TM) 64-Bit Server VM (build 25.152-b16, mixed mode)

JDK1.8 預設使用的是 Parallel Scavenge + Parallel Old,如果指定了-XX:+UseParallelGC 引數,則預設指定了-XX:+UseParallelOldGC,可以使用-XX:-UseParallelOldGC 來禁用該功能

4. Serial Old 收集器

Serial 收集器的老年代版本,也是給 Client 模式下的虛擬機器使用。如果用在 Server 模式下,它有兩大用途:

  • 在 JDK 1.5 以及之前版本(Parallel Old 誕生以前)中與 Parallel Scavenge 收集器搭配使用。
  • 作為 CMS 收集器的後備預案,在併發收集發生 Concurrent Mode Failure 時使用。

5. Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本

在注重吞吐量以及 CPU 資源敏感的場合,都可以優先考慮 Parallel Scavenge 加 Parallel Old 收集器。

6. CMS 收集器

CMS(Concurrent Mark Sweep),Mark Sweep 指的是標記 - 清除演算法。

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。它非常符合在注重使用者體驗的應用上使用。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虛擬機器第一款真正意義上的併發收集器,它第一次實現了讓垃圾收集執行緒與使用者執行緒(基本上)同時工作。

分為以下四個流程:

  • 初始標記: 僅僅只是標記一下 GC Roots 能直接關聯到的物件,速度很快,需要停頓。

  • 併發標記: 進行 GC Roots Tracing 的過程,它在整個回收過程中耗時最長,不需要停頓。

    同時開啟 GC 和使用者執行緒,用一個閉包結構去記錄可達物件。但在這個階段結束,這個閉包結構並不能保證包含當前所有的可達物件。因為使用者執行緒可能會不斷的更新引用域,所以 GC 執行緒無法保證可達性分析的實時性。所以這個演算法裡會跟蹤記錄這些發生引用更新的地方。

  • 重新標記: 為了修正併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,需要停頓。

  • 併發清除: 開啟使用者執行緒,同時 GC 執行緒開始對未標記的區域做清掃。 不需要停頓。

在整個過程中耗時最長的併發標記和併發清除過程中,收集器執行緒都可以與使用者執行緒一起工作,不需要進行停頓。

優點:併發進行、不需要停頓

具有以下缺點:

  • 吞吐量低: 低停頓時間是以犧牲吞吐量為代價的,導致 CPU 利用率不夠高
  • 無法處理浮動垃圾,可能出現 Concurrent Mode Failure。浮動垃圾是指併發清除階段由於使用者執行緒繼續執行而產生的垃圾,這部分垃圾只能到下一次 GC 時才能進行回收。由於浮動垃圾的存在,因此需要預留出一部分記憶體,意味著 CMS 收集不能像其它收集器那樣等待老年代快滿的時候再回收。如果預留的記憶體不夠存放浮動垃圾,就會出現 Concurrent Mode Failure,這時虛擬機器將臨時啟用 Serial Old 來替代 CMS。
  • 標記 - 清除演算法導致的空間碎片,往往出現老年代空間剩餘,但無法找到足夠大連續空間來分配當前物件,不得不提前觸發一次 Full GC。

7. G1收集器

G1(Garbage-First),它是一款面向服務端應用的垃圾收集器,在多 CPU 和大記憶體的場景下有很好的效能。HotSpot 開發團隊賦予它的使命是未來可以替換掉 CMS 收集器。

被視為 JDK1.7 中 HotSpot 虛擬機器的一個重要進化特徵

堆被分為新生代和老年代,其它收集器進行收集的範圍都是整個新生代或者老年代,而 G1 可以直接對新生代和老年代一起回收。

G1 把堆劃分成多個大小相等的獨立區域(Region),新生代和老年代不再物理隔離。

通過引入 Region 的概念,從而將原來的一整塊記憶體空間劃分成多個的小空間,使得每個小空間可以單獨進行垃圾回收。這種劃分方法帶來了很大的靈活性,使得可預測的停頓時間模型成為可能。通過記錄每個 Region 垃圾回收時間以及回收所獲得的空間(這兩個值是通過過去回收的經驗獲得),並維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的 Region。

每個 Region 都有一個 Remembered Set,用來記錄該 Region 物件的引用物件所在的 Region。通過使用 Remembered Set,在做可達性分析的時候就可以避免全堆掃描。

如果不計算維護 Remembered Set 的操作,G1 收集器的運作大致可劃分為以下幾個步驟:

  • 初始標記
  • 併發標記
  • 最終標記: 為了修正在併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分標記記錄,虛擬機器將這段時間物件變化記錄線上程的 Remembered Set Logs 裡面,最終標記階段需要把 Remembered Set Logs 的資料合併到 Remembered Set 中。這階段需要停頓執行緒,但是可並行執行。
  • 篩選回收: 首先對各個 Region 中的回收價值和成本進行排序,根據使用者所期望的 GC 停頓時間來制定回收計劃。此階段其實也可以做到與使用者程式一起併發執行,但是因為只回收一部分 Region,時間是使用者可控制的,而且停頓使用者執行緒將大幅度提高收集效率。

具備如下特點:

  • 並行與併發:G1 能充分利用 CPU、多核環境下的硬體優勢,使用多個 CPU(CPU 或者 CPU 核心)來縮短 Stop-The-World 停頓時間。部分其他收集器原本需要停頓 Java 執行緒執行的 GC 動作,G1 收集器仍然可以通過併發的方式讓 java 程式繼續執行。
  • 分代收集:雖然 G1 可以不需要其他收集器配合就能獨立管理整個 GC 堆,但是還是保留了分代的概念。
  • 空間整合: 整體來看是基於“標記 - 整理”演算法實現的收集器,從區域性(兩個 Region 之間)上來看是基於“複製”演算法實現的,這意味著執行期間不會產生記憶體空間碎片。
  • 可預測的停頓: 能讓使用者明確指定在一個長度為 M 毫秒的時間片段內,消耗在 GC 上的時間不得超過 N 毫秒。

更詳細內容請參考: Getting Started with the G1 Garbage Collector

8. ZGC 收集器

與 CMS 中的 ParNew 和 G1 類似,ZGC 也採用標記-複製演算法,不過 ZGC 對該演算法做了重大改進。

在 ZGC 中出現 Stop The World 的情況會更少!
詳情可以看 :《新一代垃圾回收器 ZGC 的探索與實踐》

記憶體分配與回收策略

Minor GC 和 Full GC

  • Minor GC: 發生在新生代上,因為新生代物件存活時間很短,因此 Minor GC 會頻繁執行,執行的速度一般也會比較快。
  • Full GC: 發生在老年代上,老年代物件其存活時間長,因此 Full GC 很少執行,執行速度會比 Minor GC 慢很多。

記憶體分配策略

1. 物件優先在 Eden 分配

大多數情況下,物件在新生代 Eden 區分配,當 Eden 區空間不夠時,發起 Minor GC。

2. 大物件直接進入老年代

大物件是指需要連續記憶體空間的物件,最典型的大物件是那種很長的字串以及陣列。

經常出現大物件會提前觸發垃圾收集以獲取足夠的連續空間分配給大物件。

-XX:PretenureSizeThreshold,大於此值的物件直接在老年代分配,避免在 Eden 區和 Survivor 區之間的大量記憶體複製。

3. 長期存活的物件進入老年代

為物件定義年齡計數器,物件在 Eden 出生並經過 Minor GC 依然存活,將移動到 Survivor 中,年齡就增加 1 歲,增加到一定年齡則移動到老年代中。
-XX:MaxTenuringThreshold 用來定義年齡的閾值。

4. 動態物件年齡判定

虛擬機器並不是永遠地要求物件的年齡必須達到 MaxTenuringThreshold 才能晉升老年代,如果在 Survivor 中相同年齡所有物件大小的總和大於 Survivor 空間的一半,則年齡大於或等於該年齡的物件可以直接進入老年代,無需等到 MaxTenuringThreshold 中要求的年齡。

5. 空間分配擔保

在發生 Minor GC 之前,虛擬機器先檢查老年代最大可用的連續空間是否大於新生代所有物件總空間,如果條件成立的話,那麼 Minor GC 可以確認是安全的。

如果不成立的話虛擬機器會檢視 HandlePromotionFailure 設定值是否允許擔保失敗,如果允許那麼就會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代物件的平均大小,如果大於,將嘗試著進行一次 Minor GC;如果小於,或者 HandlePromotionFailure 設定不允許冒險,那麼就要進行一次 Full GC。

Full GC 的觸發條件

對於 Minor GC,其觸發條件非常簡單,當 Eden 空間滿時,就將觸發一次 Minor GC。而 Full GC 則相對複雜,有以下條件:

1. 呼叫 System.gc()

只是建議虛擬機器執行 Full GC,但是虛擬機器不一定真正去執行。不建議使用這種方式,而是讓虛擬機器管理記憶體。

2. 老年代空間不足

老年代空間不足的常見場景為前文所講的大物件直接進入老年代、長期存活的物件進入老年代等。

為了避免以上原因引起的 Full GC,應當儘量不要建立過大的物件以及陣列。除此之外,可以通過 -Xmn 虛擬機器引數調大新生代的大小,讓物件儘量在新生代被回收掉,不進入老年代。還可以通過 -XX:MaxTenuringThreshold 調大物件進入老年代的年齡,讓物件在新生代多存活一段時間。

3. 空間分配擔保失敗

使用複製演算法的 Minor GC 需要老年代的記憶體空間作擔保,如果擔保失敗會執行一次 Full GC。具體內容請參考上面的第五小節。

4. JDK 1.7 及以前的永久代空間不足

在 JDK 1.7 及以前,HotSpot 虛擬機器中的方法區是用永久代實現的,永久代中存放的為一些 Class 的資訊、常量、靜態變數等資料。

當系統中要載入的類、反射的類和呼叫的方法較多時,永久代可能會被佔滿,在未配置為採用 CMS GC 的情況下也會執行 Full GC。如果經過 Full GC 仍然回收不了,那麼虛擬機器會丟擲 java.lang.OutOfMemoryError。

為避免以上原因引起的 Full GC,可採用的方法為增大永久代空間或轉為使用 CMS GC。

5. Concurrent Mode Failure

執行 CMS GC 的過程中同時有物件要放入老年代,而此時老年代空間不足(可能是 GC 過程中浮動垃圾過多導致暫時性的空間不足),便會報 Concurrent Mode Failure 錯誤,並觸發 Full GC。

面試題

  • 如何判斷物件是否死亡(兩種方法)。
  • 簡單的介紹一下強引用、軟引用、弱引用、虛引用(虛引用與軟引用和弱引用的區別、使用軟引用能帶來的好處)。
  • 如何判斷一個常量是廢棄常量
  • 如何判斷一個類是無用的類
  • 垃圾收集有哪些演算法,各自的特點?
  • HotSpot 為什麼要分為新生代和老年代?
  • 常見的垃圾回收器有哪些?
  • 介紹一下 CMS,G1 收集器。
  • Minor Gc 和 Full GC 有什麼不同呢?

參考

相關文章