JVM虛擬機器-垃圾回收機制與垃圾收集器概述

阿dun發表於2021-05-19

前言

往往被問到Java與C/C++有什麼區別的時候,最先想到的答案就是Java可與自動回收記憶體垃圾。

在JVM學習中,垃圾回收幾乎是最重要的知識點。

那麼,自動垃圾回收機制到底是如何實現的呢,下面我們來梳理一遍。

什麼是垃圾回收

垃圾回收(Garbage Collection)誕生於1960年 MIT 的 Lisp 語言,距今已經超過半個世紀了。

垃圾回收顧名思義,就是收集垃圾,JVM中的垃圾就是指的記憶體中不再使用的物件。

將這些不再使用的物件清除,給後來的新物件騰地方。

後文我們簡稱GC

垃圾回收的區域

Java 的自動記憶體管理主要是針對物件記憶體的回收物件記憶體的分配

Java 堆是垃圾收集器管理的主要區域,而 Java 自動記憶體管理最核心的功能是 記憶體中物件的分配與回收,因此也被稱作GC 堆(Garbage Collected Heap)

從垃圾回收的角度,由於現在收集器基本都採用分代垃圾收集演算法,所以 Java 堆還可以細分:

堆分為新生代(佔堆1/3),老生代(佔堆2/3)

  • 新生代(內部比例8:1:1)
    • Eden 空間
    • From Survivor 空間
    • To Survivor 空間
  • 老年代

進一步劃分的目的是更好地回收記憶體,或者更快地分配記憶體。

image-20210518231343790

垃圾回收機制

流程

  • 大多數情況,物件都會首先在 Eden 區域分配,當 eden 區沒有足夠空間進行分配時,虛擬機器將發起一次新生代垃圾回收(Minor GC)
  • 大物件會直接進入老年代,為了避免為大物件分配記憶體時由於分配擔保機制帶來的複製而降低效率。
  • 在一次Minor GC後,如果物件還存活,則會進入兩個Survivor中的一個,然後物件的年齡加 1。
  • 它的年齡增加到年齡閾值(預設為 15 ),就會被晉升到老年代中。
  • 當老年代空間不足時,將會觸發老年代回收(Major GC)

針對 HotSpot 實現,它裡面的 GC 其實準確分類只有兩大種:

部分收集 (Partial GC):

  • 新生代收集(Minor GC / Young GC):只對新生代進行垃圾收集;
  • 老年代收集(Major GC / Old GC):只對老年代進行垃圾收集。
  • 混合收集(Mixed GC):對整個新生代和部分老年代進行垃圾收集。

整堆收集 (Full GC):收集整個 Java 堆和方法區。

物件晉升到老年代的年齡閾值,可以通過引數 -XX:MaxTenuringThreshold 設定

怎麼判斷物件已經死亡

垃圾回收前的第一步就是要判斷哪些物件已經死亡,主要用到如下幾種演算法來判斷。

引用計數法

原理很簡單,如下:

  • 給物件中新增一個引用計數器,每當有一個地方引用它,計數器就加 1;

  • 當引用失效,計數器就減 1;任何時候計數器為 0 的物件就是不可能再被使用的。

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

迴圈引用就是兩個物件互相引用,但是又沒有其他任何物件使用這兩個物件,兩個物件就像是互相抱著的兩個孤兒,非常可憐。

image-20210518233415001

可達性分析演算法

這個原理也很簡單,如下:

  • 定義一系列的稱為 “GC Roots” 的物件作為根起點
  • 從這些節點開始向下搜尋,節點所走過的路徑稱為引用鏈
  • 當一個物件到 GC Roots 沒有任何引用鏈相連的話,則證明此物件是不可用
image-20210518234037556

可作為 GC Roots 的物件包括下面幾種:

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

不可達的物件並非一定會回收

發現不可達時,這些物件暫時處於“緩刑階段”,要真正宣告一個物件死亡,至少要經歷兩次標記過程

  • 第一次標記,篩選的條件是此物件是否有必要執行 finalize 方法

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

關於引用

JDK1.2 之前,Java 中引用的定義很傳統:如果 reference 型別的資料儲存的數值代表的是另一塊記憶體的起始地址,就稱這塊記憶體代表一個引用。

JDK1.2 以後,Java 對引用的概念進行了擴充,將引用分為強引用、軟引用、弱引用、虛引用四種(引用強度逐漸減弱)

