Java 虛擬機系列二:垃圾收集機制詳解,動圖幫你理解

Java 虛擬機系列一:一文搞懂 JVM 架構和運行時數據區
Java 虛擬機系列二:垃圾收集機制詳解,動圖幫你理解

前言

上篇文章已經給大家介紹了 JVM 的架構和運行時數據區 (內存區域),本篇文章將給大家介紹 JVM 的重點內容——垃圾收集。衆所周知,相比 C / C++ 等語言,Java 可以省去手動管理內存的繁瑣操作,很大程度上解放了 Java 程序員的生產力,而這正是得益於 JVM 的垃圾收集機制和內存分配策略。我們平時寫程序時並感知不到這一點,但是如果是在生產環境中,JVM 的不同配置對於服務器性能的影響是非常大的,所以掌握 JVM 調優是高級 Java 工程師的必備技能。正所謂“基礎不牢,地動山搖”,在這之前我們先來了解一下底層的 JVM 垃圾收集機制。

既然要介紹垃圾收集機制,就要搞清楚以下幾個問題:

  1. 哪些內存區域需要進行垃圾收集?
  2. 如何判斷對象是否可回收?
  3. 新的對象是如何進行內存分配的?
  4. 如何進行垃圾收集?

本文將按以下行文結構展開,對上述問題一一解答。

  1. 需要進行垃圾收集的內存區域;
  2. 判斷對象是否可回收的方法;
  3. 主流的垃圾收集算法介紹;
  4. JVM 的內存分配與垃圾收集機制。

下面開始正文,還是圖文並茂的老配方,走起。

一、需要進行垃圾收集的內存區域

先來回顧一下 JVM 的運行時數據區:

JVM 運行時數據區

JVM 運行時數據區

其中程序計數器、Java 虛擬機棧和本地方法棧都是線程私有的,與其對應的線程是共生關係,隨線程而生,隨線程而滅,棧中的棧幀也隨着方法的進入和退出井然有序地進行入棧和出棧操作。所以這幾個區域的內存分配和回收都是有很大確定性的,在方法結束或線程結束時,內存也會隨之釋放,因此也就不需要考慮這幾個區域的內存回收問題了。

而堆和方法區就不一樣了,Java 的對象幾乎都是在堆上創建出來的,方法區則存儲了被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯後的代碼緩存等數據,方法區中的運行時常量池則存放了各種字面量與符號引用,上述的這些數據大部分都是在運行時才能確定的,所以需要進行動態的內存管理。

還要說明一點,JVM 中的垃圾收集器的最主要的關注對象是 Java 堆,因爲這裏進行垃圾收集的“性價比”是最高的,尤其是在新生代 (後文對分代算法進行介紹) 中的垃圾收集,一次就可以回收 70% - 99% 的內存。而方法區由於垃圾收集判定條件,尤其是類型卸載的判定條件相當苛刻,其回收性價比是非常低的,因此有些垃圾收集器就乾脆不支持或不完全支持方法區的垃圾收集,比如 JDK 11 中的 ZGC 收集器就不支持類型卸載。

二、判斷對象是否可回收的方法

2.1 引用計數法

引用計數法的實現很簡單,在對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器爲零的對象就是不可能再被使用的。大部分情況下這個方法是可以發揮作用的,但是在存在循環引用的情況下,引用計數法就無能爲力了。比如下面這種情況:

public class Student {
  	// friend 字段
    public Student friend = null;
  
    public static void test() {
        Student a = new Student();
        Student b = new Student();
        a.friend = b;
        b.friend = a;
        a = null;
        b = null;
        System.gc();
    }
}

上述代碼創建了 a 和 b 兩個 Student 實例,並把它們各自的 friend 字段賦值爲對方,除此之外,這兩個對象再無任何引用,然後將它們都賦值爲 null,在這種情況下,這兩個對象已經不可能再被訪問,但是它們因爲互相引用着對方,導致它們的引用計數都不爲零,引用計數算法也就無法回收它們。如下圖所示:

循環引用

