帶你掌握JVM垃圾回收

baicai發表於2020-06-12

垃圾回收( Garbage Collection 以下簡稱 GC)誕生於1960年 MIT 的 Lisp 語言,有半個多世紀的歷史。在Java 中,JVM 會對記憶體進行自動分配與回收,其中 GC 的主要作用就是清楚不再使用的物件,自動釋放記憶體

GC 相關的研究者們主要是思考這3件事情。

  • 哪些記憶體需要回收?
  • 什麼時候回收?
  • 如何回收?

本文也大致按照這個思路,為大家描述垃圾回收的相關知識。因為會有很多記憶體區域相關的知識,希望讀者先學習完精美圖文帶你掌握 JVM 記憶體佈局再來閱讀本文。

在這裡先感謝周志明大佬的新鮮出爐的大作:《深入理解Java 虛擬機器》- 第3版

拜讀之後對JVM有了更深的理解,強烈推薦大家去看。

本文的主要內容如下(建議大家在閱讀和學習的時候,也大致按照以下的思路來思考和學習):

  • 哪些記憶體需要回收?即GC 發生的記憶體區域?
  • 如何判斷這個物件需要回收?即GC 的存活標準

    這裡又能夠引出以下的知識概念:

    • 引用計數法
    • 可達性分析法
    • 引用的種類和特點、區別 (強引用、軟引用、弱引用、虛引用)
    • 延伸知識:(WeakHashMap) (引用佇列)
  • 有了物件的存活標準之後,我們就需要知道GC 的相關演算法(思想)

    • 標記-清除(Mark-Sweep)演算法
    • 複製(Copying)演算法
    • 標記-整理(Mark-Compact)演算法
  • 在下一步學習之前,還需要知道一些GC的術語?,防止對一些概念描述出現混淆
  • 知道了演算法之後,自然而然我們到了JVM中對這些演算法的實現和應用,即各種垃圾收集器(Garbage Collector)

    • 序列收集器
    • 並行收集器
    • CMS 收集器
    • G1 收集器

一、GC 的 目標區域

一句話:GC 主要關注 堆和方法區

精美圖文帶你掌握 JVM 記憶體佈局一文中,理解介紹了Java 執行時記憶體的分佈區域和特點。

其中我們知道了程式計數器、虛擬機器棧、本地方法棧3個區域是隨執行緒而生,隨執行緒而滅的。棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作。每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知的(儘管在執行期會由JIT編譯器進行一些優化,但在本章基於概念模型的討論中,大體上可以認為是編譯期可知的),因此這幾個區域的記憶體分配和回收都具備確定性,在這幾個區域內就不需要過多考慮回收的問題,因為方法結束或者執行緒結束時,記憶體自然就跟隨著回收了。

堆和方法區則不一樣,一個介面中的多個實現類需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣,我們只有在程式處於執行期間時才能知道會建立哪些物件,這部分記憶體的分配和回收都是動態的。GC 關注的也就是這部分的記憶體區域。

二、GC 的存活標準

知道哪些區域的記憶體需要被回收之後,我們自然而然地想到了,如何去判斷一個物件需要被回收呢?(回收物件...沒物件的我聽著怎麼有點怪怪的?)

對於如何判斷物件是否可以回收,有兩種比較經典的判斷策略。

  • 引用計數演算法
  • 可達性分析演算法

1. 引用計數法

在物件頭維護著一個 counter 計數器,物件被引用一次則計數器 +1;若引用失效則計數器 -1。當計數器為 0 時,就認為該物件無效了。

主流的Java虛擬機器裡面沒有選用引用計數演算法來管理記憶體,其中最主要的原因是它很難解決物件之間相互迴圈引用的問題。發生迴圈引用的物件的引用計數永遠不會為0,結果這些物件就永遠不會被釋放。

image.png

2. 可達性分析演算法 ⭐

GC Roots 為起點開始向下搜尋,搜尋所走過的路徑稱為引用鏈。當一個物件到GC Roots 沒有任何引用鏈相連時,則證明此物件是不可用的。不可達物件。

Java 中,GC Roots 是指:

  • Java 虛擬機器棧(棧幀中的本地變數表)中引用的物件
  • 本地方法棧中引用的物件
  • 方法區中常量引用的物件
  • 方法區中類靜態屬性引用的物件

