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

Java 虛擬機系列一:一文搞懂 JVM 架構和運行時數(shù)據(jù)區(qū)

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

前言

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

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

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

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

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

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

一、需要進行垃圾收集的內(nèi)存區(qū)域

先來回顧一下 JVM 的運行時數(shù)據(jù)區(qū):

Java 虛擬機運行時數(shù)據(jù)區(qū)

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

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

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

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

2.1 引用計數(shù)法

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

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();
    }
}

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

循環(huán)引用

但是在 Java 程序中,a 和 b 是可以被回收的,因為 JVM 并沒有使用引用計數(shù)法判定對象是否可回收,而是采用了可達性分析法。

2.2 可達性分析法

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

可達性分析法

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

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

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

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

三、垃圾收集算法介紹

3.1 標記-清除算法

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

標記-清除算法

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

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

3.2 標記-復制算法

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

標記-復制算法

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

3.3 標記-整理算法

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

標記-整理算法

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

3.4 分代收集算法

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

存活對象數(shù)量與年齡的關系

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

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

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

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

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

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

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

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

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

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

四、JVM 的內(nèi)存分配和垃圾收集機制

經(jīng)過前面的鋪墊,現(xiàn)在終于可以一窺 JVM 的內(nèi)存分配和垃圾收集機制的真面目了。

4.1 JVM 堆內(nèi)存的劃分

JVM堆內(nèi)存劃分

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

4.2 分代收集原理

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

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

第一次Minor GC示意圖

在后續(xù)的 Minor GC 中,S0 和 S1會交替轉(zhuǎn)化為 From Survivor 和 To Survivor,Eden 和 From Survivor 中的存活對象會復制到 To Survivor 中,并將年齡加 1。如下圖所示:

循環(huán)進行Minor GC示意圖

4.2.2 對象晉升老年代

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

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

    對象在 Survivor 區(qū)中每熬過一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度 (默認為15),就會被晉升到老年代中。對象晉升老年代的年齡閾值,可以通過參數(shù) -XX:MaxTenuringThreshold 設置,這個參數(shù)的最大值是15,因為對象年齡信息儲存在對象頭中,占4個比特 (bit)的內(nèi)存,所能表示最大數(shù)字就是15。

晉升老年代示意圖
  1. 大對象可以直接進入老年代

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

  2. 動態(tài)對象年齡判斷

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

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

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

總結(jié)

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

最后是參考文章:

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容