下面我們來看看這四種引用

強引用(StrongReference)

強引用非常霸道,只要是強引用,一定不會被GC回收,即便是記憶體不夠,即便要OOM也不會回收它。

軟引用(SoftReference)

如果記憶體空間足夠,垃圾回收器就不會回收它,如果記憶體空間不足了,就會回收這些物件的記憶體。

只要垃圾回收器沒有回收它,該物件就可以被程式使用。軟引用可用來實現記憶體敏感的快取記憶體。

弱引用(WeakReference)

只要發現了只具有弱引用的物件,就會直接回收

不過,由於垃圾回收器是一個優先順序很低的執行緒, 因此不一定會很快發現那些只具有弱引用的物件。

弱引用與軟引用的區別在於:只具有弱引用的物件擁有更短暫的生命週期。

虛引用(PhantomReference)

"虛引用"顧名思義,就是形同虛設,與其他幾種引用都不同,虛引用並不會決定物件的生命週期。

如果一個物件僅持有虛引用,那麼它就和沒有任何引用一樣,在任何時候都可能被垃圾回收。

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

虛引用與軟引用和弱引用的一個區別在於: 虛引用必須和引用佇列(ReferenceQueue)聯合使用。

當垃圾回收器準備回收一個物件時,如果發現它還有虛引用,就會在回收物件的記憶體之前,把這個虛引用加入到與之關聯的引用佇列中。程式可以通過判斷引用佇列中是否已經加入了虛引用,來了解被引用的物件是否將要被垃圾回收。

程式如果發現某個虛引用已經被加入到引用佇列,那麼就可以在所引用的物件的記憶體被回收之前採取必要的行動。

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

判斷廢棄常量

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

判斷無用類

類需要同時滿足下面 3 個條件才能算是 “無用的類”

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

虛擬機器可以對滿足上述 3 個條件的無用類進行回收,這裡說的僅僅是“可以”,而並不是和物件一樣不使用了就會必然被回收。

垃圾收集演算法

標記-清除演算法

該演算法分為“標記”和“清除”階段:

  • 首先標記出所有不需要回收的物件
  • 標記完成後統一回收掉所有沒有被標記的物件。

它是最基礎的收集演算法,後續的演算法都是對其不足進行改進得到。

這種垃圾收集演算法會帶來兩個明顯的問題:

  • 效率問題,需要遍歷兩次進行清除
  • 空間問題,標記清除後會產生大量不連續的碎片
image-20210519000214579

標記-複製演算法

標記-複製演算法標記-清除演算法的改進版本。

  • 可以將記憶體分為大小相同的兩塊,每次使用其中的一塊

  • 當這一塊的記憶體使用完後,就將還存活的物件複製到另一塊去,然後再把使用的空間一次清理掉

  • 這樣就使每次的記憶體回收都是對記憶體區間的一半進行回收。

image-20210519000657559

標記-整理演算法

根據老年代的特點提出的一種標記演算法,標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件回收,而是讓所有存活的物件向一端移動,然後直接清理掉端邊界以外的記憶體。

image-20210519001001385

分代收集演算法

分代收集就是將新生代和老年代分開,根據各自的特點選擇合適的收集演算法。

比如在新生代中,收集很頻繁,並且數量很多,所以可以選擇標記-複製演算法,只需要付出少量物件的複製成本就可以完成每次垃圾收集。

而老年代的物件存活機率是比較高的,而且沒有額外的空間對它進行分配擔保,標記-整理演算法就很合適

垃圾收集器

垃圾收集演算法是垃圾收集的實現原理,而垃圾收集器就是記憶體回收的具體實現。

實際生產中,我們需要根據自己的需求來選擇合適的垃圾收集器,需要記住一點,沒有最好的,只有最合適的

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。

CMS收集器非常符合在注重使用者體驗的應用上使用

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

CMS 收集器使用 “標記-清除”演算法