image.png

3. Java 中的引用 ⭐

Java對引用的概念進行了擴充,將引用分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)4種,這4種引用強度依次逐漸減弱

這樣子設計的原因主要是為了描述這樣一類物件:當記憶體空間還足夠時,則能保留在記憶體之中;如果記憶體空間在進行垃圾收集後還是非常緊張,則可以拋棄這些物件。很多系統的快取功能都符合這樣的應用場景。

也就是說,對不同的引用型別,JVM 在進行GC 時會有著不同的執行策略。所以我們也需要去了解一下。

a. 強引用(Strong Reference)

MyClass obj = new MyClass(); // 強引用

obj = null // 此時‘obj’引用被設為null了,前面建立的'MyClass'物件就可以被回收了
複製程式碼

只要強引用存在,垃圾收集器永遠不會回收被引用的物件,只有當引用被設為null的時候,物件才會被回收。但是,如果我們錯誤地保持了強引用,比如:賦值給了 static 變數,那麼物件在很長一段時間內不會被回收,會產生記憶體洩漏。

b. 軟引用(Soft Reference)

軟引用是一種相對強引用弱化一些的引用,可以讓物件豁免一些垃圾收集,只有當 JVM 認為記憶體不足時,才會去試圖回收軟引用指向的物件。JVM 會確保在丟擲 OutOfMemoryError 之前,清理軟引用指向的物件。軟引用通常用來實現記憶體敏感的快取,如果還有空閒記憶體,就可以暫時保留快取,當記憶體不足時清理掉,這樣就保證了使用快取的同時,不會耗盡記憶體。

SoftReference<MyClass> softReference = new SoftReference<>(new MyClass());
複製程式碼

c. 弱引用(Weak Reference)

弱引用的強度比軟引用更弱一些。當 JVM 進行垃圾回收時,無論記憶體是否充足,都會回收只被弱引用關聯的物件。

WeakReference<MyClass> weakReference = new WeakReference<>(new MyClass());
複製程式碼
弱引用可以引申出來一個知識點, WeakHashMap&ReferenceQueue

ReferenceQueue 是GC回撥的知識點。這裡因為篇幅原因就不細講了,推薦引申閱讀:ReferenceQueue的使用

d. 幻象引用/虛引用(Phantom References)

虛引用也稱為幽靈引用或者幻影引用,它是最弱的一種引用關係。一個物件是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個物件例項。為一個物件設定虛引用關聯的唯一目的就是能在這個物件被收集器回收時收到一個系統通知

PhantomReference<MyClass> phantomReference = new PhantomReference<>(new MyClass(), new ReferenceQueue<>());
複製程式碼

三、GC 演算法 ⭐

有了判斷物件是否存活的標準之後,我們再來了解一下GC的相關演算法。

  • 標記-清除(Mark-Sweep)演算法
  • 複製(Copying)演算法
  • 標記-整理(Mark-Compact)演算法

1. 標記-清除(Mark-Sweep)演算法

標記-清除演算法在概念上是最簡單最基礎的垃圾處理演算法。

該方法簡單快速,但是缺點也很明顯,一個是效率問題,標記和清除兩個過程的效率都不高;另一個是空間問題,標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

image.png

後續的收集演算法都是基於這種思路並對其不足進行改進而得到的。

2. 複製(Copying)演算法

複製演算法改進了標記-清除演算法的效率問題。

它將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。

這樣使得每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。

缺點也是明顯的,可用記憶體縮小到了原先的一半

現在的商業虛擬機器都採用這種收集演算法來回收新生代,IBM公司的專門研究表明,新生代中的物件98%是“朝生夕死”的,所以並不需要按照1:1的比例來劃分記憶體空間,而是將記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。

在前面的文章中我們提到過,HotSpot預設的Eden:survivor1:survivor2=8:1:1,如下圖所示。

延伸知識點:記憶體分配擔保

當然,98%的物件可回收只是一般場景下的資料,我們沒有辦法保證每次回收都只有不多於10%的物件存活,當Survivor空間不夠用時,需要依賴其他記憶體(這裡指老年代)進行分配擔保(Handle Promotion)

