JVM 垃圾回收?全面詳細安排!

語言: CN / TW / HK

寫在前面:

小夥伴兒們,大家好!今天來學習Java虛擬機器相關內容,作為面試必問的知識點,來深入瞭解一波!

思維導圖:

image-20201207153125210

1,判斷物件是否死亡

我們在進行垃圾回收(Garbage Collection,簡稱GC)之前肯定要先判斷哪些是垃圾。

在堆中幾乎放著所有的物件例項,對堆垃圾回收前的第一步就是要判斷那些物件已經死亡(即不能再被任何途徑使用的物件)。

image-20201207101110001

1.1,引用計數演算法

給物件新增一個引用計數器,每當有一個地方引用該物件時,計數器+1,當引用失效時,計數器-1,任何時候當計數器為0的時候,該物件不再被引用。

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

所謂物件之間互相迴圈引用,如下面程式碼所示:除了物件 objA 和 objB 相互引用著對方之外,這兩個物件之間再無任何引用。但是它們因為互相引用對方,導致它們的引用計數器都不為 0,於是引用計數演算法無法通知 GC 回收器回收他們。

public class ReferenceCountingGc {
    public Object instance = null;
    public static final int _1MB = 1024*1024;
    public static void main(String[] args) {
        ReferenceCountingGc objA = new ReferenceCountingGc();
        ReferenceCountingGc objB = new ReferenceCountingGc();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
    }
}
複製程式碼

1.2,可達性分析演算法

這個演算法的基本思想就是通過一系列的稱為 “GC Roots” 的物件作為起點,從這些節點開始向下搜尋,節點所走過的路徑稱為引用鏈,當一個物件到 GC Roots 沒有任何引用鏈相連的話,則證明此物件是不可用的。

image-20201206094626579

在Java語言中,可作為GC Roots的物件包括下面幾種:

  • 虛擬機器棧(棧幀中的本地變量表)中引用的物件
  • 方法區中類靜態屬性引用的物件
  • 方法區中常量引用的物件
  • 本地方法棧(Native 方法)中引用的物件

2,再談引用

無論是通過引用計數法判斷物件引用數量,還是通過可達性分析法判斷物件的引用鏈是否可達,判定物件的存活都與“引用”有關。

JDK1.2 之前,Java 中引用的定義很傳統:如果 reference 型別的資料儲存的數值代表的是另一塊記憶體的起始地址,就稱這塊記憶體代表一個引用。 JDK1.2 以後,Java 對引用的概念進行了擴充,將引用分為強引用、軟引用、弱引用、虛引用四種(引用強度逐漸減弱)。

2.1,強引用

以前我們使用的大部分引用實際上都是強引用,這是使用最普遍的引用。類似於“Object obj=new Object()”這類的引用,如果一個物件具有強引用,那就類似於必不可少的生活用品,垃圾回收器絕不會回收它。當記憶體空間不足,Java虛擬機器寧願丟擲OutOfMemoryError錯誤,使程式異常終止,也不會靠隨意回收具有強引用的物件來解決記憶體不足問題。

2.2,軟引用

如果一個物件只具有軟引用,那就類似於可有可無的生活用品。如果記憶體空間足夠,垃圾回收器就不會回收它,如果記憶體空間不足了,就會回收這些物件的記憶體。只要垃圾回收器沒有回收它,該物件就可以被程式使用。軟引用可用來實現記憶體敏感的快取記憶體。

軟引用可以和一個引用佇列(ReferenceQueue)聯合使用,如果軟引用所引用的物件被垃圾回收,JAVA 虛擬機器就會把這個軟引用加入到與之關聯的引用佇列中。

2.3,弱引用

如果一個物件只具有弱引用,那就類似於可有可無的生活用品。弱引用與軟引用的區別在於:只具有弱引用的物件擁有更短暫的生命週期。在垃圾回收器執行緒掃描它所管轄的記憶體區域的過程中,一旦發現了只具有弱引用的物件,不管當前記憶體空間足夠與否,都會回收它的記憶體。不過,由於垃圾回收器是一個優先順序很低的執行緒, 因此不一定會很快發現那些只具有弱引用的物件。

弱引用可以和一個引用佇列(ReferenceQueue)聯合使用,如果弱引用所引用的物件被垃圾回收,Java 虛擬機器就會把這個弱引用加入到與之關聯的引用佇列中。

