JVM學習筆記(二):JVM GC機制與垃圾收集器

引子

在上一篇文章《JVM學習筆記(一):Java內存區域》中,我總結了一下幾大Java內存區域。接下來,我總結一下JVM的GC機制,以及垃圾收集算法 和 垃圾收集器。

內存回收區域

談起JVM的GC機制,我們首先需要關注的就是:回收哪兒的內存?如何判斷哪些內存需要被回收?怎麼回收?下面就一一解答這些問題。

在上一篇文章中提到的 程序計數器、虛擬機棧、本地方法棧 三個“線程私有”的區域,隨線程生、隨線程滅。

棧中的棧幀隨着方法的進入和退出有條不紊地執行着入棧和出棧操作。每一個棧幀中分配多少內存基本上是在內結構確定下來時就是已知的,因此這幾個區域的內存分配和回收都具備確定性,在這幾個區域內就不需要過多考慮回收的問題,方法結束或線程結束時,內存就自然跟隨着回收了。

而Java堆和方法區則不一樣,我們只有在運行時才能知道會創建哪些對象,這部分內存的分配和回收都是動態的。垃圾回收器主要關注的就是這兩個部分的內存。

對象存活判斷

在Java堆中存放着幾乎所有的Java對象,垃圾收集器在對堆內存進行回收時,首先就要判斷這些對象之中哪些還存活,哪些可以被回收。判斷對象是否存活,常見的有兩種算法:引用計數算法 和 可達性分析算法。

引用計數算法

引用計數算法(Reference Counting)就是:給對象添加一個引用計數器,每當有一個地方引用它時,計數器值就加 1;當引用失效時,計數器值就減 1;在任一時刻,計數器爲 0 的對象就是不可能再被使用的。

引用計數算法的優點是 實現簡單,判定效率也很高;缺點是 很難解決對象之間相互循環引用的問題。這也就是 主流的Java虛擬機裏面都沒有選用引用計數算法來管理內存的原因。

可達性分析算法

可達性分析算法的思想就是:通過一系列稱爲“GC Roots”的對象作爲起始點,從這些節點向下搜索,搜索所走過的路徑稱爲引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連,則說明此對象是不可用的。

如上圖,雖然Object 5、Object 6、Object 7雖然互相有關聯,但是他們到GC Roots是不可達的,所以它們將會被判定爲是可回收的對象。

補充說明

在Java語言中,可作爲GC Roots的對象包括下面幾種:

  • 虛擬機棧(棧幀中的本地變量表)中引用的對象。
  • 方法區中類靜態屬性引用的對象。
  • 方法區中常量引用的對象。
  • 本地方法棧中JNI(即Native方法)引用的對象。

垃圾收集算法

這裏只介紹幾種算法的思想,具體的算法實現一般也不會考~😁

1、標記-清除算法

標記-清除(Mark-Sweep)算法是最基礎的收集算法,後續的收集算法都是基於該算法的思路改進而來的。

算法的基本思想是:算法分爲“標記”和“清除”兩個階段:首先標記出所有需要回收的對象,在標記完成後統一回收所有被標記的對象。

算法的不足主要有兩點:

  1. 一是效率問題,標記和清除兩個過程的效率都不高;
  2. 二是空間空間,標記、清除之後,會產生大量不連續的內存碎片。空間碎片太多可能導致以後在程序運行運行過程中需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。

Ps:

這裏的標記過程,我會在後文 補充說明 模塊詳細描述我的理解。

2、複製算法

爲了解決效率問題,一種被成爲“複製”(Copying)的收集算法出現了。

算法的基本思想是:將內存按照容量劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活着的對象複製到另一塊上面,然後再把之前使用過的那一塊內存空間一次性清理掉。

這種算法的優點是:每次都是對整個半區進行內存回收,需要分配內存時不需要考慮內存碎片等複雜情況,只需要移動堆頂指針,按順序分配內存即可,實現簡單,運行高效。