記憶體的分配擔保就好比我們去銀行借款,如果我們信譽很好,在98%的情況下都能按時償還,於是銀行可能會預設我們下一次也能按時按量地償還貸款,只需要有一個擔保人能保證如果我不能還款時,可以從他的賬戶扣錢,那銀行就認為沒有風險了。記憶體的分配擔保也一樣,如果另外一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活物件時,這些物件將直接通過分配擔保機制進入老年代。

image.png

3. 標記-整理演算法

前面說了複製演算法主要用於回收新生代的物件,但是這個演算法並不適用於老年代。因為老年代的物件存活率都較高(畢竟大多數都是經歷了一次次GC千辛萬苦熬過來的,身子骨很硬朗?)

根據老年代的特點,提出了另外一種標記-整理(Mark-Compact)演算法,標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。

image.png

4. 分代收集演算法

有沒有注意到了,我們前面的表述當中就引入了新生代、老年代的概念。準確來說,是先有了分代收集演算法的這種思想,才會將Java堆分為新生代和老年代。這兩個概念之間存在著一個先後因果關係。

這個演算法很簡單,就是根據物件存活週期的不同,將記憶體分塊。在Java 堆中,記憶體區域被分為了新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。

就如我們在介紹上面的演算法時描述的,在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就選用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集。而老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用 “標記—清理” 或者 “標記—整理” 演算法 來進行回收。

  • 新生代:複製演算法
  • 老年代:標記-清除演算法、標記-整理演算法

5. 重新回顧 建立物件時觸發GC的流程

這裡重新回顧一下精美圖文帶你掌握 JVM 記憶體佈局裡面JVM建立一個新物件的記憶體分配流程圖。這張圖也描述了GC的流程。

image.png

四、GC 術語 ?

在學習垃圾收集器知識點之前,需要向讀者大大們科普一些GC的術語?,方便你們後面理解。

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

    • 新生代收集(Minor GC/Young GC):指目標只是新生代的垃圾收集。
    • 老年代收集(Major GC/Old GC):指目標只是老年代的垃圾收集。目前只有CMS收集器會有單獨收集老年代的行為。另外請注意“Major GC”這個說法現在有點混淆,在不同資料上常有不同所指,讀者需按上下文區分到底是指老年代的收集還是整堆收集。
    • 混合收集(Mixed GC):指目標是收集整個新生代以及部分老年代的垃圾收集。目前只有G1收集器會有這種行為。
  • 整堆收集(Full GC):收集整個Java堆和方法區的垃圾收集。
  • 並行(Parallel) :在JVM執行時,同時存在應用程式執行緒和垃圾收集器執行緒。 並行階段是由多個GC 執行緒執行,即GC 工作在它們之間分配。
  • 序列(Serial):序列階段僅在單個GC 執行緒上執行。
  • STW :Stop The World 階段,應用程式執行緒被暫停,以便GC執行緒 執行其工作。 當應用程式因為GC 暫停時,這通常是由於Stop The World 階段。
  • 併發(Concurrent):使用者執行緒與垃圾收集器執行緒同時執行,不一定是並行執行,可能是交替執行(競爭)
  • 增量:如果一個階段是增量的,那麼它可以執行一段時間之後由於某些條件提前終止,例如需要執行更高優先順序的GC 階段,同時仍然完成生產性工作。 增量階段與需要完全完成的階段形成鮮明對比。

五、垃圾收集器 ⭐

知道了演算法之後,自然而然我們到了JVM中對這些演算法的實現和應用,即各種垃圾收集器(Garbage Collector)

首先要認識到的一個重要方面是,對於大多數JVM,需要兩種不同的GC演算法,一種用於清理新生代,另一種用於清理老年代

意思就是說,在JVM中你通常會看到兩種收集器組合使用。下圖是JVM 中所有的收集器(Java 8 ),其中有連線的就是可以組合的。

為了減小複雜性,快速記憶,我這邊直接給出比較常用的幾種組合。其他的要麼是已經廢棄了要麼就是在現實情況下不實用的。

新生代

老年代

JVM options

Serial

Serial Old

-XX:+UseSerialGC

Parallel Scavenge

Parallel Old

-XX:+UseParallelGC -XX:+UseParallelOldGC

Parallel New

CMS

-XX:+UseParNewGC -XX:+UseConcMarkSweepGC