循環引用

但是在 Java 程序中,a 和 b 是可以被回收的,因爲 JVM 並沒有使用引用計數法判定對象是否可回收,而是採用了可達性分析法。

2.2 可達性分析法

這個算法的基本思路就是通過一系列稱爲“GC Roots”的根對象作爲起始節點集 (GC Root Set),從這些節點開始,根據引用關係向下搜索,搜索過程所走過的路徑稱爲“引用鏈” (Reference Chain),如果某個對象到GC Roots間沒有任何引用鏈相連,則說明此對象不再被使用,也就可以被回收了。要進行可達性分析就需要先枚舉根節點 (GC Roots),在枚舉根節點過程中,爲防止對象的引用關係發生變化,需要暫停所有用戶線程 (垃圾收集之外的線程),這種暫停全部用戶線程的行爲被稱爲 (Stop The World)。可達性分析法如下圖所示:

可達性分析法

可達性分析法

圖中綠色的都是位於 GC Root Set 中的 GC Roots,所有與其有關聯的對象都是可達的,被標記爲藍色,而所有與其沒有任何關聯的對象都是不可達的,被標記爲灰色。即使是不可達對象,也並非一定會被回收,如果該對象同時滿足以下幾個條件,那麼它仍有“逃生”的可能:

  1. 該對象有重寫的 finalize()方法 (Object 類中的方法);
  2. finalize()方法中將其自身鏈接到了引用鏈上;
  3. JVM 此前沒有調用過該對象的finalize()方法 (因爲 JVM 在收集可回收對象時會調用且僅調用一次該對象的finalize()方法)。

不過由於finalize()方法的運行代價高昂,不確定性大,且無法保證各個對象的調用順序,所以並不推薦使用。那麼 GC Roots 又是何方神聖呢?在 Java 語言中,固定可作爲GC Roots的對象包括以下幾種:

  1. 在虛擬機棧 (棧幀中的本地變量表) 中引用的對象,比如各個線程被調用的方法堆棧中使用到的參數、局部變量、臨時變量等。
  2. 在方法區中類靜態屬性引用的對象,比如Java類的引用類型靜態變量。
  3. 在方法區中常量引用的對象,比如字符串常量池(String Table)裏的引用。
  4. 在本地方法棧中JNI (即通常所說的Native方法) 引用的對象。
  5. Java虛擬機內部的引用,如基本數據類型對應的Class對象,一些常駐的異常對象 (比如
    NullPointExcepiton、OutOfMemoryError) 等,還有系統類加載器。
  6. 所有被同步鎖 (synchronized關鍵字) 持有的對象。
  7. 反映Java虛擬機內部情況的 JM XBean、JVM TI 中註冊的回調、本地代碼緩存等。

三、垃圾收集算法介紹

3.1 標記-清除算法

標記-清除算法的思想很簡單,顧名思義,該算法的過程分爲標記和清除兩個階段:首先標記出所有需要回收的對象,其中標記過程就是使用可達性分析法判斷對象是否屬於垃圾的過程。在標記完成後,統一回收掉所有被標記的對象,也可以反過來,標記存活的對象,統一回收所有未被標記的對象。示意圖如下:

標記清除算法

標記清除算法

這個算法雖然很簡單,但是有兩個明顯的缺點:

  1. 執行效率不穩定。如果 Java 堆中包含大量對象,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個過程的執行效率都隨對象數量增長而降低;
  2. 導致內存空間碎片化。標記、清除之後會產生大量不連續的內存碎片,空間碎片太多可能會導致當以後在程序運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作,非常影響程序運行效率。

3.2 標記-複製算法

標記-複製算法常簡稱複製算法,這一算法正好解決了標記-清除算法在面對大量可回收對象時執行效率低下的問題。其實現方法也很易懂:在可用內存中劃分出兩塊大小相同的區域,每次只使用其中一塊,另一塊保持空閒狀態,第一塊用完的時候,就把存活的對象全部複製到第二塊區域,然後把第一塊全部清空。如下圖所示:

標記-複製算法

標記-複製算法

這個算法很適合用於對象存活率低的情況,因爲它只關注存活對象而無需理會可回收對象,所以 JVM 中新生代的垃圾收集正是採用的這一算法。但是其缺點也很明顯,每次都要浪費一半的內存,未免太過奢侈,不過新生代有更精細的內存劃分,比較好地解決了這個問題,見下文。

3.3 標記-整理算法

這個算法完美解決了標記-清除算法的空間碎片化問題,其標記過程與“標記-清除”算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向內存空間一端移動,然後直接清理掉邊界以外的內存。

標記整理算法

標記整理算法

這個算法雖然可以很好地解決空間碎片化問題,但是每次垃圾回收都要移動存活的對象,還要對引用這些對象的地方進行更新,對象移動的操作也需要全程暫停用戶線程 (Stop The World)。

3.4 分代收集算法

與其說是算法,不如說是理論。如今大多數虛擬機的實現版本都遵循了“分代收集”的理論進行設計,這個理論可以看作是經驗之談,因爲開發人員在開發過程中發現了 JVM 中存活對象的數量和它們的年齡之間有着某種規律,如下圖:

JVM 中存活對象數量與年齡之間的關係

JVM 中存活對象數量與年齡之間的關係

在此基礎上,人們得出了以下假說:

  1. 絕大多數對象都是朝生夕滅的。
  2. 熬過越多次垃圾收集過程的對象就越難以消亡。

根據這兩個假說,可以把 JVM 的堆內存大致分爲新生代和老年代,新生代對象大多存活時間短,每次回收時只關注如何保留少量存活而不是去標記那些大量將要被回收的對象,就能以較低代價回收到大量的空間,所以這一區域一般採用標記-複製算法進行垃圾收集,頻率比較高。而老年代則是一些難以消亡的對象,可以採用標記-清除和標記整理算法進行垃圾收集,頻率可以低一些。

按照 Hotspot 虛擬機的實現,針對新生代和老年代的垃圾收集又分爲不同的類型,也有不同的名詞,如下:

  1. 部分收集 (Partial GC):指目標不是完整收集整個Java堆的垃圾收集,其中又分爲:

    • 新生代收集 (Minor GC / Young GC):指目標只是新生代的垃圾收集。

    • 老年代收集 (Major GC / Old GC):指目標只是老年代的垃圾收集,目前只有CMS收集器的併發收集階段是單獨收集老年代的行爲。

    • 混合收集 (Mixed GC):指目標是收集整個新生代以及部分老年代的垃圾收集,目前只有G1收集器會有這種行爲。

  2. 整堆收集 (Full GC):收集整個Java堆和方法區的垃圾收集。

人們經常會混淆 Major GC 和 Full GC,不過這也有情可原,因爲這兩種 GC 行爲都包含了老年代的垃圾收集,而單獨的老年代收集 (Major GC) 又比較少見,大多數情況下只要包含老年代收集,就會是整堆收集 (Full GC),不過還是分得清楚一點比較好哈。

四、JVM 的內存分配和垃圾收集機制

經過前面的鋪墊,現在終於可以一窺 JVM 的內存分配和垃圾收集機制的真面目了。

4.1 JVM 堆內存的劃分

JVM 堆內存劃分

JVM 堆內存劃分

Java 堆是 JVM 所管理的內存中最大的一塊,也是垃圾收集器的管理區域。大多數垃圾收集器都會將堆內存劃分爲上圖所示的幾個區域,整體分爲新生代和老年代,比例爲 1 : 2,新生代又進一步分爲 Eden、From Survivor 和 To Survivor,默認比例爲 8 : 1 : 1,請注意,可通過 SurvivorRatio 參數進行設置。請注意,從 JDK 8 開始,JVM 中已經不再有永久代的概念了。Java 堆上的無論哪個區域,存儲的都只能是對象的實例,將Java 堆細分的目的只是爲了更好地回收內存,或者更快地分配內存。

4.2 分代收集原理