不足是:將內存縮小爲了原來的一般,代價偏高。同時,在對象存活率較高時就需要進行比較多的複製操作,效率就會變低。

關於該算法,還有一些補充說明:

現在的虛擬機一般採用複製算法來回收新生代,HotSpot中內存劃分方式是:將內存分爲一塊較大的 Eden空間 和兩塊較小的 Survivor空間,每次使用 Eden 和其中一塊 Survivor空間。當回收時,將 Eden和 Survivor 中還存活着的對象一次性複製到另一塊 Survivor 空間上,最後清理掉 Eden 和 剛纔使用過的 Survivor空間。默認情況下,Eden空間和 Survivor空間的大小比爲:8 : 1。也就是每次新生代中可用內存空間是整個新聲代的 90%。

如果某次回收後有多於 10%內存空間 的對象存活,Survivor空間的內存不夠用時,就需要老年代進行分配擔保。存放不下的對象,將會通過分配擔保機制直接進入老年代。

3、標記-整理算法

複製收集算法在對象存活率較高時需要進行比較多的複製操作,效率就會變低。更關鍵的是,我們需要有額外的空間進行分配擔保,以應對內存中所有的對象100%都存活的極端情況,所以在老年代不能直接選用複製收集算法。

根據老年代的特點,有人提出了“標記-整理”(Mark-Compact)算法。

算法的基本思想是:首先標記出需要回收的對象,標記過程與“標記-清除”算法一樣,標記完成後,不直接對可回收的對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的內存。

4、分代收集算法

當前大多數虛擬機都採用“分代收集”(Generational Collection)算法。其實上面就已經提到了。

該算法的基本思想是:根據對象存活週期的不同,將內存劃分爲幾塊。一般是把Java對劃分爲新生代和老年代,然後根據各個代的特點採取不同的垃圾收集算法:

新生代中,大量的對象都是“朝生夕死”的,聲明週期很短,只有少量存活。這裏就採用 複製算法,只需要複製出少量存活的對象就可以完成收集,成本很低。

老年代中,對象存活率高,沒有額外的空間對它進行分配擔保,就必須使用“標記-清理”或者“標記-整理”算法來進行回收。

補充說明:

  • Minor GC:新生代GC,指發生在新生代的垃圾收集動作。因爲Java對象大多都具備“朝生夕滅”的特性,所以 Minor GC 非常頻繁,一般回收速度也比較快。
  • Full GC:也叫做 Major GC,指的是 老年代GC,發生在老年代的GC。出現了 Full GC,經常會伴隨着至少一次的 Minor GC。Full GC一般會比 Minor GC慢10倍以上。

垃圾收集器

如果說垃圾收集算法是內存回收的方法論,那麼垃圾收集器就是內存回收的具體實現。當我讀到《Our Collectors》這篇文章的時候,仿照着文中的配圖,結合着我對周老師書中的理解,也畫了下面這幅圖:

上圖中的橫線代表 兩個虛擬機可以“配合工作”。比如,當新生代我們使用 ParNew收集器 時,那老年代我們只能選擇 CMS收集器 或者 Serial Old收集器。

1、Serial / Serial Old 收集器

Serial Old 收集器就是 Serial 收集器的老年代版本。在新生代使用 標記-清除算法,在老年代使用 標記-整理算法。下面就不多加區分兩種收集器了。

Serial 收集器使用單線程完成垃圾收集工作,並且在其進行垃圾收集時會“Stop The World”,也就是會暫停其他工作線程,直到垃圾收集結束。其優點是:簡單而高效,沒有線程交互的開銷,可以獲得最高的單線程收集效率。

Serial 收集器主要面向的是運行在Client模式下的虛擬機,以及數據集較小的應用(大約100MB)。針對第二點,官方文檔原文:

it can be useful on multiprocessors for applications with small data sets (up to approximately 100 MB). 

Ps:

上面標註的 Serial Old 和 CMS 之間配合工作:Serial Old 可以作爲CMS收集器的備用方案,當CMS收集器在併發收集中發生 Current Model Failure 時,此時就可以使用Serial Old 來完成垃圾收集工作。

