1. 程式人生 > >深入理解JVM虛擬機器3:垃圾回收器詳解

深入理解JVM虛擬機器3:垃圾回收器詳解

深入理解JVM虛擬機器3:垃圾回收器詳解

轉自How 2 Play Life 垃圾回收器詳解
關於 JVM 記憶體管理或者說垃圾收集,大家可能看過很多的文章了,筆者準備給大家總結下。這算是系列的第一篇,接下來一段時間會持續更新。

本文主要是翻譯《Memory Management in the Java HotSpot Virtual Machine》白皮書的前四章內容,這是 2006 的老文章了,當年釋出這篇文章的還是 Sun Microsystems,以後應該會越來越少人記得這家曾經無比偉大的公司了。

雖然這個白皮書有點老了,不過那個時候 Sun 在 J2SE 5.0 版本的 HotSpot 虛擬機器上已經有了 Parallel 並行垃圾收集器和 CMS 這種併發收集器了,所以其實內容也沒那麼過時。
目錄

  • 垃圾收集概念

    • 垃圾收集器的理想特徵
    • 設計上的權衡
    • 效能指標
    • 分代收集介紹
  • J2SE 5.0 HotSpot JVM 中的垃圾收集器

    • HotSpot 分代
    • 垃圾回收型別
    • 快速分配
    • 序列收集器
    • 並行收集器
    • 並行壓縮收集器
    • Concurrent Mark-Sweep(CMS)收集器
  • 小結

垃圾收集概念

GC 需要做 3 件事情:

  • 分配記憶體,為每個新建的物件分配空間
  • 確保還在使用的物件的記憶體一直還在,不能把有用的空間當垃圾回收了
  • 釋放不再使用的物件所佔用的空間

我們把還被 GC Roots 引用的物件稱為活的,把不再被引用的物件認為是死的,也就是我們說的垃圾,GC 的工作就是找到死的物件,回收它們佔用的空間。

在這裡,我們總結一下 GC Roots 有哪些:

  • 當前各執行緒執行方法中的區域性變數(包括形參)引用的物件
  • 已被載入的類的 static 域引用的物件
  • 方法區中常量引用的物件
  • JNI 引用

以上不完全,不過我覺得了解到這些就夠了,瞭解更多

我們把 GC 管理的記憶體稱為 堆(heap),垃圾收集啟動的時機取決於各個垃圾收集器,通常,垃圾收集發生於整個堆或堆的部分已經被使用光了,或者使用的空間達到了某個百分比閾值。這些後面都會具體說,這裡的每一句話都是對應了某些場景的。

對於記憶體分配請求,實現的難點在於在堆中找到一塊沒有被使用的確定大小的記憶體空間。所以,對於大部分垃圾回收演算法來說避免記憶體碎片化是非常重要的,它將使得空間分配更加高效。

垃圾收集器的理想特徵

安全和全面: 活的物件一定不能被清理掉,死的物件一定不能在幾個回收週期結束後還在記憶體中。
高效: 不能將我們的應用程式掛起太長時間。我們需要在時間、空間、頻次上作出權衡。比如,如果堆記憶體很小,每次垃圾收集就會很快,但是頻次會增加。如果堆記憶體很大,很久才會被填滿,但是每一次回收需要的時間很長。
儘量少的記憶體碎片: 每次將垃圾物件釋放以後,這些空間可能分佈在各個地方,最糟糕的情況就是,記憶體中到處都是碎片,在給一個大物件分配空間的時候沒有記憶體可用,實際上記憶體是夠的。消除碎片的方式就是壓縮。
可擴充套件性: 在多核多執行緒應用中,記憶體分配和垃圾回收都不應該成為可擴充套件性的瓶頸。原文提到的這一點,我的理解是:單執行緒垃圾回收在多核系統中會浪費 CPU 資源,如果我理解錯誤,請指正我。

設計上的權衡

