JVM Garbage First(G1) 垃圾收集器
前言
Garbage First(G1)是垃圾收集領域的最新成果,同時也是HotSpot在JVM上力推的垃圾收集器,並賦予取代CMS的使命。如果使用Java 8/9,那麼有很大可能希望對G1收集器進行評估。本文詳細首先對JVM其他的垃圾收集器進行總結,並與G1進行了簡單的對比;然後通過G1的記憶體模型、G1的活動週期,對G1的工作機制進行了介紹;同時還在介紹過程中,描述了可能需要引起注意的優化點。筆者希望通過本文,讓有一定JVM基礎的讀者能儘快掌握G1的知識點。
第一章 概述
G1(Garbage First)垃圾收集器是當今垃圾回收技術最前沿的成果之一。早在JDK7就已加入JVM的收集器大家庭中,成為HotSpot重點發展的垃圾回收技術。同優秀的CMS垃圾回收器一樣,G1也是關注最小時延的垃圾回收器,也同樣適合大尺寸堆記憶體的垃圾收集,官方也推薦使用G1來代替選擇CMS。G1最大的特點是引入分割槽的思路,弱化了分代的概念,合理利用垃圾收集各個週期的資源,解決了其他收集器甚至CMS的眾多缺陷。
第二章 JVM GC收集器的回顧與比較
從JDK3(1.3)開始,HotSpot團隊一直努力朝著高效收集、減少停頓(STW: Stop The World)的方向努力,也貢獻了從序列到CMS乃至最新的G1在內的一系列優秀的垃圾收集器。上圖展示了JDK的垃圾回收大家庭,以及相互之間的組合關係,下面就幾種典型的組合應用進行簡單的介紹。
序列收集器
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-VllqSnjk-1600416354690)(https://c1.staticflickr.com/5/4603/28345836579_8dff90eb76_z.jpg)]
序列收集器組合 Serial + Serial Old
開啟選項:
-XX:+SerialGC
序列收集器是最基本、發展時間最長、久經考驗的垃圾收集器,也是client模式下的預設收集器配置。
序列收集器採用單執行緒stop-the-world的方式進行收集。當記憶體不足時,序列GC設定停頓標識,待所有執行緒都進入安全點(Safepoint)時,應用執行緒暫停,序列GC開始工作,採用單執行緒方式回收空間並整理記憶體。單執行緒也意味著複雜度更低、佔用記憶體更少,但同時也意味著不能有效利用多核優勢。事實上,序列收集器特別適合堆記憶體不高、單核甚至雙核CPU的場合。
並行收集器
並行收集器組合 Parallel Scavenge + Parallel Old
開啟選項:
-XX:+UseParallelGC
或-XX:+UseParallelOldGC
(可互相啟用)
並行收集器是以關注吞吐量為目標的垃圾收集器,也是server模式下的預設收集器配置,對吞吐量的關注主要體現在年輕代Parallel Scavenge收集器上。
並行收集器與序列收集器工作模式相似,都是stop-the-world方式,只是暫停時並行地進行垃圾收集。年輕代採用複製演算法,老年代採用標記-整理,在回收的同時還會對記憶體進行壓縮。關注吞吐量主要指年輕代的Parallel Scavenge收集器,通過兩個目標引數-XX:MaxGCPauseMills
和-XX:GCTimeRatio
,調整新生代空間大小,來降低GC觸發的頻率。並行收集器適合對吞吐量要求遠遠高於延遲要求的場景,並且在滿足最差延時的情況下,並行收集器將提供最佳的吞吐量。
併發標記清除收集器
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-LeSnO6cw-1600416354703)(https://c1.staticflickr.com/5/4740/40093687062_7383cd1b49_z.jpg)]
併發標記清除收集器組合 ParNew + CMS + Serial Old
開啟選項:
-XX:+UseConcMarkSweepGC
併發標記清除(CMS)是以關注延遲為目標、十分優秀的垃圾回收演算法,開啟後,年輕代使用STW式的並行收集,老年代回收採用CMS進行垃圾回收,對延遲的關注也主要體現在老年代CMS上。
年輕代ParNew與並行收集器類似,而老年代CMS每個收集週期都要經歷:初始標記、併發標記、重新標記、併發清除。其中,初始標記以STW的方式標記所有的根物件;併發標記則同應用執行緒一起並行,標記出根物件的可達路徑;在進行垃圾回收前,CMS再以一個STW進行重新標記,標記那些由mutator執行緒(指引起資料變化的執行緒,即應用執行緒)修改而可能錯過的可達物件;最後得到的不可達物件將在併發清除階段進行回收。值得注意的是,初始標記和重新標記都已優化為多執行緒執行。CMS非常適合堆記憶體大、CPU核數多的伺服器端應用,也是G1出現之前大型應用的首選收集器。
但是CMS並不完美,它有以下缺點:
- 由於併發進行,CMS在收集與應用執行緒會同時會增加對堆記憶體的佔用,也就是說,CMS必須要在老年代堆記憶體用盡之前完成垃圾回收,否則CMS回收失敗時,將觸發擔保機制,序列老年代收集器將會以STW的方式進行一次GC,從而造成較大停頓時間;
- 標記清除演算法無法整理空間碎片,老年代空間會隨著應用時長被逐步耗盡,最後將不得不通過擔保機制對堆記憶體進行壓縮。CMS也提供了引數
-XX:CMSFullGCsBeForeCompaction
(預設0,即每次都進行記憶體整理)來指定多少次CMS收集之後,進行一次壓縮的Full GC。
Garbage First
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-s3YrrBdC-1600416354705)(https://c1.staticflickr.com/5/4655/28345836209_7b70465317_b.jpg)]
Garbage First (G1)
開啟選項:
-XX:+UseG1GC
之前介紹的幾組垃圾收集器組合,都有幾個共同點:
- 年輕代、老年代是獨立且連續的記憶體塊;
- 年輕代收集使用單eden、雙survivor進行復制演算法;
- 老年代收集必須掃描整個老年代區域;
- 都是以儘可能少而塊地執行GC為設計原則。
G1垃圾收集器也是以關注延遲為目標、伺服器端應用的垃圾收集器,被HotSpot團隊寄予取代CMS的使命,也是一個非常具有調優潛力的垃圾收集器。雖然G1也有類似CMS的收集動作:初始標記、併發標記、重新標記、清除、轉移回收,並且也以一個序列收集器做擔保機制,但單純地以類似前三種的過程描述顯得並不是很妥當。事實上,G1收集與以上三組收集器有很大不同:
- G1的設計原則是"首先收集儘可能多的垃圾(Garbage First)"。因此,G1並不會等記憶體耗盡(序列、並行)或者快耗盡(CMS)的時候開始垃圾收集,而是在內部採用了啟發式演算法,在老年代找出具有高收集收益的分割槽進行收集。同時G1可以根據使用者設定的暫停時間目標自動調整年輕代和總堆大小,暫停目標越短年輕代空間越小、總空間就越大;
- G1採用記憶體分割槽(Region)的思路,將記憶體劃分為一個個相等大小的記憶體分割槽,回收時則以分割槽為單位進行回收,存活的物件複製到另一個空閒分割槽中。由於都是以相等大小的分割槽為單位進行操作,因此G1天然就是一種壓縮方案(區域性壓縮);
- G1雖然也是分代收集器,但整個記憶體分割槽不存在物理上的年輕代與老年代的區別,也不需要完全獨立的survivor(to space)堆做複製準備。G1只有邏輯上的分代概念,或者說每個分割槽都可能隨G1的執行在不同代之間前後切換;
- G1的收集都是STW的,但年輕代和老年代的收集界限比較模糊,採用了混合(mixed)收集的方式。即每次收集既可能只收集年輕代分割槽(年輕代收集),也可能在收集年輕代的同時,包含部分老年代分割槽(混合收集),這樣即使堆記憶體很大時,也可以限制收集範圍,從而降低停頓。
第三章 G1的記憶體模型
分割槽概念
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-wqGru6eQ-1600416354708)(https://c1.staticflickr.com/5/4678/40093686972_25da5b859f_z.jpg)]
分割槽
分割槽 Region
G1採用了分割槽(Region)的思路,將整個堆空間分成若干個大小相等的記憶體區域,每次分配物件空間將逐段地使用記憶體。因此,在堆的使用上,G1並不要求物件的儲存一定是物理上連續的,只要邏輯上連續即可;每個分割槽也不會確定地為某個代服務,可以按需在年輕代和老年代之間切換。啟動時可以通過引數-XX:G1HeapRegionSize=n
可指定分割槽大小(1MB~32MB,且必須是2的冪),預設將整堆劃分為2048個分割槽。
卡片
卡片 Card
在每個分割槽內部又被分成了若干個大小為512 Byte卡片(Card),標識堆記憶體最小可用粒度所有分割槽的卡片將會記錄在全域性卡片表(Global Card Table)中,分配的物件會佔用物理上連續的若干個卡片,當查詢對分割槽內物件的引用時便可通過記錄卡片來查詢該引用物件(見RSet)。每次對記憶體的回收,都是對指定分割槽的卡片進行處理。
堆
堆 Heap
G1同樣可以通過-Xms
/-Xmx
來指定堆空間大小。當發生年輕代收集或混合收集時,通過計算GC與應用的耗費時間比,自動調整堆空間大小。如果GC頻率太高,則通過增加堆尺寸,來減少GC頻率,相應地GC佔用的時間也隨之降低;目標引數-XX:GCTimeRatio
即為GC與應用的耗費時間比,G1預設為9,而CMS預設為99,因為CMS的設計原則是耗費在GC上的時間儘可能的少。另外,當空間不足,如物件空間分配或轉移失敗時,G1會首先嚐試增加堆空間,如果擴容失敗,則發起擔保的Full GC。Full GC後,堆尺寸計算結果也會調整堆空間。
分代模型
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-JNrQ7ADx-1600416354711)(https://c1.staticflickr.com/5/4621/28345836009_65a54854be_z.jpg)]
分代
分代 Generation
分代垃圾收集可以將關注點集中在最近被分配的物件上,而無需整堆掃描,避免長命物件的拷貝,同時獨立收集有助於降低響應時間。雖然分割槽使得記憶體分配不再要求緊湊的記憶體空間,但G1依然使用了分代的思想。與其他垃圾收集器類似,G1將記憶體在邏輯上劃分為年輕代和老年代,其中年輕代又劃分為Eden空間和Survivor空間。但年輕代空間並不是固定不變的,當現有年輕代分割槽佔滿時,JVM會分配新的空閒分割槽加入到年輕代空間。
整個年輕代記憶體會在初始空間-XX:G1NewSizePercent
(預設整堆5%)與最大空間-XX:G1MaxNewSizePercent
(預設60%)之間動態變化,且由引數目標暫停時間-XX:MaxGCPauseMillis
(預設200ms)、需要擴縮容的大小以及分割槽的已記憶集合(RSet)計算得到。當然,G1依然可以設定固定的年輕代大小(引數-XX:NewRatio
、-Xmn
),但同時暫停目標將失去意義。
本地分配緩衝
本地分配緩衝 Local allocation buffer (Lab)
值得注意的是,由於分割槽的思想,每個執行緒均可以"認領"某個分割槽用於執行緒本地的記憶體分配,而不需要顧及分割槽是否連續。因此,每個應用執行緒和GC執行緒都會獨立的使用分割槽,進而減少同步時間,提升GC效率,這個分割槽稱為本地分配緩衝區(Lab)。
其中,應用執行緒可以獨佔一個本地緩衝區(TLAB)來建立的物件,而大部分都會落入Eden區域(巨型物件或分配失敗除外),因此TLAB的分割槽屬於Eden空間;而每次垃圾收集時,每個GC執行緒同樣可以獨佔一個本地緩衝區(GCLAB)用來轉移物件,每次回收會將物件複製到Suvivor空間或老年代空間;對於從Eden/Survivor空間晉升(Promotion)到Survivor/老年代空間的物件,同樣有GC獨佔的本地緩衝區進行操作,該部分稱為晉升本地緩衝區(PLAB)。
分割槽模型
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-RLElAZ8f-1600416354719)(https://c1.staticflickr.com/5/4619/40093686872_ce639c1121_z.jpg)]
G1對記憶體的使用以分割槽(Region)為單位,而對物件的分配則以卡片(Card)為單位。
巨型物件
巨型物件 Humongous Region
一個大小達到甚至超過分割槽大小一半的物件稱為巨型物件(Humongous Object)。當執行緒為巨型分配空間時,不能簡單在TLAB進行分配,因為巨型物件的移動成本很高,而且有可能一個分割槽不能容納巨型物件。因此,巨型物件會直接在老年代分配,所佔用的連續空間稱為巨型分割槽(Humongous Region)。G1內部做了一個優化,一旦發現沒有引用指向巨型物件,則可直接在年輕代收集週期中被回收。
巨型物件會獨佔一個、或多個連續分割槽,其中第一個分割槽被標記為開始巨型(StartsHumongous),相鄰連續分割槽被標記為連續巨型(ContinuesHumongous)。由於無法享受Lab帶來的優化,並且確定一片連續的記憶體空間需要掃描整堆,因此確定巨型物件開始位置的成本非常高,如果可以,應用程式應避免生成巨型物件。
已記憶集合
已記憶集合 Remember Set (RSet)
在序列和並行收集器中,GC通過整堆掃描,來確定物件是否處於可達路徑中。然而G1為了避免STW式的整堆掃描,在每個分割槽記錄了一個已記憶集合(RSet),內部類似一個反向指標,記錄引用分割槽內物件的卡片索引。當要回收該分割槽時,通過掃描分割槽的RSet,來確定引用本分割槽內的物件是否存活,進而確定本分割槽內的物件存活情況。
事實上,並非所有的引用都需要記錄在RSet中,如果一個分割槽確定需要掃描,那麼無需RSet也可以無遺漏的得到引用關係。那麼引用源自本分割槽的物件,當然不用落入RSet中;同時,G1 GC每次都會對年輕代進行整體收集,因此引用源自年輕代的物件,也不需要在RSet中記錄。最後只有老年代的分割槽可能會有RSet記錄,這些分割槽稱為擁有RSet分割槽(an RSet’s owning region)。
Per Region Table
Per Region Table (PRT)
RSet在內部使用Per Region Table(PRT)記錄分割槽的引用情況。由於RSet的記錄要佔用分割槽的空間,如果一個分割槽非常"受歡迎",那麼RSet佔用的空間會上升,從而降低分割槽的可用空間。G1應對這個問題採用了改變RSet的密度的方式,在PRT中將會以三種模式記錄引用:
- 稀少:直接記錄引用物件的卡片索引
- 細粒度:記錄引用物件的分割槽索引
- 粗粒度:只記錄引用情況,每個分割槽對應一個位元位
由上可知,粗粒度的PRT只是記錄了引用數量,需要通過整堆掃描才能找出所有引用,因此掃描速度也是最慢的。
收集集合 (CSet)
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-2WcZKCzT-1600416354721)(https://c1.staticflickr.com/5/4766/40126693251_d74183c2c6.jpg)]
收集集合 CSet
收集集合(CSet)代表每次GC暫停時回收的一系列目標分割槽。在任意一次收集暫停中,CSet所有分割槽都會被釋放,內部存活的物件都會被轉移到分配的空閒分割槽中。因此無論是年輕代收集,還是混合收集,工作的機制都是一致的。年輕代收集CSet只容納年輕代分割槽,而混合收集會通過啟發式演算法,在老年代候選回收分割槽中,篩選出回收收益最高的分割槽新增到CSet中。
候選老年代分割槽的CSet准入條件,可以通過活躍度閾值-XX:G1MixedGCLiveThresholdPercent
(預設85%)進行設定,從而攔截那些回收開銷巨大的物件;同時,每次混合收集可以包含候選老年代分割槽,可根據CSet對堆的總大小佔比-XX:G1OldCSetRegionThresholdPercent
(預設10%)設定數量上限。
由上述可知,G1的收集都是根據CSet進行操作的,年輕代收集與混合收集沒有明顯的不同,最大的區別在於兩種收集的觸發條件。
年輕代收集集合
年輕代收集集合 CSet of Young Collection
應用執行緒不斷活動後,年輕代空間會被逐漸填滿。當JVM分配物件到Eden區域失敗(Eden區已滿)時,便會觸發一次STW式的年輕代收集。在年輕代收集中,Eden分割槽存活的物件將被拷貝到Survivor分割槽;原有Survivor分割槽存活的物件,將根據任期閾值(tenuring threshold)分別晉升到PLAB中,新的survivor分割槽和老年代分割槽。而原有的年輕代分割槽將被整體回收掉。
同時,年輕代收集還負責維護物件的年齡(存活次數),輔助判斷老化(tenuring)物件晉升的時候是到Survivor分割槽還是到老年代分割槽。年輕代收集首先先將晉升物件尺寸總和、物件年齡資訊維護到年齡表中,再根據年齡表、Survivor尺寸、Survivor填充容量-XX:TargetSurvivorRatio
(預設50%)、最大任期閾值-XX:MaxTenuringThreshold
(預設15),計算出一個恰當的任期閾值,凡是超過任期閾值的物件都會被晉升到老年代。
混合收集集合
混合收集集合 CSet of Mixed Collection
年輕代收集不斷活動後,老年代的空間也會被逐漸填充。當老年代佔用空間超過整堆比IHOP閾值-XX:InitiatingHeapOccupancyPercent
(預設45%)時,G1就會啟動一次混合垃圾收集週期。為了滿足暫停目標,G1可能不能一口氣將所有的候選分割槽收集掉,因此G1可能會產生連續多次的混合收集與應用執行緒交替執行,每次STW的混合收集與年輕代收集過程相類似。
為了確定包含到年輕代收集集合CSet的老年代分割槽,JVM通過引數混合週期的最大總次數-XX:G1MixedGCCountTarget
(預設8)、堆廢物百分比-XX:G1HeapWastePercent
(預設5%)。通過候選老年代分割槽總數與混合週期最大總次數,確定每次包含到CSet的最小分割槽數量;根據堆廢物百分比,當收集達到引數時,不再啟動新的混合收集。而每次新增到CSet的分割槽,則通過計算得到的GC效率進行安排。
第四章 G1的活動週期
G1垃圾收集活動彙總
祭出一張總圖
RSet的維護
由於不能整堆掃描,又需要計算分割槽確切的活躍度,因此,G1需要一個增量式的完全標記併發演算法,通過維護RSet,得到準確的分割槽引用資訊。在G1中,RSet的維護主要來源兩個方面:寫柵欄(Write Barrier)和併發優化執行緒(Concurrence Refinement Threads)
柵欄
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-GmvZOTP3-1600416354724)(https://c1.staticflickr.com/5/4658/40093686702_9d689b29e9_z.jpg)]
柵欄 Barrier
我們首先介紹一下柵欄(Barrier)的概念。柵欄是指在原生程式碼片段中,當某些語句被執行時,柵欄程式碼也會被執行。而G1主要在賦值語句中,使用寫前柵欄(Pre-Write Barrrier)和寫後柵欄(Post-Write Barrrier)。事實上,寫柵欄的指令序列開銷非常昂貴,應用吞吐量也會根據柵欄複雜度而降低。
寫前柵欄 Pre-Write Barrrier
即將執行一段賦值語句時,等式左側物件將修改引用到另一個物件,那麼等式左側物件原先引用的物件所在分割槽將因此喪失一個引用,那麼JVM就需要在賦值語句生效之前,記錄喪失引用的物件。JVM並不會立即維護RSet,而是通過批量處理,在將來RSet更新(見SATB)。
寫後柵欄 Post-Write Barrrier
當執行一段賦值語句後,等式右側物件獲取了左側物件的引用,那麼等式右側物件所在分割槽的RSet也應該得到更新。同樣為了降低開銷,寫後柵欄發生後,RSet也不會立即更新,同樣只是記錄此次更新日誌,在將來批量處理(見Concurrence Refinement Threads)。
起始快照演算法
起始快照演算法 Snapshot at the beginning (SATB)
Taiichi Tuasa貢獻的增量式完全併發標記演算法起始快照演算法(SATB),主要針對標記-清除垃圾收集器的併發標記階段,非常適合G1的分割槽塊的堆結構,同時解決了CMS的主要煩惱:重新標記暫停時間長帶來的潛在風險。
SATB會建立一個物件圖,相當於堆的邏輯快照,從而確保併發標記階段所有的垃圾物件都能通過快照被鑑別出來。當賦值語句發生時,應用將會改變了它的物件圖,那麼JVM需要記錄被覆蓋的物件。因此寫前柵欄會在引用變更前,將值記錄在SATB日誌或緩衝區中。每個執行緒都會獨佔一個SATB緩衝區,初始有256條記錄空間。當空間用盡時,執行緒會分配新的SATB緩衝區繼續使用,而原有的緩衝去則加入全域性列表中。最終在併發標記階段,併發標記執行緒(Concurrent Marking Threads)在標記的同時,還會定期檢查和處理全域性緩衝區列表的記錄,然後根據標記點陣圖分片的標記位,掃描引用欄位來更新RSet。此過程又稱為併發標記/SATB寫前柵欄。
併發優化執行緒
併發優化執行緒 Concurrence Refinement Threads
G1中使用基於Urs Hölzle的快速寫柵欄,將柵欄開銷縮減到2個額外的指令。柵欄將會更新一個card table type的結構來跟蹤代間引用。
當賦值語句發生後,寫後柵欄會先通過G1的過濾技術判斷是否是跨分割槽的引用更新,並將跨分割槽更新物件的卡片加入緩衝區序列,即更新日誌緩衝區或髒卡片佇列。與SATB類似,一旦日誌緩衝區用盡,則分配一個新的日誌緩衝區,並將原來的緩衝區加入全域性列表中。
併發優化執行緒(Concurrence Refinement Threads),只專注掃描日誌緩衝區記錄的卡片來維護更新RSet,執行緒最大數目可通過-XX:G1ConcRefinementThreads
(預設等於-XX:ParellelGCThreads
)設定。併發優化執行緒永遠是活躍的,一旦發現全域性列表有記錄存在,就開始併發處理。如果記錄增長很快或者來不及處理,那麼通過閾值-X:G1ConcRefinementGreenZone
/-XX:G1ConcRefinementYellowZone
/-XX:G1ConcRefinementRedZone
,G1會用分層的方式排程,使更多的執行緒處理全域性列表。如果併發優化執行緒也不能跟上緩衝區數量,則Mutator執行緒(Java應用執行緒)會掛起應用並被加進來幫助處理,直到全部處理完。因此,必須避免此類場景出現。
併發標記週期
併發標記週期 Concurrent Marking Cycle
併發標記週期是G1中非常重要的階段,這個階段將會為混合收集週期識別垃圾最多的老年代分割槽。整個週期完成根標記、識別所有(可能)存活物件,並計算每個分割槽的活躍度,從而確定GC效率等級。
當達到IHOP閾值-XX:InitiatingHeapOccupancyPercent
(老年代佔整堆比,預設45%)時,便會觸發併發標記週期。整個併發標記週期將由初始標記(Initial Mark)、根分割槽掃描(Root Region Scanning)、併發標記(Concurrent Marking)、重新標記(Remark)、清除(Cleanup)幾個階段組成。其中,初始標記(隨年輕代收集一起活動)、重新標記、清除是STW的,而併發標記如果來不及標記存活物件,則可能在併發標記過程中,G1又觸發了幾次年輕代收集。
併發標記執行緒
併發標記執行緒 Concurrent Marking Threads
要標記存活的物件,每個分割槽都需要建立點陣圖(Bitmap)資訊來儲存標記資料,來確定標記週期內被分配的物件。G1採用了兩個點陣圖Previous Bitmap、Next Bitmap,來儲存標記資料,Previous點陣圖儲存上次的標記資料,Next點陣圖在標記週期內不斷變化更新,同時Previous點陣圖的標記資料也越來越過時,當標記週期結束後Next點陣圖便替換Previous點陣圖,成為上次標記的點陣圖。同時,每個分割槽通過頂部開始標記(TAMS),來記錄已標記過的記憶體範圍。同樣的,G1使用了兩個頂部開始標記Previous TAMS(PTAMS)、Next TAMS(NTAMS),記錄已標記的範圍。
在併發標記階段,G1會根據引數-XX:ConcGCThreads
(預設GC執行緒數的1/4,即-XX:ParallelGCThreads
/4),分配併發標記執行緒(Concurrent Marking Threads),進行標記活動。每個併發執行緒一次只掃描一個分割槽,並通過"手指"指標的方式優化獲取分割槽。併發標記執行緒是爆發式的,在給定的時間段拼命幹活,然後休息一段時間,再拼命幹活。
每個併發標記週期,在初始標記STW的最後,G1會分配一個空的Next點陣圖和一個指向分割槽頂部(Top)的NTAMS標記。Previous點陣圖記錄的上次標記資料,上次的標記位置,即PTAMS,在PTAMS與分割槽底部(Bottom)的範圍內,所有的存活物件都已被標記。那麼,在PTAMS與Top之間的物件都將是隱式存活(Implicitly Live)物件。在併發標記階段,Next點陣圖吸收了Previous點陣圖的標記資料,同時每個分割槽都會有新的物件分配,則Top與NTAMS分離,前往更高的地址空間。在併發標記的一次標記中,併發標記執行緒將找出NTAMS與PTAMS之間的所有存活物件,將標記資料儲存在Next點陣圖中。同時,在NTAMS與Top之間的物件即成為已標記物件。如此不斷地更新Next點陣圖資訊,並在清除階段與Previous點陣圖交換角色。
初始標記
初始標記 Initial Mark
初始標記(Initial Mark)負責標記所有能被直接可達的根物件(原生棧物件、全域性物件、JNI物件),根是物件圖的起點,因此初始標記需要將Mutator執行緒(Java應用執行緒)暫停掉,也就是需要一個STW的時間段。事實上,當達到IHOP閾值時,G1並不會立即發起併發標記週期,而是等待下一次年輕代收集,利用年輕代收集的STW時間段,完成初始標記,這種方式稱為借道(Piggybacking)。在初始標記暫停中,分割槽的NTAMS都被設定到分割槽頂部Top,初始標記是併發執行,直到所有的分割槽處理完。
根分割槽掃描
根分割槽掃描 Root Region Scanning
在初始標記暫停結束後,年輕代收集也完成的物件複製到Survivor的工作,應用執行緒開始活躍起來。此時為了保證標記演算法的正確性,所有新複製到Survivor分割槽的物件,都需要被掃描並標記成根,這個過程稱為根分割槽掃描(Root Region Scanning),同時掃描的Suvivor分割槽也被稱為根分割槽(Root Region)。根分割槽掃描必須在下一次年輕代垃圾收集啟動前完成(併發標記的過程中,可能會被若干次年輕代垃圾收集打斷),因為每次GC會產生新的存活物件集合。
併發標記
併發標記 Concurrent Marking
和應用執行緒併發執行,併發標記執行緒在併發標記階段啟動,由引數-XX:ConcGCThreads
(預設GC執行緒數的1/4,即-XX:ParallelGCThreads
/4)控制啟動數量,每個執行緒每次只掃描一個分割槽,從而標記出存活物件圖。在這一階段會處理Previous/Next標記點陣圖,掃描標記物件的引用欄位。同時,併發標記執行緒還會定期檢查和處理STAB全域性緩衝區列表的記錄,更新物件引用資訊。引數-XX:+ClassUnloadingWithConcurrentMark
會開啟一個優化,如果一個類不可達(不是物件不可達),則在重新標記階段,這個類就會被直接解除安裝。所有的標記任務必須在堆滿前就完成掃描,如果併發標記耗時很長,那麼有可能在併發標記過程中,又經歷了幾次年輕代收集。如果堆滿前沒有完成標記任務,則會觸發擔保機制,經歷一次長時間的序列Full GC。
存活資料計算
存活資料計算 Live Data Accounting
存活資料計算(Live Data Accounting)是標記操作的附加產物,只要一個物件被標記,同時會被計算位元組數,並計入分割槽空間。只有NTAMS以下的物件會被標記和計算,在標記週期的最後,Next點陣圖將被清空,等待下次標記週期。
重新標記
重新標記 Remark
重新標記(Remark)是最後一個標記階段。在該階段中,G1需要一個暫停的時間,去處理剩下的SATB日誌緩衝區和所有更新,找出所有未被訪問的存活物件,同時安全完成存活資料計算。這個階段也是並行執行的,通過引數-XX:ParallelGCThread
可設定GC暫停時可用的GC執行緒數。同時,引用處理也是重新標記階段的一部分,所有重度使用引用物件(弱引用、軟引用、虛引用、最終引用)的應用都會在引用處理上產生開銷。
清除
清除 Cleanup
緊挨著重新標記階段的清除(Clean)階段也是STW的。Previous/Next標記點陣圖、以及PTAMS/NTAMS,都會在清除階段交換角色。清除階段主要執行以下操作:
- RSet梳理,啟發式演算法會根據活躍度和RSet尺寸對分割槽定義不同等級,同時RSet數理也有助於發現無用的引用。引數
-XX:+PrintAdaptiveSizePolicy
可以開啟列印啟發式演算法決策細節; - 整理堆分割槽,為混合收集週期識別回收收益高(基於釋放空間和暫停目標)的老年代分割槽集合;
- 識別所有空閒分割槽,即發現無存活物件的分割槽。該分割槽可在清除階段直接回收,無需等待下次收集週期。
年輕代收集/混合收集週期
年輕代收集和混合收集週期,是G1回收空間的主要活動。當應用執行開始時,堆記憶體可用空間還比較大,只會在年輕代滿時,觸發年輕代收集;隨著老年代記憶體增長,當到達IHOP閾值-XX:InitiatingHeapOccupancyPercent
(老年代佔整堆比,預設45%)時,G1開始著手準備收集老年代空間。首先經歷併發標記週期,識別出高收益的老年代分割槽,前文已述。但隨後G1並不會馬上開始一次混合收集,而是讓應用執行緒先執行一段時間,等待觸發一次年輕代收集。在這次STW中,G1將保準整理混合收集週期。接著再次讓應用執行緒執行,當接下來的幾次年輕代收集時,將會有老年代分割槽加入到CSet中,即觸發混合收集,這些連續多次的混合收集稱為混合收集週期(Mixed Collection Cycle)。
GC工作執行緒數
GC工作執行緒數 -XX:ParallelGCThreads
JVM可以通過引數-XX:ParallelGCThreads
進行指定GC工作的執行緒數量。引數-XX:ParallelGCThreads
預設值並不是固定的,而是根據當前的CPU資源進行計算。如果使用者沒有指定,且CPU小於等於8,則預設與CPU核數相等;若CPU大於8,則預設JVM會經過計算得到一個小於CPU核數的執行緒數;當然也可以人工指定與CPU核數相等。
年輕代收集
年輕代收集 Young Collection
每次收集過程中,既有並行執行的活動,也有序列執行的活動,但都可以是多執行緒的。在並行執行的任務中,如果某個任務過重,會導致其他執行緒在等待某項任務的處理,需要對這些地方進行優化。
並行活動
外部根分割槽掃描 Ext Root Scanning:此活動對堆外的根(JVM系統目錄、VM資料結構、JNI執行緒控制代碼、硬體暫存器、全域性變數、執行緒對棧根)進行掃描,發現那些沒有加入到暫停收集集合CSet中的物件。如果系統目錄(單根)擁有大量載入的類,最終可能其他並行活動結束後,該活動依然沒有結束而帶來的等待時間。
更新已記憶集合 Update RS:併發優化執行緒會對髒卡片的分割槽進行掃描更新日誌緩衝區來更新RSet,但只會處理全域性緩衝列表。作為補充,所有被記錄但是還沒有被優化執行緒處理的剩餘緩衝區,會在該階段處理,變成已處理緩衝區(Processed Buffers)。為了限制花在更新RSet的時間,可以設定暫停佔用百分比-XX:G1RSetUpdatingPauseTimePercent
(預設10%,即-XX:MaxGCPauseMills
/10)。值得注意的是,如果更新日誌緩衝區更新的任務不降低,單純地減少RSet的更新時間,會導致暫停中被處理的緩衝區減少,將日誌緩衝區更新工作推到併發優化執行緒上,從而增加對Java應用執行緒資源的爭奪。
RSet掃描 Scan RS:在收集當前CSet之前,考慮到分割槽外的引用,必須掃描CSet分割槽的RSet。如果RSet發生粗化,則會增加RSet的掃描時間。開啟診斷模式-XX:UnlockDiagnosticVMOptions
後,通過引數-XX:+G1SummarizeRSetStats
可以確定併發優化執行緒是否能夠及時處理更新日誌緩衝區,並提供更多的資訊,來幫助為RSet粗化總數提供視窗。引數-XX:G1SummarizeRSetStatsPeriod=n
可設定RSet的統計週期,即經歷多少此GC後進行一次統計
程式碼根掃描 Code Root Scanning:對程式碼根集合進行掃描,掃描JVM編譯後代碼Native Method的引用資訊(nmethod掃描),進行RSet掃描。事實上,只有CSet分割槽中的RSet有強程式碼根時,才會做nmethod掃描,查詢對CSet的引用。
轉移和回收 Object Copy:通過選定的CSet以及CSet分割槽完整的引用集,將執行暫停時間的主要部分:CSet分割槽存活物件的轉移、CSet分割槽空間的回收。通過工作竊取機制來負載均衡地選定複製物件的執行緒,並且複製和掃描物件被轉移的存活物件將拷貝到每個GC執行緒分配緩衝區GCLAB。G1會通過計算,預測分割槽複製所花費的時間,從而調整年輕代的尺寸。
終止 Termination:完成上述任務後,如果任務佇列已空,則工作執行緒會發起終止要求。如果還有其他執行緒繼續工作,空閒的執行緒會通過工作竊取機制嘗試幫助其他執行緒處理。而單獨執行根分割槽掃描的執行緒,如果任務過重,最終會晚於終止。
GC外部的並行活動 GC Worker Other:該部分並非GC的活動,而是JVM的活動導致佔用了GC暫停時間(例如JNI編譯)。
序列活動
程式碼根更新 Code Root Fixup:根據轉移物件更新程式碼根。
程式碼根清理 Code Root Purge:清理程式碼根集合表。
清除全域性卡片標記 Clear CT:在任意收集週期會掃描CSet與RSet記錄的PRT,掃描時會在全域性卡片表中進行標記,防止重複掃描。在收集週期的最後將會清除全域性卡片表中的已掃描標誌。
選擇下次收集集合 Choose CSet:該部分主要用於併發標記週期後的年輕代收集、以及混合收集中,在這些收集過程中,由於有老年代候選分割槽的加入,往往需要對下次收集的範圍做出界定;但單純的年輕代收集中,所有收集的分割槽都會被收集,不存在選擇。
引用處理 Ref Proc:主要針對軟引用、弱引用、虛引用、final引用、JNI引用。當Ref Proc佔用時間過多時,可選擇使用引數-XX:ParallelRefProcEnabled
啟用多執行緒引用處理。G1希望應用能小心使用軟引用,因為軟引用會一直佔據記憶體空間直到空間耗盡時被Full GC回收掉;即使未發生Full GC,軟引用對記憶體的佔用,也會導致GC次數的增加。
引用排隊 Ref Enq:此項活動可能會導致RSet的更新,此時會通過記錄日誌,將關聯的卡片標記為髒卡片。
卡片重新髒化 Redirty Cards:重新髒化卡片。
回收空閒巨型分割槽 Humongous Reclaim:G1做了一個優化:通過檢視所有根物件以及年輕代分割槽的RSet,如果確定RSet中巨型物件沒有任何引用,則說明G1發現了一個不可達的巨型物件,該物件分割槽會被回收。
釋放分割槽 Free CSet:回收CSet分割槽的所有空間,並加入到空閒分割槽中。
其他活動 Other:GC中可能還會經歷其他耗時很小的活動,如修復JNI控制代碼等。
併發標記週期後的年輕代收集
併發標記週期後的年輕代收集 Young Collection Following Concurrent Marking Cycle
當G1發起併發標記週期之後,並不會馬上開始混合收集。G1會先等待下一次年輕代收集,然後在該收集階段中,確定下次混合收集的CSet(Choose CSet)。
混合收集週期
混合收集週期 Mixed Collection Cycle
單次的混合收集與年輕代收集並無二致。根據暫停目標,老年代的分割槽可能不能一次暫停收集中被處理完,G1會發起連續多次的混合收集,稱為混合收集週期(Mixed Collection Cycle)。G1會計算每次加入到CSet中的分割槽數量、混合收集進行次數,並且在上次的年輕代收集、以及接下來的混合收集中,G1會確定下次加入CSet的分割槽集(Choose CSet),並且確定是否結束混合收集週期。
轉移失敗的擔保機制 Full GC
轉移失敗的擔保機制 Full GC
轉移失敗(Evacuation Failure)是指當G1無法在堆空間中申請新的分割槽時,G1便會觸發擔保機制,執行一次STW式的、單執行緒的Full GC。Full GC會對整堆做標記清除和壓縮,最後將只包含純粹的存活物件。引數-XX:G1ReservePercent
(預設10%)可以保留空間,來應對晉升模式下的異常情況,最大佔用整堆50%,更大也無意義。
G1在以下場景中會觸發Full GC,同時會在日誌中記錄to-space-exhausted以及Evacuation Failure:
- 從年輕代分割槽拷貝存活物件時,無法找到可用的空閒分割槽
- 從老年代分割槽轉移存活物件時,無法找到可用的空閒分割槽
- 分配巨型物件時在老年代無法找到足夠的連續分割槽
由於G1的應用場合往往堆記憶體都比較大,所以Full GC的收集代價非常昂貴,應該避免Full GC的發生。
第五章 總結
G1是一款非常優秀的垃圾收集器,不僅適合堆記憶體大的應用,同時也簡化了調優的工作。通過主要的引數初始和最大堆空間、以及最大容忍的GC暫停目標,就能得到不錯的效能;同時,我們也看到G1對記憶體空間的浪費較高,但通過**首先收集儘可能多的垃圾(Garbage First)**的設計原則,可以及時發現過期物件,從而讓記憶體佔用處於合理的水平。
參考資料
[1] Charlie H, Monica B, Poonam P, Bengt R. Java Performance Companion
[2] 周志明. 深入理解JVM虛擬機器