2、ParNew 收集器

ParNew收集器就是 Serial收集器的 多線程 版本,兩者也共用了相當一部分的代碼。它使用多條線程進行垃圾收集。

ParNew收集器主要用於 新生代 的垃圾收集,使用 複製算法。在垃圾收集時,也需要“Stop The World”。

ParNew收集器是運行在Server模式下的虛擬機首選的 新生代 垃圾收集器,除了其性能好,還有一個原因是,除了Serial收集器外,只有ParNew收集器能夠和 CMS收集器配合工作。

Ps:

在周老師的書中,配圖是:ParNew在新生代使用複製算法,在老年代使用標記-整理算法。對於這一點,我查閱了官方文檔,以及Oracle博客中的一些博文,沒有發現有任何地方明確說明了 ParNew用於收集老年代,以及在老年代使用的算法,只提到了ParNew收集器使用複製算法收集新生代。在《Our Collectors》中是這麼描述的:

"ParNew" is a stop-the-world, copying collector which uses multiple GC threads.

"ParNew" (and "Serial") implements space_iterate() which will apply an operation to every object in the young generation.

3、Parallel Scavenge / Parallel Old 收集器

Parallel Scavenge收集器是一個 新生代收集器,使用複製算法。Parallel Old收集器是 Parallel Scavenge收集器的老年代版本,使用 標記-整理 算法。該收集器是多線程收集器,並且其目標是爲了達到一個可控制的吞吐量(Throughput)。

高吞吐量可以高效率地利用CPU時間,儘快完成程序的運算任務,主要適合在後臺運算而不需要太多交互的任務。

4、CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取 最短回收停頓時間 爲目標的收集器。

CMS收集器是基於“標記-清除”算法實現的,主要用於 老年代 的垃圾收集。整個標記過程分爲4個步驟:

  1. 初始標記:需要“Stop The World”,僅僅只標記一下 GC Roots 能直接關聯到的對象。
  2. 併發標記:進行 GC Roots Tracing 的過程,也就是找到所有能夠與GC Roots直接或間接連接到的對象。耗時較長。
  3. 重新標記:需要“Stop The World”,用於修正併發標記期間因用戶線程繼續運作而導致標記產生變動的那一部分對象的標記記錄。
  4. 併發清除:不需要“Stop The World”,併發清理掉需要回收的對象。耗時較長。

CMS收集器的優點是 併發收集,低停頓。同時,其缺點也很明顯:

  • CMS收集器對CPU資源非常敏感。實際上,面向併發設計的程序都對CPU資源敏感。在併發標記階段,用戶線程雖然不會暫停,但是標記線程會佔用CPU資源,用戶程序就會變慢。
  • CMS收集器無法處理浮動垃圾,可能出現“Concurrent Mode Failure”失敗而導致另一次 Full GC 的產生。當老年代使用到一定比率(JDK6中是 92%)時CMS收集器就會被激活,因爲要預留一部分空間提供給併發收集時的程序運算使用。
  • CMS是基於“標記-清除”算法實現的虛擬機,這就意味着收集結束時會有大量的碎片產生。碎片太多時,就會給大對象的分配帶來很大麻煩,往往會出現老年代還有很大空間剩餘,但是無法找到足夠大的連續空間來分配給當前對象,而不得不提前觸發一次 Full GC。

5、G1收集器

G1(Garbage First)收集器是一款面向服務端應用的收集器。根據HotSpot開發團隊的描述,如果G1表現符合預期,它將成爲“ParNew + CMS”組合的低延時替代收集器。G1收集器的特點是:

  • 可預測的GC停頓
  • 更高的GC工作效率
  • 更低的Stop The World停頓時間,沒有碎片
  • 併發和並行性更好,能充分利用多CPU、多核環境下的硬件優勢
  • 更好的堆利用率