G1

G1

-XX:+UseG1GC

接下去我們開始具體介紹上各個垃圾收集器。這裡需要提一下的是,我這邊是將垃圾收集器分成以下幾類來講述的:

  • Serial GC
  • Parallel GC
  • Concurrent Mark and Sweep (CMS)
  • G1 - Garbage First

理由無他,我覺得這樣更符合理解的思路,你更好理解。

4.1 序列收集器

Serial 翻譯過來可以理解成單執行緒。單執行緒收集器有Serial 和 Serial Old 兩種,它們的唯一區別就是:Serial 工作在新生代,使用“複製”演算法,Serial Old 工作在老年代,使用“標誌-整理”演算法。所以這裡將它們放在一起講。

序列收集器收集器是最經典、最基礎,也是最好理解的。它們的特點就是單執行緒執行及獨佔式執行,因此會帶來很不好的使用者體驗。雖然它的收集方式對程式的執行並不友好,但由於它的單執行緒執行特性,應用於單個CPU硬體平臺的效能可以超過其他的並行或併發處理器。

“單執行緒”的意義並不僅僅是說明它只會使用一個處理器或一條收集執行緒去完成垃圾收集工作,更重要的是強調在它進行垃圾收集時,必須暫停其他所有工作執行緒,直到它收集結束(STW階段)

image.png

STW 會帶給使用者惡劣的體驗,所以從JDK 1.3開始,一直到現在最新的JDK 13,HotSpot虛擬機器開發團隊為消除或者降低使用者執行緒因垃圾收集而導致停頓的努力一直持續進行著,從Serial收集器到Parallel收集器,再到Concurrent Mark Sweep(CMS)和Garbage First(G1)收集器,最終至現在垃圾收集器的最前沿成果Shenandoah和ZGC等。

雖然新的收集器很多,但是序列收集器仍有其適合的場景。迄今為止,它依然是HotSpot虛擬機器執行在客戶端模式下的預設新生代收集器,有著優於其他收集器的地方,那就是簡單而高效。對於記憶體資源受限的環境,它是所有收集器裡額外記憶體消耗最小的,單執行緒沒有執行緒互動開銷。(這裡實際上也是一個時間換空間的概念)

通過JVM引數 -XX:+UseSerialGC 可以使用序列垃圾回收器(上面表格也有說明)

4.2 並行收集器

按照程式發展的思路,單執行緒處理之後,下一步很自然就到了多核處理器時代,程式多執行緒並行處理的時代。並行收集器是多執行緒的收集器,在多核CPU下能夠很好的提高收集效能。

image.png

這裡我們會介紹:

  • ParNew
  • Parallel Scavenge
  • Parallel Old

這裡還是提供太長不看版白話總結,方便理解。因為我知道有些人剛開始學習JVM 看這些名詞都會覺得頭暈。

  • ParNew收集器 就是 Serial收集器的多執行緒版本,基於“複製”演算法,其他方面完全一樣,在JDK9之後差不多退出歷史舞臺,只能配合CMS在JVM中發揮作用。
  • Parallel Scavenge 收集器 和 ParNew收集器類似,基於“複製”演算法,但前者更關注可控制的吞吐量,並且能夠通過-XX:+UseAdaptiveSizePolicy開啟垃圾收集自適應調節策略的開關。
  • Parallel Old 就是 Parallel Scavenge 收集器的老年代版本,基於**“標記-整理”演算法**實現。

a. ParNew 收集器

ParNew收集器除了支援多執行緒並行收集之外,其他與Serial收集器相比並沒有太多創新之處,但它卻是不少執行在服務端模式下的HotSpot虛擬機器,尤其是JDK 7之前的遺留系統中首選的新生代收集器,其中有一個與功能、效能無關但其實很重要的原因是:除了Serial收集器外,目前只有它能與CMS收集器配合工作

但是從G1 出來之後呢,ParNew的地位就變得微妙起來,自JDK 9開始,ParNew加CMS收集器的組合就不再是官方推薦的服務端模式下的收集器解決方案了。官方希望它能完全被G1所取代,甚至還取消了『ParNew + Serial Old』 以及『Serial + CMS』這兩組收集器組合的支援(其實原本也很少人這樣使用),並直接取消了-XX:+UseParNewGC引數,這意味著ParNew 和CMS 從此只能互相搭配使用,再也沒有其他收集器能夠和它們配合了。可以理解為從此以後,ParNew 合併入CMS,成為它專門處理新生代的組成部分。