往下看之前,我們需要先分清楚這裡的兩個概念:併發和並行

  • 並行:多個垃圾回收執行緒同時工作,而不是隻有一個垃圾回收執行緒在工作
  • 併發:垃圾回收執行緒和應用程式執行緒同時工作,應用程式不需要掛起

在設計或選擇垃圾回收演算法的時候,我們需要作出以下幾個權衡:

序列 vs 並行

序列收集的情況,即使是多核 CPU,也只有一個核心參與收集。使用並行收集器的話,垃圾收集的工作將分配給多個執行緒在不同的 CPU 上同時進行。並行可以讓收集工作更快,缺點是帶來的複雜性和記憶體碎片問題。

併發 vs Stop-the-world

當 stop-the-world 垃圾收集器工作的時候,應用將完全被掛起。與之相對的,併發收集器在大部分工作中都是併發進行的,也許會有少量的 stop-the-world。

stop-the-world 垃圾收集器比並發收集器簡單很多,因為應用掛起後堆空間不再發生變化,它的缺點是在某些場景下掛起的時間我們是不能接受的(如 web 應用)。

相應的,併發收集器能夠降低掛起時間,但是也更加複雜,因為在收集的過程中,也會有新的垃圾產生,同時,需要有額外的空間用於在垃圾收集過程中應用程式的繼續使用。

壓縮 vs 不壓縮 vs 複製

當垃圾收集器標記出記憶體中哪些是活的,哪些是垃圾物件後,收集器可以進行壓縮,將所有活的物件移到一起,這樣新的記憶體分配就可以在剩餘的空間中進行了。經過壓縮後,分配新物件的記憶體空間是非常簡單快速的。

相對的,不壓縮的收集器只會就地釋放空間,不會移動存活物件。優點就是快速完成垃圾收集,缺點就是潛在的碎片問題。通常,這種情況下,分配物件空間會比較慢比較複雜,比如為新的一個大物件找到合適的空間。

還有一個選擇就是複製收集器,將活的物件複製到另一塊空間中,優點就是原空間被清空了,這樣後續分配物件空間非常迅速,缺點就是需要進行復制操作和佔用額外的空間。

什麼是stop the world?

Java中Stop-The-World機制簡稱STW,是在執行垃圾收集演算法時,Java應用程式的其他所有執行緒都被掛起(除了垃圾收集幫助器之外)。Java中一種全域性暫停現象,全域性停頓,所有Java程式碼停止,native程式碼可以執行,但不能與JVM互動;這些現象多半是由於gc引起。

GC時的Stop the World(STW)是大家最大的敵人。但可能很多人還不清楚,除了GC,JVM下還會發生停頓現象。

JVM裡有一條特殊的執行緒--VM Threads,專門用來執行一些特殊的VM Operation,比如分派GC,thread dump等,這些任務,都需要整個Heap,以及所有執行緒的狀態是靜止的,一致的才能進行。所以JVM引入了安全點(Safe Point)的概念,想辦法在需要進行VM Operation時,通知所有的執行緒進入一個靜止的安全點。

除了GC,其他觸發安全點的VM Operation包括:

  1. JIT相關,比如Code deoptimization, Flushing code cache ;

  2. Class redefinition (e.g. javaagent,AOP程式碼植入的產生的instrumentation) ;

  3. Biased lock revocation 取消偏向鎖 ;

  4. Various debug operation (e.g. thread dump or deadlock check);

效能指標

以下幾個是評估垃圾收集器效能的一些指標:

  • 吞吐量:應用程式的執行時間佔總時間的百分比,當然是越高越好
  • 垃圾收集開銷:垃圾收集時間佔總時間的百分比(1 - 吞吐量)
  • 停頓時間:垃圾收集過程中導致的應用程式掛起時間
  • 頻次:相對於應用程式來說,垃圾收集的頻次
  • 空間:垃圾收集佔用的記憶體
  • 及時性:一個物件從成為垃圾到該物件空間再次可用的時間