這個地方衆說紛紜,我也不想再看了。有興趣的同學,可以看看周老師對這裏的描述,以及《Our Collectors》一文中的Question 4。

補充說明

上面介紹“標記-清除”算法時,沒有詳細說明標記流程。這裏,先介紹一個GC Roots根結點的枚舉,以及我自己對標記過程的理解。

Stop The World

在枚舉根節點(從GC Roots 節點找到引用鏈)操作時,可達性分析工作必須在一個能確保“一致性”的快照中進行——這裏的“一致性”是指:在整個分析期間,整個執行系統看起來就像被凍結在某個時間點上,不可以出現分析過程中對象的引用關係還在不斷變化的情況,否則分析結果的準確性就無法得到保證。這就導致在GC進行時,JVM會暫停所有的Java執行線程,Sun官方將這一事件稱爲“Stop The World”,簡寫爲 STW

即使是在號稱幾乎不會停頓的CMS收集器中,枚舉根節點時也是必須要停頓的。

對象標記過程

首先說明:下面這一小部分內容是作者自己的理解,不一定正確。作者也是在不斷學習之中,如果有大牛偶然路過,還望不吝賜教,感激不盡!

首先是周志明老師在《深入理解Java虛擬機》的第3章:3.2.4生存還是死亡 中寫的:

即使在可達性分析算法中不可達的對象,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段,要真正宣告一個對象的死亡,至少要經歷兩次標記過程:如果對象在進行可達性分析後發現沒有與GC Roots 相連接的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此對象是否有必要執行 finalize() 方法。當對象沒有覆蓋 finalize() 方法,或者 finalize() 方法已經被虛擬機調用過,虛擬機將這兩種情況都視爲“沒有必要執行”。

如果這個對象被判定爲有必要執行 finalize() 方法,這個對象將會被放置在一個叫做F-Queue 的隊列之中,並在稍後由一個由虛擬機自動建立的、低優先級的 Finalizer 線程去執行它。這裏所謂的“執行”是指虛擬機會觸發這個方法,但並不承諾會等待它運行結束。這樣做的原因是如果一個對象的 finalize() 方法執行緩慢,或者發生了死循環,很有可能導致 F-Queue 隊列中的其他對象永久處於等待,甚至導致整個內存回收系統崩潰。finalize() 方法是對象逃脫死亡命運的最後一次機會,稍後GC 將對 F-Queue中的對象進行第二次小規模的標記,如果對象要在 finalize() 方法中成功拯救自己——只需要重新與引用鏈上的任何一個對象建立關聯即可,譬如把自己(this關鍵字)賦值給某個類變量或者對象的成員變量,那在第二次標記時它將被移出“即將回收”的集合;如果對象這時候還沒有與逃脫,那基本上它就真的(要)被回收了。

當我在看到這面這一段時,我就很疑惑:如上面處所說:“至少要經歷兩次標記過程”。而後文提到,只有在這個對象被判定爲有必要執行 finalize() 方法的時候,纔會被觸發第二次標記。那豈不是和作者所說的“至少要經歷兩次標記過程”矛盾了?

當我看完《Debugging to understand Finalizers》這篇文章的時候,我的理解是這樣的:

當對象沒有覆蓋 finalize() 方法,或者對象的 finalize() 方法已經被調用過時,對象只會被標記一次,在 Minor GC 時便會被回收內存;

當對象對應的類重寫了 finalize() 方法,JVM會針對每一個重寫了 finalize() 方法的類的對象註冊一個 java.lang.ref.Finalizer 類的實例,該 Finalizer 實例將會引用到這個對象(重寫了 finalize()方法的類的對象)。而沒有覆蓋 finalize() 方法對象則不會有對應的 Finalizer 實例生成,也不會有後面的操作,將正常參與GC內存回收。

Finalizer類的實現類似一個雙向鏈表,每個Finalizer實例結點都包含其前驅結點、後繼結點的引用信息。也就是說,所有重寫了 finalize() 方法的類的對象,在構造時,都會對應生成一個該對象專屬的 Finalizer 實例,這些實例最終將成爲一條 Finalzer鏈