4.2.1 新生代中對象的分配與回收

大多數情況下,對象優先在新生代 Eden 區中分配,當 Eden 區沒有足夠空間進行分配時,虛擬機將發起一次 Minor GC。Eden、From Survivor 和 To Survivor 的比例爲 8 : 1 : 1,之所以按這個比例是因爲絕大多數對象都是朝生夕滅的,垃圾收集時 Eden 存活的對象數量不會太多,Survivor 空間小一點也足以容納,每次新生代中可用內存空間爲整個新生代容量的90% (Eden 的 80% 加上 To Survivor 的 10%),只有From Survivor 空間,即 10% 的新生代是會被“浪費”的。不會像原始的標記-複製算法那樣浪費一半的內存空間。From Survivor 和 To Survivor 的空間並不是固定的,而是在 S0 和 S1 之間動態轉換的,第一次 Minor GC 時會選擇 S1 作爲 To Survivor,並將 Eden 中存活的對象複製到其中,並將對象的年齡加1,注意新生代使用的垃圾收集算法是標記-複製算法的改良版。下面是示意圖,請注意其中第一步的變色是爲了醒目,虛擬機只做了標記存活對象的操作。

第一次 Minor GC 示意圖

第一次 Minor GC 示意圖

在後續的 Minor GC 中,S0 和 S1會交替轉化爲 From Survivor 和 To Survivor,Eden 和 From Survivor 中的存活對象會複製到 To Survivor 中,並將年齡加 1。如下圖所示:

後續 Minor GC 示意圖

後續 Minor GC 示意圖

4.2.2 對象晉升老年代

在以下這些情況下,對象會晉升到老年代。

  1. 長期存活對象將進入老年代

    對象在 Survivor 區中每熬過一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度 (默認爲15),就會被晉升到老年代中。對象晉升老年代的年齡閾值,可以通過參數 -XX:MaxTenuringThreshold 設置。

長期存活對象晉升老年代示意圖

長期存活對象晉升老年代示意圖

  1. 大對象可以直接進入老年代

    對於大對象,尤其是很長的字符串,或者元素數量很多的數組,如果分配在 Eden 中,會很容易過早佔滿 Eden 空間導致 Minor GC,而且大對象在 Eden 和兩個 Survivor 之間的來回複製也還會有很大的內存複製開銷。所以我們可以通過設置 -XX:PretenureSizeThreshold 的虛擬機參數讓大對象直接進入老年代。

  2. 動態對象年齡判斷

    爲了能更好地適應不同程序的內存狀況,HotSpot 虛擬機並不是永遠要求對象的年齡必須達到 -XX:MaxTenuringThreshold 才能晉升老年代,如果在 Survivor 空間中相同年齡所有對象大小的總和大於 Survivor 空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代,無須等到 -XX:MaxTenuringThreshold 中要求的年齡。

  3. 空間分配擔保 (Handle Promotion)

    當 Survivor 空間不足以容納一次 Minor GC 之後存活的對象時,就需要依賴其他內存區域 (實際上大多數情況下就是老年代) 進行分配擔保。在發生 Minor GC 之前,虛擬機必須先檢查老年代最大可用的連續空間是否大於新生代所有對象總空間,如果這個條件成立,那這一次 Minor GC 可以確保是安全的。如果不成立,則虛擬機會先查看 - XX:HandlePromotionFailure 參數的設置值是否允許擔保失敗 (Handle Promotion Failure);如果允許,那會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於,將嘗試進行一次 Minor GC,儘管這次 Minor GC 是有風險的;如果小於,或者-XX: HandlePromotionFailure設置不允許冒險,那這時就要改爲進行一次 Full GC。

總結

本文介紹了 JVM 的垃圾收集機制,並用大量圖片和動圖來幫助大家理解,如有錯誤,歡迎指正。後續文章會繼續介紹 JVM 中的各種垃圾收集器,包括最前沿的 ZGC 和 Shenandoah 收集器,是 JVM 領域的最新科技成果,敬請期待。

最後是參考文章:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章