b. Parallel Scavenge收集器

Parallel Scavenge收集器與ParNew收集器類似,也是使用複製演算法的並行的多執行緒新生代收集器。但Parallel Scavenge收集器關注可控制的吞吐量(Throughput)

注:吞吐量是指CPU用於執行使用者程式碼的時間與CPU總消耗時間的比值,即吞吐量 = 執行使用者程式碼時間 /( 執行使用者程式碼時間 + 垃圾收集時間 )

Parallel Scavenge收集器提供了幾個引數用於精確控制吞吐量和停頓時間:

引數

作用

--XX: MaxGCPauseMillis

最大垃圾收集停頓時間,是一個大於0的毫秒數,收集器將回收時間儘量控制在這個設定值之內;但需要注意的是在同樣的情況下,回收時間與回收次數是成反比的,回收時間越小,相應的回收次數就會增多。所以這個值並不是越小越好。

-XX: GCTimeRatio

吞吐量大小,是一個(0, 100)之間的整數,表示垃圾收集時間佔總時間的比率。

XX: +UseAdaptiveSizePolicy

這是一個開關引數,當這個引數被啟用之後,就不需要人工指定新生代的大小(-Xmn)、Eden與Survivor區的比例(-XX:SurvivorRatio)、晉升老年代物件大小(-XX:PretenureSizeThreshold)等細節引數了,虛擬機器會根據當前系統的執行情況收集效能監控資訊,動態調整這些引數以提供最合適的停頓時間或者最大的吞吐量。這種調節方式稱為垃圾收集的自適應的調節策略(GC Ergonomics)

c. Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,多執行緒,基於“標記-整理”演算法。這個收集器是在JDK 1.6中才開始提供的。

由於如果新生代選擇了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器外別無選擇(Parallel Scavenge無法與CMS收集器配合工作),Parallel Old收集器的出現就是為了解決這個問題。Parallel Scavenge和Parallel Old收集器的組合更適用於注重吞吐量以及CPU資源敏感的場合

4.3 ⭐ Concurrent Mark and Sweep (CMS)

CMS(Concurrent Mark Sweep,併發標記清除) 收集器是以獲取最短回收停頓時間為目標的收集器(追求低停頓),它在垃圾收集時使得使用者執行緒和 GC 執行緒併發執行,因此在垃圾收集過程中使用者也不會感到明顯的卡頓。

從名字就可以知道,CMS是基於“標記-清除”演算法實現的。它的工作過程相對於上面幾種收集器來說,就會複雜一點。整個過程分為以下四步:

1)初始標記 (CMS initial mark):主要是標記 GC Root 開始的下級(注:僅下一級)物件,這個過程會 STW,但是跟 GC Root 直接關聯的下級物件不會很多,因此這個過程其實很快。

2)併發標記 (CMS concurrent mark):根據上一步的結果,繼續向下標識所有關聯的物件,直到這條鏈上的最盡頭。這個過程是多執行緒的,雖然耗時理論上會比較長,但是其它工作執行緒並不會阻塞沒有 STW

3)重新標記(CMS remark):顧名思義,就是要再標記一次。為啥還要再標記一次?因為第 2 步並沒有阻塞其它工作執行緒,其它執行緒在標識過程中,很有可能會產生新的垃圾

這裡舉一個很形象的例子:

就比如你和你的小夥伴(多個GC執行緒)給一條長走廊打算衛生,從一頭打掃到另一頭。當你們打掃到走廊另一頭的時候,可能有同學(使用者執行緒)丟了新的垃圾。所以,為了打掃乾淨走廊,需要你示意所有的同學(使用者執行緒)別再丟了(進入STW階段),然後你和小夥伴迅速把剛剛的新垃圾收走。當然,因為剛才已經收過一遍垃圾,所以這次收集新產生的垃圾,用不了多長時間(即:STW 時間不會很長)。

4)併發清除(CMS concurrent sweep):

image.png