下面是 Finalizer類的部分源碼:

final class Finalizer extends FinalReference<Object> {
    // 靜態對象,所有的 Finalzer實例共享同一個隊列
    private static ReferenceQueue<Object> queue = new ReferenceQueue<>();
    private static Finalizer unfinalized = null;
    private static final Object lock = new Object();

    // 其前驅結點、後繼結點的引用信息
    private Finalizer next = null, prev = null;

    /**
     * 私有構造
     * 所有重寫了的 finalize() 方法的類的對象,在構造時,都會通過下面的 finalizee 引用,
     * 額外生成一個該對象專屬的 Finalizer 實例,這些示例最終將成爲一條 Finalizer 鏈
     */
    private Finalizer(Object finalizee) {
        super(finalizee, queue);
        // 即下面的這個 add 方法
        add();
    }

    // 通過 add 方法,所有的 Finalizer 對象最終會構成一條鏈
    private void add() {
        synchronized (lock) {
            if (unfinalized != null) {
                this.next = unfinalized;
                unfinalized.prev = this;
            }
            unfinalized = this;
        }
    }
    // 此處省略其他代碼
}

由於這些對象會被 Finalizer 類的實例引用,所以在接下來的這一次 GC 中,這些對象將不會被回收;當這次GC執行完成之後,JVM就能知道,除了這些 Finalizer實例,沒有其他什麼地方再引用到這些對象了。GC內部將會把這些 Finalizer實例 添加到一個 java.lang.ref.ReferenceQueue<T> 隊列中。這個添加到隊列的操作,一定是在一次GC之後纔會觸發。

如上圖,我寫的一個測試用例。在System.gc()執行之前,我們在IDEA上找到 Memory 調試模塊,點擊 Load Classes,然後搜索 “final”這個關鍵字(如圖),就可以找到我標註的那個 java.lang.ref.Finalizer Class,我們雙擊它,就可以看到圖中的彈窗。彈窗中我們可以得到三條信息以證明上面我的理解:

  1. 針對每一個重寫了finalize()方法的類的對象,JVM會創建一個引用到該對象的Finalizer實例。如圖中的藍色框標記,Finalizer@785的 referent字段就引用了我的 FinalizeObj@471 對象。
  2. 所有的Finalzer實例都共享同一個 ReferenceQueue 隊列。如圖中的 ReferenceQueue@1207.
  3. 在一次GC之前,該ReferenceQueue 隊列還爲空,沒有任何對象放入。

我們將斷點打在 Finalize 類中的 FinalizerThread 類中的 run() 方法中,然後讓代碼繼續執行,執行System.gc()方法後,並且讓主線程休眠1秒。可以觀察到如下圖:

我們個給斷點加上條件,條件就是篩選出 引用了我寫的 finalizeObj 的 Finalizer對象。然後我們可以觀察到:在 queue.remove() 之前,引用 覆蓋了finalize() 方法的 FinalizeObj@471 這個對象的 Finalizer實例 Finalizer@785 已經被添加到了 ReferenceQueue@1207 這個隊列中。在從隊列中拿到Finalizer@785實例時,該實例引用的隊列變爲了一個NULL隊列(實際上該隊列是ReferenceQueue.NULL,類似於 Collections.emptyList() 返回的空List一樣)。

此時 Finalizer@785 實例仍然是引用了 FinalizeObj@471 這個對象的。下面我們看看 f.runFinalizer(jla) 到底執行了什麼:

private void runFinalizer(JavaLangAccess jla) {
    synchronized (this) {
        if (hasBeenFinalized()) return;
        remove();
    }
    try {
        Object finalizee = this.get();
        if (finalizee != null && !(finalizee instanceof java.lang.Enum)) {
            jla.invokeFinalize(finalizee);
            /* Clear stack slot containing this variable, to decrease
               the chances of false retention with a conservative GC.
              注意下面行代碼,改變了 Finalizer 引用的 FinalizeObj 的指針指向
              將 finalizee 指向null,而不是指向 FinalizeObj。
              這樣,FinalizeObj就沒有任何地方引用了!將正常參與GC垃圾回收 */
            finalizee = null;
        }
    } catch (Throwable x) { }
    super.clear();
}