整個過程分為四個步驟:

  • 初始標記: 暫停所有的其他執行緒,並記錄下直接與 root 相連的物件,速度很快 ;
  • 併發標記:
    • 同時開啟 GC 和使用者執行緒,用一個閉包結構去記錄可達物件
    • 在階段結束,閉包結構不能保證包含當前所有的可達物件。
    • 因為使用者執行緒可能會不斷的更新引用域,所以 GC 執行緒無法保證可達性分析的實時性
    • 所以此演算法裡會跟蹤記錄這些發生引用更新的地方。
  • 重新標記:
    • 修正併發標記期間因為使用者程式繼續執行而導致標記產生變動的那一部分物件的標記記錄,
    • 停頓時間一般會比初始標記階段的時間稍長,遠遠比並發標記階段時間短
  • 併發清除: 開啟使用者執行緒,同時 GC 執行緒開始對未標記的區域做清掃。
image-20210519005222430

優點:併發收集、低停頓

缺點:

  • 對 CPU 資源敏感。
  • 無法處理浮動垃圾。
  • 收集結束時會有大量空間碎片產生。

Serial 收集器

Serial(序列)收集器是最基本、歷史最悠久的垃圾收集器了。

它收集器是一個單執行緒收集器了:

  • 新生代採用標記-複製演算法

  • 老年代採用標記-整理演算法

它最大的特點就是進行GC時,會阻塞其他執行緒

它的優點是簡單高效,在單執行緒收集器中幾乎就是最快的存在,但是由於會阻塞其他執行緒,這讓他的使用起來體驗並不算好。

image-20210519002820371

ParNew 收集器

ParNew 收集器是 Serial 收集器的多執行緒版本,除了使用多執行緒進行垃圾收集外,其餘行為和 Serial 收集器完全一樣

它是許多執行在 Server 模式下的虛擬機器的首要選擇,除了 Serial 收集器外,只有它能與 CMS 收集器配合工作。

image-20210519002908033

Parallel Scavenge 收集器

JDK1.8 預設使用的是 Parallel Scavenge + Parallel Old

JDK1.8 預設收集器

Parallel Scavenge 收集器幾乎和 ParNew 是一樣。

區別在於:

  • Parallel Scavenge 收集器關注點是吞吐量(高效率的利用 CPU)

  • CMS 等垃圾收集器的關注點更多的是使用者執行緒的停頓時間(提高使用者體驗)。

Serial Old 收集器

Serial 收集器的老年代版本,它同樣是一個單執行緒收集器。它主要有兩大用途:

一種用途是在 JDK1.5 以及以前的版本中與 Parallel Scavenge 收集器搭配使用,另一種用途是作為 CMS 收集器的後備方案。

Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本

使用多執行緒和“標記-整理”演算法。在注重吞吐量以及 CPU 資源的場合,都可以優先考慮 Parallel Scavenge 收集器和 Parallel Old 收集器。

G1 收集器

G1 (Garbage-First) 是一款面向伺服器的垃圾收集器,主要針對配備多顆處理器及大容量記憶體的機器。

以極高概率滿足 GC 停頓時間要求的同時,還具備高吞吐量效能特徵。

非常強的一款垃圾收集器,甚至它可能會引領JVM垃圾收集的未來。

它具備一下特點:

  • 並行與併發
    • G1 能充分利用 CPU、多核環境下的硬體優勢,使用多個 CPU(CPU 或者 CPU 核心)來縮短停頓時間。
    • 部分其他收集器原本需要停頓 Java 執行緒執行的 GC 動作,G1 收集器仍然可以通過併發的方式讓 Java 程式繼續執行
  • 分代收集:雖然 G1 可以不需要其他收集器配合就能獨立管理整個 GC 堆,但是還是保留了分代的概念。
  • 空間整合
    • G1 從整體來看是基於標記-整理演算法實現的收集器;
    • 區域性上來看是基於標記-複製演算法實現的。
  • 可預測的停頓:降低停頓時間是 G1 和 CMS 共同的關注點,但 G1 除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為 M 毫秒的時間片段內。

G1 收集器的運作大致分為以下幾個步驟:

  • 初始標記
  • 併發標記
  • 最終標記
  • 篩選回收

G1 收集器在後臺維護了一個優先列表,每次根據允許的收集時間,優先選擇回收價值最大的 Region(這也就是它的名字 Garbage-First 的由來)

這種使用 Region 劃分記憶體空間以及有優先順序的區域回收方式,保證了 G1 收集器在有限時間內可以儘可能高的收集效率(把記憶體化整為零)。

ZGC 收集器

與G1 類似,但又互有不同,這裡不展開了,感興趣可以自行了解。

參考

  • 《深入理解Java虛擬機器》第三版,再次吹爆!

相關文章