在互動式程式中,通常希望是低延時的,而對於非互動式程式,總執行時間比較重要。實時應用程式既要求每次停頓時間足夠短,也要求總的花費在收集的時間足夠短。在小型個人計算機和嵌入式系統中,則希望佔用更小的空間。

分代收集介紹

當我們使用分代垃圾收集器時,記憶體將被分為不同的代(generation),最常見的就是分為年輕代和老年代。

在不同的分代中,可以根據不同的特點使用不同的演算法。分代垃圾收集基於 weak generational hypothesis 假設(通常國人會翻譯成 弱分代假設):

  • 大部分物件都是短命的,它們在年輕的時候就會死去
    極少老年物件對年輕物件的引用。
  • 年輕代中的收集是非常頻繁的、高效的、快速的,因為年輕代空間中,通常都是小物件,同時有非常多的不再被引用的物件。

那些經歷過多次年輕代垃圾收集還存活的物件會晉升到老年代中,老年代的空間更大,而且佔用空間增長比較慢。這樣,老年代的垃圾收集是不頻繁的,但是進行一次垃圾收集需要的時間更長。

對於新生代,需要選擇速度比較快的垃圾回收演算法,因為新生代的垃圾回收是頻繁的。

對於老年代,需要考慮的是空間,因為老年代佔用了大部分堆記憶體,而且針對該部分的垃圾回收演算法,需要考慮到這個區域的垃圾密度比較低。

J2SE 5.0 HotSpot JVM 中的垃圾收集器

J2SE 5.0 HotSpot 虛擬機器包含四種垃圾收集器,都是採用分代演算法。包括序列收集器、並行收集器、並行壓縮收集器 和 CMS 垃圾收集器。

HotSpot 分代

在 HotSpot 虛擬機器中,記憶體被組織成三個分代:年輕代、老年代、永久代。

大部分物件初始化的時候都是在年輕代中的。

老年代存放經過了幾次年輕代垃圾收集依然還活著的物件,還有部分大物件因為比較大所以分配的時候直接在老年代分配。

如 -XX:PretenureSizeThreshold=1024,這樣大於 1k 的物件就會直接分配在老年代

永久代,通常也叫 方法區,用於儲存已載入類的元資料,以及儲存執行時常量池等。

快速分配

如果垃圾收集完成後,存在大片連續的記憶體可用於分配給新物件,這種情況下分配空間是非常簡單快速的,只要一個簡單的指標碰撞就可以了(bump-the-pointer),每次分配物件空間只要檢測一下是否有足夠的空間,如果有,指標往前移動 N 位就分配好空間了,然後就可以初始化這個物件了。

對於多執行緒應用,物件分配必須要保證執行緒安全性,如果使用全域性鎖,那麼分配空間將成為瓶頸並降低程式效能。HotSpot 使用了稱之為 Thread-Local Allocation Buffers (TLABs) 的技術,該技術能改善多執行緒空間分配的吞吐量。首先,給予每個執行緒一部分記憶體作為快取區,每個執行緒都在自己的快取區中進行指標碰撞,這樣就不用獲取全域性鎖了。只有當一個執行緒使用完了它的 TLAB,它才需要使用同步來獲取一個新的緩衝區。HotSpot 使用了多項技術來降低 TLAB 對於記憶體的浪費。比如,TLAB 的平均大小被限制在 Eden 區大小的 1% 之內。TLABs 和使用指標碰撞的線性分配結合,使得記憶體分配非常簡單高效,只需要大概 10 條機器指令就可以完成。

序列收集器

使用序列收集器,年輕代和老年代都使用單執行緒進行收集(使用一個 CPU),收集過程中會 stop-the-world。所以當在垃圾收集的時候,應用程式是完全停止的。

在年輕代中使用序列收集器

下圖展示了年輕代中使用序列收集器的流程。
在這裡插入圖片描述
年輕代分為一個 Eden 區和兩個 Survivor 區(From 區和 To 區)。 年輕代垃圾收集時,將 Eden 中活著的物件複製到空的 Survivor-To 區,Survivor-From 區的物件分兩類,一類是年輕的,也是複製到 Survivor-To 區,還有一類是老傢伙,晉升到老年代中。

