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版).周志明