❔❔❔ 提問環節:為什麼CMS要使用“標記-清除”演算法呢?剛才我們不是提到過“標記-清除”演算法,會留下很多記憶體碎片嗎?

確實,但是也沒辦法,如果換成“標記 - 整理”演算法,把垃圾清理後,剩下的物件也順便整理,會導致這些物件的記憶體地址發生變化,別忘了,此時其它執行緒還在工作,如果引用的物件地址變了,就天下大亂了

對於上述的問題JVM提供了兩個引數:

引數

作用

--XX: +UseCMS-CompactAtFullCollection

(預設是開啟的,此引數從JDK 9開始廢棄)用於在CMS收集器不得不進行FullGC時開啟記憶體碎片的合併整理過程,記憶體整理的過程是無法併發的,空間碎片問題沒有了,但停頓時間不得不變長。

--XX: CMSFullGCsBeforeCompaction

(此引數從JDK 9開始廢棄)這個引數的作用是要求CMS收集器在執行過若干次(數量由引數值決定)不整理空間的Full GC之後,下一次進入Full GC前會先進行碎片整理(預設值為0,表示每次進入Full GC時都進行碎片整理)

另外,由於最後一步併發清除時,並不阻塞其它執行緒,所以還有一個副作用,在清理的過程中,仍然可能會有新垃圾物件產生,只能等到下一輪 GC,才會被清理掉

4.4 ⭐ G1 - Garbage First

JDK 9釋出之日,G1宣告取代Parallel Scavenge加Parallel Old組合,成為服務端模式下的預設垃圾收集器。

鑑於 CMS 的一些不足之外,比如: 老年代記憶體碎片化,STW 時間雖然已經改善了很多,但是仍然有提升空間。G1 就橫空出世了,它對於堆區的記憶體劃思路很新穎,有點演算法中分治法“分而治之”的味道。具體什麼意思呢,讓我們繼續看下去。

G1 將連續的Java堆劃分為多個大小相等的獨立區域(Region),每一個Region都可以根據需要,扮演新生代的Eden空間、Survivor空間,或者老年代空間。每個Region的大小可以通過引數-XX:G1HeapRegionSize設定,取值範圍為1MB~32MB,且應為2的N次冪

Region中還有一類特殊的Humongous區域,專門用來儲存大物件G1認為只要大小超過了一個Region容量一半的物件即可判定為大物件。對於那些超過了整個Region容量的超級大物件,將會被存放在N個連續的Humongous Region之中

Humongous,簡稱 H 區,是專用於存放超大物件的區域,通常 >= 1/2 Region SizeG1的大多數行為都把Humongous Region作為老年代的一部分來進行看待

image.png

認識了G1中的記憶體規劃之後,我們就可以理解為什麼它叫做"Garbage First"。所有的垃圾回收,都是基於 region 的。G1根據各個Region回收所獲得的空間大小以及回收所需時間等指標在後臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大(垃圾)的Region,從而可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。這也是 "Garbage First" 得名的由來。

G1從整體來看是基於“標記-整理”演算法實現的收集器,但從區域性(兩個Region之間)上看又是基於“標記-複製”演算法實現,無論如何,這兩種演算法都意味著G1運作期間不會產生記憶體空間碎片,垃圾收集完成之後能提供規整的可用記憶體。這種特性有利於程式長時間執行,在程式為大物件分配記憶體時不容易因無法找到連續記憶體空間而提前觸發下一次GC。

❔❔❔ 提問環節:

一個物件和它內部所引用的物件可能不在同一個 Region 中,那麼當垃圾回收時,是否需要掃描整個堆記憶體才能完整地進行一次可達性分析?

這裡就需要引入 Remembered Set 的概念了。

答案是不需要,每個 Region 都有一個 Remembered Set (記憶集)用於記錄本區域中所有物件引用的物件所在的區域,進行可達性分析時,只要在 GC Roots 中再加上 Remembered Set 即可防止對整個堆記憶體進行遍歷

再提一個概念,Collection Set :簡稱 CSet,記錄了等待回收的 Region 集合,GC 時這些 Region 中的物件會被回收(copied or moved)

G1 運作步驟