Survivor-From 和 Survivor-To 是我瞎取的名字。。。

如果複製的過程中,發現 Survivor-To 空間滿了,將剩下還沒複製到 Survivor-To 的來自於 Eden 和 Survivor-From 區的物件直接晉升到老年代。

年輕代垃圾收集完成後,Eden 區和 Survivor-From 就乾淨了,此時,將 Survivor-From 和 Survivor-To 交換一下角色。得到下面這個樣子:
在這裡插入圖片描述
在老年代中使用序列收集器

如果使用序列收集器,在老年代和永久代將通過使用 標記 -> 清除 -> 壓縮 演算法。標記階段,收集器識別出哪些物件是活的;清除階段將遍歷一下老年代和永久代,識別出哪些是垃圾;然後執行壓縮,將活的物件左移到老年代的起始端(永久代類似),這樣就留下了右邊一片連續可用的空間,後續就可以通過指標碰撞的方式快速分配物件空間。
在這裡插入圖片描述
何時應該使用序列收集器

序列收集器適用於執行在 client 模式下的大部分程式,它們不要求低延時。在現代硬體條件下,序列收集器可以高效管理 64M 堆記憶體,並且能將 full GC 控制在半秒內完成。

使用序列收集器

它是 J2SE 5.0 版本 HotSpot 虛擬機器在非伺服器級別硬體的預設選擇。你也可以使用 -XX:+UseSerialGC 來強制使用序列收集器。

並行收集器

現在大多數 Java 應用都執行在大記憶體、多核環境中,並行收集器,也就是大家熟知的吞吐量收集器,利用多核的優勢來進行垃圾收集,而不是像序列收集器一樣將程式掛起後只使用單執行緒來收集垃圾。

在年輕代中使用並行收集器

並行收集器在年輕代中其實就是序列收集器收集演算法的並行版本。它仍然使用 stop-the-world 和複製演算法,只不過使用了多核的優勢並行執行,降低垃圾收集的時間,從而提高吞吐量。下圖示意了在年輕代中,序列收集器和並行收集器的區別:
在這裡插入圖片描述
在老年代中使用並行收集器

在老年代中,並行收集器使用的是和序列收集器一樣的演算法:單執行緒,標記 -> 清除 -> 壓縮。

何時使用並行收集器

其適用於多核、不要求低停頓的應用,因為老年代的收集雖然不頻繁,但是每次老年代的單執行緒垃圾收集依然可能會需要很長時間。比如說,它可以應用在批處理、賬單計算、科學計算等。

你應該不會想要這個收集器,而是要一個可以對每個代都採用並行收集的並行壓縮收集器,下一節將介紹這個。

使用並行收集器

前面我們說了,J2SE 5.0 中 client 模式自動選擇使用序列收集器,如果是 server 模式,那麼將自動使用並行收集器。在其他版本中,顯示使用 -XX:+UseParallelGC 可以指定並行收集器。

並行壓縮收集器

並行壓縮收集器於 J2SE 5.0 update 6 引入,和並行收集器的區別在於它在老年代也使用並行收集演算法。注意:並行壓縮收集器終將會取代並行收集器。

在年輕代中使用並行壓縮收集器

並行壓縮收集器在年輕代中使用了和並行收集器一樣的演算法。即使用 並行、stop-the-world、複製 演算法。

在老年代中使用並行壓縮收集器

在老年代和永久代中,其使用 並行、stop-the-world、滑動壓縮 演算法