2.4,虛引用

"虛引用"顧名思義,就是形同虛設,與其他幾種引用都不同,虛引用並不會決定物件的生命週期。如果一個物件僅持有虛引用,那麼它就和沒有任何引用一樣,在任何時候都可能被垃圾回收。

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

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

3,廢棄常量以及無用類

3.1,如何判斷一個常量是廢棄常量?

執行時常量池主要回收的是廢棄的常量。那麼,我們如何判斷一個常量是廢棄常量呢?

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

3.2,如何判斷一個類是無用的類?

判定一個常量是否是“廢棄常量”比較簡單,而要判定一個類是否是“無用的類”的條件則相對苛刻許多。方法區主要回收無用的類,類需要同時滿足下面3個條件才能算是 “無用的類” :

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

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

4,垃圾收集演算法

4.1,標記--清除演算法

該演算法分為“標記”和“清除”階段:首先標記出所有不需要回收的物件,在標記完成後統一回收掉所有沒有被標記的物件。它是最基礎的收集演算法,後續的演算法都是對其不足進行改進得到。這種垃圾收集演算法會帶來兩個明顯的問題:

  • 效率問題:標記和清除兩個過程的效率都不高;
  • 空間問題:標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

標記-清除演算法

4.2,複製演算法

為了解決效率問題,“複製”收集演算法出現了。它將可用記憶體分為大小相同的兩塊,每次使用其中的一塊。當這一塊的記憶體使用完後,就將還存活的物件複製到另一塊去,然後再把使用的空間一次清理掉。這樣就使每次的記憶體回收都是對記憶體區間的一半進行回收。實現簡單,執行高效。

複製演算法

4.3,標記--整理演算法

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

標記-整理演算法

4.4,分代收集演算法

當前虛擬機器的垃圾收集都採用分代收集演算法,這種演算法沒有什麼新的思想,只是根據物件存活週期的不同將記憶體分為幾塊。一般將 java 堆分為新生代和老年代,這樣我們就可以根據各個年代的特點選擇合適的垃圾收集演算法。

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

5,垃圾收集器

如果說收集演算法是記憶體回收的方法論,那麼垃圾收集器就是記憶體回收的具體實現。

雖然我們對各個收集器進行比較,但並非要挑選出一個最好的收集器。因為直到現在為止還沒有最好的垃圾收集器出現,更加沒有萬能的垃圾收集器,我們能做的就是根據具體應用場景選擇適合自己的垃圾收集器。試想一下:如果有一種四海之內、任何場景下都適用的完美收集器存在,那麼我們的 HotSpot 虛擬機器就不會實現那麼多不同的垃圾收集器了。

常見的垃圾收集器

5.1,Serial收集器

Serial收集器是最基本、歷史最悠久的垃圾收集器了。從名字上看是序列的意思,這個收集器是一個單執行緒的新生代收集器。它的 “單執行緒” 的意義不僅僅意味著它只會使用一條垃圾收集執行緒去完成垃圾收集工作,更重要的是它在進行垃圾收集工作的時候必須暫停其他所有的工作執行緒( "Stop The World" ),直到它收集結束。

Serial收集器

Serial 採取 “複製演算法” 實現,如果是在單 CPU 環境下,Serial 收集器沒有執行緒互動的開銷,理論上是可以獲得最高的單執行緒執行效率,STW 的時間也可以控制在幾十到幾百毫秒內,這個時間是完全可以接受的。

與其他單執行緒收集器相比它的優點就是:它簡單而高效(與其他收集器的單執行緒相比)。 簡單而高效 Serial 收集器由於沒有執行緒互動的開銷,自然可以獲得很高的單執行緒收集效率。 Serial 收集器對於執行在 Client 模式下的虛擬機器來說是個不錯的選擇。

5.2,ParNew收集器

ParNew 收集器其實就是 Serial 收集器的多執行緒版本,除了使用多執行緒進行垃圾收集外,其餘行為(控制引數、收集演算法、回收策略等等)和 Serial 收集器完全一樣。ParNew 收集器雖然有多執行緒優勢,但在單 CPU 和多 CPU 環境下,效果並不一定會比 Serial 好,至少在單 CPU 環境下是肯定不如的 Serial 的。由於執行緒互動開銷的時間,效果並不如人意,多執行緒的好處在於更高效率地利用 CPU ,提高 CPU 的吞吐量,讓 CPU 空閒的時間減少。