如果不計算維護 Remembered Set 的操作,G1 收集器的工作過程分為以下幾個步驟:

  • 初始標記(Initial Marking):Stop The World,僅使用一條初始標記執行緒對所有與 GC Roots 直接關聯的物件進行標記。
  • 併發標記(Concurrent Marking):使用一條標記執行緒與使用者執行緒併發執行。此過程進行可達性分析,速度很慢。
  • 最終標記(Final Marking):Stop The World,使用多條標記執行緒併發執行。
  • 篩選回收(Live Data Counting and Evacuation):回收廢棄物件,此時也要 Stop The World,並使用多條篩選回收執行緒併發執行。(還會更新Region的統計資料,對各個Region的回收價值和成本進行排序)

image.png

從上述階段的描述可以看出,G1收集器除了併發標記外,其餘階段也是要完全暫停使用者執行緒的,換言之,它並非純粹地追求低延遲,官方給它設定的目標是在延遲可控的情況下獲得儘可能高的吞吐量

G1 的 Minor GC/Young GC

在分配一般物件時,當所有eden region使用達到最大閾值並且無法申請足夠記憶體時,會觸發一次YGC。每次YGC會回收所有Eden以及Survivor區,並且將存活物件複製到Old區以及另一部分的Survivor區。

image.png

下面是一段經過抽取的GC日誌:

GC pause (G1 Evacuation Pause) (young)
  ├── Parallel Time
    ├── GC Worker Start
    ├── Ext Root Scanning
    ├── Update RS
    ├── Scan RS
    ├── Code Root Scanning
    ├── Object Copy
  ├── Code Root Fixup
  ├── Code Root Purge
  ├── Clear CT
  ├── Other
    ├── Choose CSet
    ├── Ref Proc
    ├── Ref Enq
    ├── Redirty Cards
    ├── Humongous Register
    ├── Humongous Reclaim
    ├── Free CSet  
複製程式碼

由這段GC日誌我們可知,整個YGC由多個子任務以及巢狀子任務組成,且一些核心任務為:Root Scanning,Update/Scan RS,Object Copy,CleanCT,Choose CSet,Ref Proc,Humongous Reclaim,Free CSet

推薦閱讀:深入理解G1的GC日誌

這篇文章通過G1 GC日誌介紹了GC的幾個步驟。對上面英文單詞概念不清楚的可以查閱。

英文好的更推薦這篇:garbage-collection-algorithms-implementations

G1 的 Mixed GC

當越來越多的物件晉升到老年代Old Region 時,為了避免堆記憶體被耗盡,虛擬機器會觸發一個混合的垃圾收集器,即Mixed GC,是收集整個新生代以及部分老年代的垃圾收集。除了回收整個Young Region,還會回收一部分的Old Region ,這裡需要注意:是一部分老年代,而不是全部老年代,可以選擇哪些Old Region 進行收集,從而可以對垃圾回收的耗時時間進行控制。

Mixed GC的整個子任務和YGC完全一樣,只是回收的範圍不一樣。

image.png

注:G1 一般來說是沒有FGC的概念的。因為它本身不提供FGC的功能。

如果 Mixed GC 仍然效果不理想,跟不上新物件分配記憶體的需求,會使用 Serial Old GC 進行 Full GC強制收集整個 Heap。

相比CMS,G1總結有以下優點:

  • G1運作期間不會產生記憶體空間碎片,垃圾收集完成之後能提供規整的可用記憶體。這種特性有利於程式長時間執行。
  • G1 能預測 GC 停頓時間, STW 時間可控(G1 uses a pause prediction model to meet a user-defined pause time target and selects the number of regions to collect based on the specified pause time target.)
關於G1實際上還有很多的細節可以講,這裡希望讀者去閱讀《深入理解Java虛擬機器》或者其他資料來延伸學習,查漏補缺。

相關引數:

引數

作用

-XX:+UseG1GC

採用 G1 收集器

-XX:G1HeapRegionSize

每個Region的大小

更多的引數和調優參考詳見:分析和效能來調整和調優 G1 GC

後記

本系列關於JVM 垃圾回收的知識就到這裡了。

因為篇幅的關係,也受限於能力水平,本文很多細節沒有涉及到,只能算是為學習JVM的同學開啟了一扇的門(一扇和平常看到的文章相比要大那麼一點點的門,寫了這麼久允許我自戀一下吧??)。希望不過癮的同學能自己更加深入的學習。

相關文章