一次收集分三個階段,首先,將老年代或永久代邏輯上分為固定大小的區塊。

  1. 標記階段,將 GC Roots 分給多個垃圾收集執行緒,每個執行緒並行地去標記存活的物件,一旦標記一個存活物件,在該物件所在的區塊記錄這個物件的大小和物件所在的位置。

  2. 彙總階段,此階段針對區塊進行。由於之前的垃圾回收影響,老年代和永久代的左側是 存活物件密集區,對這部分割槽域直接進行壓縮的代價是不值得的,能清理出來的空間有限。所以第一件事就是,檢查每個區塊的密度,從左邊第一個開始,直到找到一個區塊滿足:對右側的所有區塊進行壓縮獲得的空間抵得上壓縮它們的成本。這個區塊左邊的區域過於密集,不會有物件移動到這個區域中。然後,計算並儲存右側區域中每個區塊被壓縮後的新位置首位元組地址。
    右側的區域將被壓縮,對於右側的每個區塊,由於每個區塊中儲存了該區塊的存活物件資訊,所以很容易計算每個區塊的新位置。注意:彙總階段目前被實現為序列進行,這個階段修改為並行也是可行的,不過沒有在標記階段和下面的壓縮階段並行那麼重要。

  3. 壓縮階段,在彙總階段已經完成了每個區塊新位置的計算,所以壓縮階段每個回收執行緒並行將每個區塊複製到新位置即可。壓縮結束後,就清出來了右側一大片連續可用的空間。

何時使用並行壓縮收集器

首先是多核上的並行優勢,這個就不重複了。其次,前面的並行收集器對於老年代和永久代使用序列,而並行壓縮收集器在這些區域使用並行,能降低停頓時間。

並行壓縮收集器不適合執行在大型共享主機上(如 SunRays),因為它在收集的時候會獨佔幾個 CPU,在這種機器上,可以考慮減少垃圾收集的執行緒數(通過 –XX:ParallelGCThreads=n),或者就選擇其他收集器。

使用並行壓縮收集器

顯示指定:-XX:+UseParallelOldGC

Concurrent Mark-Sweep(CMS)收集器

重頭戲 CMS 登場了,至少對於我這個 web 開發者來說,目前 CMS 最常用(使用 JDK8 的應用一般都切換到 G1 收集器了)。前面介紹的都是並行收集,這裡要介紹併發收集了,也就是垃圾回收執行緒和應用程式執行緒同時執行。

對於許多程式來說,吞吐量不如響應時間來得重要。通常年輕代的垃圾收集不會停頓多長時間,但是,老年代垃圾回收,雖然不頻繁,但是可能導致長時間的停頓,尤其當堆記憶體比較大的時候。為了解決這個問題,HotSpot 虛擬機器提供了 CMS 收集器,也叫做 低延時收集器

在年輕代中使用 CMS 收集器

在年輕代中,CMS 和 並行收集器 一樣,即:並行、stop-the-world、複製。

在老年代中使用 CMS 收集器

在老年代的垃圾收集過程中,大部分收集任務是和應用程式併發執行的。

CMS 收集過程首先是一段小停頓 stop-the-world,叫做 初始標記階段(initial mark),用於確定 GC Roots。然後是 併發標記階段(concurrent mark),標記 GC Roots 可達的所有存活物件,由於這個階段應用程式同時也在執行,所以併發標記階段結束後,並不能標記出所有的存活物件。為了解決這個問題,需要再次停頓應用程式,稱為 再次標記階段(remark),遍歷在併發標記階段應用程式修改的物件(標記出應用程式在這個期間的活物件),由於這次停頓比初始標記要長得多,所以會使用多執行緒並行執行來增加效率。

再次標記階段結束後,能保證所有存活物件都被標記完成,所以接下來的 併發清理階段(concurrent sweep) 將就地回收垃圾物件所佔空間。下圖示意了老年代中 序列、標記 -> 清理 -> 壓縮收集器和 CMS 收集器的區別:
在這裡插入圖片描述
由於部分任務增加了收集器的工作,如遍歷併發階段應用程式修改的物件,所以增加了 CMS 收集器的負載。對於大部分試圖降低停頓時間的收集器來說,這是一種權衡方案。
CMS 收集器是唯一不進行壓縮的收集器,在它釋放了垃圾物件佔用的空間後,它不會移動存活物件到一邊去。
在這裡插入圖片描述
這將節省垃圾回收的時間,但是由於之後空閒空間不是連續的,所以也就不能使用簡單的 指標碰撞(bump-the-pointer) 進行物件空間分配了。它需要維護一個 空閒列表,將所有的空閒區域連線起來,當分配空間時,需要尋找到一個可以容納該物件的區域。顯然,它比使用簡單的指標碰撞成本要高。同時它也會加大年輕代垃圾收集的負載,因為年輕代中的物件如果要晉升到老年代中,需要老年代進行空間分配。

