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

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

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

  • 哪些內存區域須要進行垃圾收集?
  • 如何判斷對象是否可回收?
  • 新的對象是如何進行內存分配的?
  • 如何進行垃圾收集?

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

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

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

1、須要進行垃圾收集的內存區域

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

JVM 運行時數據區緩存

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

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

還要說明一點,JVM 中的垃圾收集器的最主要的關注對象是 Java 堆,由於這裏進行垃圾收集的「性價比」是最高的,尤爲是在新生代 (後文對分代算法進行介紹) 中的垃圾收集,一次就能夠回收 70% - 99% 的內存。而方法區因爲垃圾收集斷定條件,尤爲是類型卸載的斷定條件至關苛刻,其回收性價比是很是低的,所以有些垃圾收集器就乾脆不支持或不徹底支持方法區的垃圾收集,好比 JDK 11 中的 ZGC 收集器就不支持類型卸載。服務器

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

  • 引用計數法架構

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

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 並無使用引用計數法斷定對象是否可回收,而是採用了可達性分析法。

  • 可達性分析法

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

圖中綠色的都是位於 GC Root Set 中的 GC Roots,全部與其有關聯的對象都是可達的,被標記爲藍色,而全部與其沒有任何關聯的對象都是不可達的,被標記爲灰色。即便是不可達對象,也並不是必定會被回收,若是該對象同時知足如下幾個條件,那麼它仍有「逃生」的可能:

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

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

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

垃圾收集算法介紹

  • 標記-清除算法

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

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

  • 執行效率不穩定。若是 Java 堆中包含大量對象,並且其中大部分是須要被回收的,這時必須進行大量標記和清除的動做,致使標記和清除兩個過程的執行效率都隨對象數量增加而下降;

  • 致使內存空間碎片化。標記、清除以後會產生大量不連續的內存碎片,空間碎片太多可能會致使當之後在程序運行過程當中須要分配較大對象時沒法找到足夠的連續內存而不得不提早觸發另外一次垃圾收集動做,很是影響程序運行效率。

  • 標記-複製算法

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

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

  • 標記-整理算法

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

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

  • 分代收集算法

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

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

在此基礎上,人們提出瞭如下假說:

  • 絕大多數對象都是朝生夕滅的。
  • 熬過越屢次垃圾收集過程的對象就越難以消亡。 根據這兩個假說,能夠把 JVM 的堆內存大體分爲新生代和老年代,新生代對象大多存活時間短,每次回收時只關注如何保留少許存活而不是去標記那些大量將要被回收的對象,就能以較低代價回收到大量的空間,因此這一區域通常採用標記-複製算法進行垃圾收集,頻率比較高。而老年代則是一些難以消亡的對象,能夠採用標記-清除和標記整理算法進行垃圾收集,頻率能夠低一些。

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

  1. 部分收集 (Partial GC):指目標不是完整收集整個Java堆的垃圾收集,其中又分爲:
  • 新生代收集 (Minor GC / Young GC):指目標只是新生代的垃圾收集。
  • 老年代收集 (Major GC / Old GC):指目標只是老年代的垃圾收集,目前只有CMS收集器的併發收集階段是單獨收集老年代的行爲。
  • 混合收集 (Mixed GC):指目標是收集整個新生代以及部分老年代的垃圾收集,目前只有G1收集器會有這種行爲。
  1. 整堆收集 (Full GC):收集整個Java堆和方法區的垃圾收集。

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

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

通過前面的鋪墊,如今終於能夠一窺 JVM 的內存分配和垃圾收集機制的真面目了。

  • JVM 堆內存的劃分

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

分代收集原理

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

大多數狀況下,對象優先在新生代 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 中,S0 和 S1會交替轉化爲 From Survivor 和 To Survivor,Eden 和 From Survivor 中的存活對象會複製到 To Survivor 中,並將年齡加 1。以下圖所示:

  • 對象晉升老年代

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

長期存活對象將進入老年代 對象在 Survivor 區中每熬過一次Minor GC,年齡就增長1歲,當它的年齡增長到必定程度 (默認爲15),就會被晉升到老年代中。對象晉升老年代的年齡閾值,能夠經過參數 -XX:MaxTenuringThreshold 設置,這個參數的最大值是15,由於對象年齡信息儲存在對象頭中,佔4個比特 (bit)的內存,所能表示最大數字就是15。

  • 大對象能夠直接進入老年代

對於大對象,尤爲是很長的字符串,或者元素數量不少的數組,若是分配在 Eden 中,會很容易過早佔滿 Eden 空間致使 Minor GC,並且大對象在 Eden 和兩個 Survivor 之間的來回複製也還會有很大的內存複製開銷。因此咱們能夠經過設置 -XX:PretenureSizeThreshold 的虛擬機參數讓大對象直接進入老年代。

  • 動態對象年齡判斷

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

  • 空間分配擔保 (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 領域的最新科技成果,敬請期待。

參考:

相關文章
相關標籤/搜索