上面 finalizee = null 這一行執行完畢, Finalizer@785 實例將不再引用 FinalizeObj@471 這個對象。當下一次GC來臨時,FinalizeObj@471 這個對象將會被回收。

還需要補充的是,上面斷點我們打在了 FinalizerThread 這個類中。下面是該類的構造方法:

FinalizerThread(ThreadGroup g) {
    super(g, "Finalizer");
}

這個方法很簡單,初始化一個名爲“Finalizer”的線程。我們在Finalizer類代碼末尾(不同的JDK版本可能位置不同,我的是JDK8),可以看到有一個靜態塊:

static {
    ThreadGroup tg = Thread.currentThread().getThreadGroup();
    for (ThreadGroup tgn = tg;
         tgn != null;
         tg = tgn, tgn = tg.getParent());
    Thread finalizer = new FinalizerThread(tg);
    // 線程優先級比主線程低
    finalizer.setPriority(Thread.MAX_PRIORITY - 2);
    // 守護線程
    finalizer.setDaemon(true);
    finalizer.start();
}

當 Finalizer 這個類加載的時候,就會啓動一個優先級略低的守護線程,名爲“Finalizer”。實際上,我們通過 Jps、Jstack 命令可以發現這個名爲“Finalizer”的守護線程。

這個線程優先級比主線程低,從 FinalizerThread 源碼中的 run() 方法可以看出,該線程只做一件事情:這個線程一直循環,每檔它發現有新的 Finalizer實例出現在 ReferenceQueue 這個隊列中時,就將該Finalizer實例從隊列中彈出,並且得到其引用的 重寫了 finalize() 方法的對象,然後執行該對象的 finalize() 方法,並且移除掉Finalizer實例對該對象的引用。此時,該對象將會被標記爲不可達。因此,當下一次GC來臨時,該對象就會被回收內存。

理解了上面的這些內容後,我們再看上面引用的周老師的內容中,我的標註:

  • ①:至少要經歷兩次標註過程,我覺得描述的有問題。如果對象沒有覆蓋 finalize() 方法,只會被標記一次,就等待被回收。
  • ④:F-Queue:即上面的 java.lang.ref.ReferenceQueue<T> 隊列,並且放入其中的也不是 重寫了 finalize() 方法的對象,而是引用了該對象的對應的 Finalizer 實例。
  • ⑤:虛擬機自動建立的、低優先級的 Finalizer 線程,即上面提到的名爲 “Finalizer”的低優先級線程。

總結

本文主要分爲兩部分:第一部分是根據周志明老師的《深入理解Java虛擬機》總結了一下JVM的GC機制,第二部分是自己根據自己的理解,結合源碼分析,總結了一下對象死亡判定時的標記過程。現在獲取知識的途徑有很多,獲取的知識也很雜。不同的人會有不同的看法。你一搜“JVM”這個關鍵詞,網上99%的內容都是搬運的周老師書中的內容,本文也不例外,也搬運的有。所以,在更多的時候,我們對知識,要有自己的思考,而不是死記硬背。希望和大家一起進步~

參考文檔

1、《深入理解Java虛擬機》第三章. 第二版 周志明·著

2、JDK finalize() 方法的註釋文檔

3、https://bbs.csdn.net/topics/392309703

4、https://stackoverflow.com/questions/2506488/when-is-the-finalize-method-called-in-java

5、https://plumbr.io/blog/garbage-collection/debugging-to-understand-finalizer

6、https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/collectors.html

7、https://dzone.com/articles/garbage-collectors-serial-vs-0

8、https://blogs.oracle.com/jonthecollector/our-collectors

發佈了47 篇原創文章 · 獲贊 43 · 訪問量 9萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章