另外一個缺點就是,CMS 收集器相比其他收集器需要使用更大的堆記憶體。因為在併發標記階段,程式還需要執行,所以需要留足夠的空間給應用程式。另外,雖然收集器能保證在標記階段識別出所有的存活物件,但是由於應用程式併發執行,所以剛剛標記的存活物件很可能立馬成為垃圾,而且這部分由於已經被標記為存活物件,所以只能到下次老年代收集才會被清理,這部分垃圾稱為 浮動垃圾。

最後,由於缺少壓縮環節,堆將會出現碎片化問題。為了解決這個問題,CMS 收集器需要追蹤統計最常用的物件大小,評估將來的分配需求,可能還需要分割或合併空閒區域。

不像其他垃圾收集器,CMS 收集器不能等到老年代滿了才開始收集。否則的話,CMS 收集器將退化到使用更加耗時的 stop-the-world、標記-清除-壓縮 演算法。為了避免這個,CMS 收集器需要統計之前每次垃圾收集的時間和老年代空間被消耗的速度。另外,如果老年代空間被消耗了 預設佔用率(initiating occupancy),也將會觸發一次垃圾收集,這個佔用率通過 –XX:CMSInitiatingOccupancyFraction=n 進行設定,n 為老年代空間的佔用百分比,預設值是 68。

這個數字到 Java8 的時候已經變為預設 92 了。如果老年代空間不足以容納從新生代垃圾回收晉升上來的物件,那麼就會發生 concurrent mode failure,此時會退化到發生 Full GC,清除老年代中的所有無效物件,這個過程是單執行緒的,比較耗時
另外,即使在晉升的時候判斷出老年代有足夠的空間,但是由於老年代的碎片化問題,其實最終沒法容納晉升上來的物件,那麼此時也會發生 Full GC,這次的耗時將更加嚴重,因為需要對整個堆進行壓縮,壓縮後年輕代徹底就空了。

總結下來,和並行收集器相比,CMS 收集器降低了老年代收集時的停頓時間(有時是顯著降低),稍微增加了一些年輕代收集的時間、降低了吞吐量 以及 需要更多的堆記憶體。

小結

雖然是翻譯的文章,也小結一下吧。

序列收集器:在年輕代和老年代都採用單執行緒,年輕代中使用 stop-the-world、複製 演算法;老年代使用 stop-the-world、標記 -> 清理 -> 壓縮演算法。

並行收集器:在年輕代中使用 並行、stop-the-world、複製 演算法;老年代使用序列收集器的 序列、stop-the-world、標記 -> 清理 -> 壓縮 演算法。

並行壓縮收集器:在年輕代中使用並行收集器的 並行、stop-the-world、複製 演算法;老年代使用 並行、stop-the-world、標記 -> 清理 -> 壓縮演算法。和並行收集器的區別是老年代使用了並行。

CMS 收集器:在年輕使用並行收集器的 並行、stop-the-world、複製 演算法;老年代使用 併發、標記 -> 清理 演算法,不壓縮。本文介紹的唯一一個併發收集器,也是唯一一個不對老年代進行壓縮的收集器。

另外,在 HotSpot 中,永久代使用的是和老年代一樣的演算法。到了 J2SE 8.0 的 HotSpot JVM 中,永久代被 MetaSpace 取代了,這個以後再介紹。

(全文完)

G1 垃圾收集器

之前根據 Sun 的記憶體管理白皮書介紹了在 HotSpot JVM 分代演算法中的幾個垃圾收集器,本文將介紹 G1 垃圾收集器。