新生代採用複製演算法,老年代採用標記-整理演算法。

ParNew收集器

它是許多執行在 Server 模式下的虛擬機器的首要選擇,除了 Serial 收集器外,只有它能與 CMS 收集器(真正意義上的併發收集器,後面會介紹到)配合工作。

  • 並行(Parallel) :指多條垃圾收集執行緒並行工作,但此時使用者執行緒仍然處於等待狀態。
  • 併發(Concurrent):指使用者執行緒與垃圾收集執行緒同時執行(但不一定是並行,可能會交替執行),使用者程式在繼續執行,而垃圾收集器執行在另一個 CPU 上。

5.3,Parallel Scavenge收集器

Parallel Scavenge 收集器也是使用複製演算法的多執行緒收集器,它看上去幾乎和ParNew都一樣。 那麼它有什麼特別之處呢?

Parallel Scavenge 收集器關注點是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的關注點更多的是使用者執行緒的停頓時間(提高使用者體驗)。所謂吞吐量就是 CPU 中用於執行使用者程式碼的時間與 CPU 總消耗時間的比值。 Parallel Scavenge 收集器提供了很多引數供使用者找到最合適的停頓時間或最大吞吐量,如果對於收集器運作不太瞭解,手工優化存在困難的時候,使用Parallel Scavenge收集器配合自適應調節策略,把記憶體管理優化交給虛擬機器去完成也是一個不錯的選擇。

新生代採用複製演算法,老年代採用標記-整理演算法。

Parallel Scavenge收集器

這是 JDK1.8 預設收集器

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

-XX:InitialHeapSize=197918400 -XX:MaxHeapSize=3166694400 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
java version "1.8.0_144"
Java(TM) SE Runtime Environment (build 1.8.0_144-b01)
Java HotSpot(TM) 64-Bit Server VM (build 25.144-b01, mixed mode)
複製程式碼

檢視結果

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

5.4,Serial Old收集器

Serial 收集器的老年代版本,它同樣是一個單執行緒收集器。它主要有兩大用途:一種用途是在 JDK1.5 以及以前的版本中與 Parallel Scavenge 收集器搭配使用,另一種用途是作為 CMS 收集器的後備方案。

5.5,Parallel Old 收集器

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

5.6,CMS 收集器

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

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

從名字中的Mark Sweep這兩個詞可以看出,CMS 收集器是一種 “標記-清除”演算法實現的,它的運作過程相比於前面幾種垃圾收集器來說更加複雜一些。整個過程分為四個步驟:

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

CMS收集器

兩次STW,從它的名字就可以看出它是一款優秀的垃圾收集器,主要優點:併發收集、低停頓。但是它有下面三個明顯的缺點:

  • 對 CPU 資源敏感;
  • 無法處理浮動垃圾;
  • 它使用的回收演算法-“標記-清除”演算法會導致收集結束時會有大量空間碎片產生。

什麼是浮動垃圾? CMS在併發清理階段,使用者執行緒還在執行, 伴隨著程式的執行自然也會產生新的垃圾,這一部分垃圾產生在標記過程之後,CMS無法再當次過程中處理,所以只有等到下次gc時候在清理掉,這一部分垃圾就稱作“浮動垃圾”。

5.7,G1 收集器

G1 (Garbage-First) 是一款面向伺服器的垃圾收集器,主要針對配備多顆處理器及大容量記憶體的機器. 以極高概率滿足 GC 停頓時間要求的同時,還具備高吞吐量效能特徵。

被視為 JDK1.7 中 HotSpot 虛擬機器的一個重要進化特徵。它具備一下特點:

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

G1收集器執行示意圖

G1 收集器在後臺維護了一個優先列表,每次根據允許的收集時間,優先選擇回收價值最大的 Region(這也就是它的名字 Garbage-First 的由來)。這種使用 Region 劃分記憶體空間以及有優先順序的區域回收方式,保證了 G1 收集器在有限時間內可以儘可能高的收集效率(把記憶體化整為零)。


微信搜尋公眾號《程式設計師的時光》 好了,今天就先分享到這裡了,下期繼續給大家帶來JVM面試內容! 更多幹貨、優質文章,歡迎關注我的原創技術公眾號~

參考文獻:

深入理解Java虛擬機器(第2版).周志明