G1 的主要關注點在於達到可控的停頓時間,在這個基礎上儘可能提高吞吐量,這一點非常重要。

G1 被設計用來長期取代 CMS 收集器,和 CMS 相同的地方在於,它們都屬於併發收集器,在大部分的收集階段都不需要掛起應用程式。區別在於,G1 沒有 CMS 的碎片化問題(或者說不那麼嚴重),同時提供了更加可控的停頓時間。

如果你的應用使用了較大的堆(如 6GB 及以上)而且還要求有較低的垃圾收集停頓時間(如 0.5 秒),那麼 G1 是你絕佳的選擇,是時候放棄 CMS 了。
**閱讀建議:**本文力求用簡單的話介紹清楚 G1 收集器,但是並不會重複介紹每一個細節,所以希望讀者瞭解其他幾個收集器的工作過程,尤其是 CMS 收集器。

G1 總覽

首先是記憶體劃分上,之前介紹的分代收集器將整個堆分為年輕代、老年代和永久代,每個代的空間是確定的。

而 G1 將整個堆劃分為一個個大小相等的小塊(每一塊稱為一個 region),每一塊的記憶體是連續的。和分代演算法一樣,G1 中每個塊也會充當 Eden、Survivor、Old 三種角色,但是它們不是固定的,這使得記憶體使用更加地靈活。
在這裡插入圖片描述
執行垃圾收集時,和 CMS 一樣,G1 收集執行緒在標記階段和應用程式執行緒併發執行,標記結束後,G1 也就知道哪些區塊基本上是垃圾,存活物件極少,G1 會先從這些區塊下手,因為從這些區塊能很快釋放得到很大的可用空間,這也是為什麼 G1 被取名為 Garbage-First 的原因。
在 G1 中,目標停頓時間非常非常重要,用 -XX:MaxGCPauseMillis=200 指定期望的停頓時間。

G1 使用了停頓預測模型來滿足使用者指定的停頓時間目標,並基於目標來選擇進行垃圾回收的區塊數量。G1 採用增量回收的方式,每次回收一些區塊,而不是整堆回收。

我們要知道 G1 不是一個實時收集器,它會盡力滿足我們的停頓時間要求,但也不是絕對的,它基於之前垃圾收集的資料統計,估計出在使用者指定的停頓時間內能收集多少個區塊。

注意: G1 有和應用程式一起執行的併發階段,也有 stop-the-world 的並行階段。但是,Full GC 的時候還是單執行緒執行的,所以我們應該儘量避免發生 Full GC,後面我們也會介紹什麼時候會觸發 Full GC。

G1 工作流程

前面囉裡囉嗦說了挺多的,唯一要記住的就是,G1 的設計目標就是盡力滿足我們的目標停頓時間上的要求。

本節介紹 G1 的收集過程,G1 收集器主要包括了以下 4 種操作:

  1. 年輕代收集
  2. 併發收集,和應用執行緒同時執行
  3. 混合式垃圾收集
  4. 必要時的 Full GC

接下來,我們進行一一介紹。

年輕代收集

首先,我們來看下 G1 的堆結構:
在這裡插入圖片描述
年輕代中的垃圾收集流程(Young GC):
在這裡插入圖片描述
我們可以看到,年輕代收集概念上和之前介紹的其他分代收集器大差不差的,但是它的年輕代會動態調整。

Old GC / 併發標記週期

接下來是 Old GC 的流程(含 Young GC 階段),其實把 Old GC 理解為併發週期是比較合理的,不要單純地認為是清理老年代的區塊,因為這一步和年輕代收集也是相關的。下面我們介紹主要流程:

  1. 初始標記:stop-the-world,它伴隨著一次普通的 Young GC 發生,然後對 Survivor 區(root region)進行標記,因為該區可能存在對老年代的引用。
    因為 Young GC 是需要 stop-the-world 的,所以併發週期直接重用這個階段,雖然會增加 CPU 開銷,但是停頓時間只是增加了一小部分。

  2. 掃描根引用區:掃描 Survivor 到老年代的引用,該階段必須在下一次 Young GC 發生前結束。
    這個階段不能發生年輕代收集,如果中途 Eden 區真的滿了,也要等待這個階段結束才能進行 Young GC。

  3. 併發標記:尋找整個堆的存活物件,該階段可以被 Young GC 中斷。
    這個階段是併發執行的,中間可以發生多次 Young GC,Young GC 會中斷標記過程

  4. 重新標記:stop-the-world,完成最後的存活物件標記。使用了比 CMS 收集器更加高效的 snapshot-at-the-beginning (SATB) 演算法。
    Oracel 的資料顯示,這個階段會回收完全空閒的區塊

  5. 清理:清理階段真正回收的記憶體很少。

混合垃圾回收週期

併發週期結束後是混合垃圾回收週期,不僅進行年輕代垃圾收集,而且回收之前標記出來的老年代的垃圾最多的部分割槽塊。

混合垃圾回收週期會持續進行,直到幾乎所有的被標記出來的分割槽(垃圾佔比大的分割槽)都得到回收,然後恢復到常規的年輕代垃圾收集,最終再次啟動併發週期。

Full GC

到這裡我們已經說了年輕代收集、併發週期、混合回收週期了,大家要熟悉這幾個階段的工作。

下面我們來介紹特殊情況,那就是會導致 Full GC 的情況,也是我們需要極力避免的:

  1. concurrent mode failure:併發模式失敗,CMS 收集器也有同樣的概念。G1 併發標記期間,如果在標記結束前,老年代被填滿,G1 會放棄標記。

這個時候說明

  1. 堆需要增加了,
  2. 或者需要調整併發週期,如增加併發標記的執行緒數量,讓併發標記儘快結束
  3. 或者就是更早地進行併發週期,預設是整堆記憶體的 45%被佔用就開始進行併發週期。
  1. 晉升失敗:併發週期結束後,是混合垃圾回收週期,伴隨著年輕代垃圾收集,進行清理老年代空間,如果這個時候清理的速度小於消耗的速度,導致老年代不夠用,那麼會發生晉升失敗。

說明混合垃圾回收需要更迅速完成垃圾收集,也就是說在混合回收階段,每次年輕代的收集應該處理更多的老年代已標記區塊。

  1. 疏散失敗:年輕代垃圾收集的時候,如果 Survivor 和 Old 區沒有足夠的空間容納所有的存活物件。這種情況肯定是非常致命的,因為基本上已經沒有多少空間可以用了,這個時候會觸發 Full GC 也是很合理的。

最簡單的就是增加堆大小

  1. 大物件分配失敗,我們應該儘可能地不建立大物件,尤其是大於一個區塊大小的那種物件。

簡單小結

看完上面的 Young GC 和 Old GC 等,很多讀者可能還是很懵的,這裡說幾句不嚴謹的白話文幫助讀者進行理解:

首先,最好不要把上面的 Old GC 當做是一次 GC 來看,而應該當做併發標記週期來理解,雖然它確實會釋放出一些記憶體。

併發標記結束後,G1 也就知道了哪些區塊是最適合被回收的,那些完全空閒的區塊會在這這個階段被回收。如果這個階段釋放了足夠的記憶體出來,其實也就可以認為結束了一次 GC。

我們假設併發標記結束了,那麼下次 GC 的時候,還是會先回收年輕代,如果從年輕代中得到了足夠的記憶體,那麼結束;過了幾次後,年輕代垃圾收集不能滿足需要了,那麼就需要利用之前併發標記的結果,選擇一些活躍度最低的老年代區塊進行回收。直到最後,老年代會進入下一個併發週期。

那麼什麼時候會啟動併發標記週期呢?這個是通過引數控制的,下面馬上要介紹這個引數了,此引數預設值是 45,也就是說當堆空間使用了 45% 後,G1 就會進入併發標記週期。