深入學(xué)習(xí)JVM(三) -- JVM垃圾收集-G1

G1 (Garbage First)垃圾收集器

因為上一篇文章JVM垃圾收集器總結(jié)的篇幅太長,同時G1也非常的重要篇幅也不會短,所以這里另開一篇文章總結(jié)一下G1垃圾收集器;

前面的文章我們介紹了很多垃圾收集器,例如PS+PO,CMS等,就還有G1沒有講到,那接下來我們先認(rèn)識一下G1,

我學(xué)習(xí)這部分的內(nèi)容時覺得前面講解的PS+PO,CMS等這些垃圾收集器的時候覺得沒有難,但是到G1,java的堆內(nèi)存布局就有點"妖艷賤貨"了! 然后就有點越來越看不懂了;看不懂就多看幾遍,多查閱一些資料!

image

簡單的介紹G1

G1把堆分成了大小相等的獨立區(qū)域(Region),新生代和老年代不在物理上隔離,只在邏輯上有定義;每一個Region都可以根據(jù)需要,扮演新生代的Eden空間,Survivor空間,或者老年代空間,除此之外它還有一類特殊區(qū)域叫做Humongous,專門用來存儲大對象,當(dāng)新建對象大小超過Region大小一半時,直接在新的一個或多個連續(xù)的Region中分配,并標(biāo)記為H,空間分布如下圖:

image.png

上面提到G1的堆內(nèi)存被劃分為多個大小相等的Region,但是劃分多少個?我找了一些資料:

The goal is to have around 2048 regions for the total heap.

對于一個Region來說,是邏輯連續(xù)的一段空間,其大小的取值范圍是1MB到32MB之間;那這些數(shù)據(jù)是哪里來的呢?我說32MB你們就信了嗎?就像2048一樣一些證據(jù)呀!

#ifndef SHARE_VM_GC_G1_HEAPREGIONBOUNDS_HPP
#define SHARE_VM_GC_G1_HEAPREGIONBOUNDS_HPP

#include "memory/allocation.hpp"

class HeapRegionBounds : public AllStatic {
private:
  // Minimum region size; we won't go lower than that.
  // We might want to decrease this in the future, to deal with small
  // heaps a bit more efficiently.

  //最小的Region大小為1M
  static const size_t MIN_REGION_SIZE = 1024 * 1024;

  // Maximum region size; we don't go higher than that. There's a good
  // reason for having an upper bound. We don't want regions to get too
  // large, otherwise cleanup's effectiveness would decrease as there
  // will be fewer opportunities to find totally empty regions after
  // marking.
  
  //最大的Region大小為32M
  static const size_t MAX_REGION_SIZE = 32 * 1024 * 1024;

  // The automatic region size calculation will try to have around this
  // many regions in the heap (based on the min heap size).
  
  //默認(rèn)的Region個數(shù)為2048
  static const size_t TARGET_REGION_NUMBER = 2048; 

public:
  static inline size_t min_size();
  static inline size_t max_size();
  static inline size_t target_number();
};



來源于:http://hg.openjdk.java.net/jdk/jdk/file/fa2f93f99dbc/src/hotspot/share/gc/g1/heapRegionBounds.hpp

這個更權(quán)威吧,但是知道這些數(shù)據(jù)重要嗎?我覺得不重要!但是知道了更牛逼呀!

言歸正傳,G1重新定義了堆空間,打破原有的分代模型,將堆劃分為一個個區(qū)域,這么做的目的就是為了在進行收集時不必再全堆范圍進行,這是它最顯著的特定;不在全堆內(nèi)進行收集帶來的好處就是:停頓時間可以預(yù)測;用戶可以指定收集操作在多長時間內(nèi)完成;如果用戶指定了收集時間為0.05s;那G1就會努力向這個時間上靠,G1收集垃圾時怎么能保證接近這個時間呢?G1要想達到這個目標(biāo)就會最少的收集Region;

把上面的話重新組織一下就是說:G1收集器之所以能建立可預(yù)測的停頓時間模型,是因為它可以有計劃的避免在整個java堆中進行全區(qū)域的收集,G1會通過一個合理的計算模型,計算出每個Region的收集成本并量化,這樣一來,收集器在給定了停頓時間的限制下,總是能選擇一組恰當(dāng)?shù)腞egion作為收集目標(biāo),讓其收集開銷滿足這個限制條件,以此達到實時收集的目的;

上面我們在宏觀上對G1進行了大致了解,接下來我們對它的執(zhí)行流程步驟了解一下:

G1收集器的運作過程

初始標(biāo)記(Initial Marking)

僅僅只是標(biāo)記了一下GCRoots能直接關(guān)聯(lián)的對象,并且修改TAMS(Next Top at Mark Start)的值,讓下一階段用戶程序并發(fā)運行時,能在正確可用的Region上創(chuàng)建對象,這個階段需要停頓線程,但耗時很短!

并發(fā)標(biāo)記

從GCRoots開始對堆進行可達性分析,找到存活的對象,這個階段耗時較長,可以與用戶程序并發(fā)執(zhí)行;

最終標(biāo)記

這個階段是為了修正并發(fā)標(biāo)記期間因用戶程序繼續(xù)運行而導(dǎo)致標(biāo)記產(chǎn)生變動的那一部分標(biāo)記記錄,虛擬機將這段時間對象變化記錄在線程Remembered Set Logs里面;最終標(biāo)記階段需要把Remembered Set Logs 的數(shù)據(jù)合并到Remembered Set中;這個階段需要停頓線程,但是可并行執(zhí)行;(這里對Remembered Set有眼熟,后面會講到)

篩選回收

負(fù)責(zé)更新Region的統(tǒng)計數(shù)據(jù),對各個Region的回收價值和成本進行排序,根據(jù)用戶所期望的停頓時間來制定回收計劃;

可以自由選擇任意多個Region構(gòu)成回收集,然后把決定回收的那一部分Region中存活的對象進行復(fù)制到空的Region中,再清理掉整個舊的Region的全部空間;這里的操作涉及到存活對象的移動復(fù)制,時必須要暫停用戶線程,由多個收集器線程并行完成的;

G1收集器的大致介紹到這里,上面的提到了Remembered Set,下面我們介紹一下Remembered Set為何物?

G1涉及到的技術(shù)點

Rset (Remembered Set)

每個區(qū)域(Region)都有一個Rset,用于記錄進入該區(qū)域塊的對象引用,?比如區(qū)塊A中的對象引用的區(qū)塊B,區(qū)塊B的Rest需要記錄這個信息,它用于實現(xiàn)收集過程的并行化以及使得區(qū)塊能獨立收集,當(dāng)然Rset會占用Region的空間,但是總體上占用的內(nèi)存小于5%;

卡表(Card Table)

在上一篇文章JVM垃圾收集器總結(jié)中我們也對Card Table 做了介紹!我們一起可以再回顧一下:

在上一篇我們講述CMS的時候有這樣一個場景:CMS是老年代的垃圾收集器,但是在并發(fā)標(biāo)記階段它也會掃描新生代,因為在并發(fā)過程中無法確保不可達對象會再次變成可達對象(之前和GCRoots跟斷了但是并發(fā)階段又重新連接上);這個時候如果全量的去掃描新生代和老年代就是特別的慢!這個時候就會用到卡表(Card Table)這個東西其實就是個數(shù)組,數(shù)組中每個位置存的是一個byte;CMS將老年代的空間分成大小為512bytes的塊,card table中的每個元素對應(yīng)著一個塊。

并發(fā)標(biāo)記時,如果某個對象的引用發(fā)生了變化,就標(biāo)記該對象所在的塊為 dirty card(臟卡)。

如果你沒有看過我的上一篇文章也沒有關(guān)系,我這里貼一下:

下面我們用圖來解釋一下:

并發(fā)標(biāo)記時對象的狀態(tài):

image

但隨后current obj的引用發(fā)生了變化:

image

current obj所在的塊被標(biāo)記為了dirty card。

隨后到了pre-cleaning(預(yù)清理階段)階段,該階段的任務(wù)之一就是標(biāo)記這些在并發(fā)標(biāo)記階段被修改了的對象,之后那些通過current obj變得可達的對象也被標(biāo)記了,變成下面這樣:

image

同時dirty card標(biāo)志也被清除。老年代的機制就是這樣。

這是卡表(Card Table)的其中一個功能,卡表(Card Table)還有另外一個功能:

我們再想象一個場景,老年代的對象可能引用新生代的對象,那標(biāo)記存活對象的時候,需要掃描老年代中的所有對象。因為該對象擁有對新生代對象的引用,那么這個引用也會被稱為GC Roots。那不是得又做全堆掃描?成本太高了吧。在進行Minor GC的時候,我們便可以不用掃描整個老年代,而是在卡表中尋找臟卡,并將臟卡中的對象加入到Minor GC的GC Roots里。當(dāng)完成所有臟卡的掃描之后,Java虛擬機便會將所有臟卡的標(biāo)識位清零。

卡表能用于減少老年代的全堆空間掃描,這能很大的提升GC效率。

三色標(biāo)記

在遍歷對象的過程中,把訪問的對象按照是否訪問過這個條件標(biāo)記成以下三種顏色:

  • 白色表示對象尚未被垃圾回收器訪問過。顯然,在可達性分析剛剛開始的階段,所有的對象都是白色的,若在分析結(jié)束的階段,仍然是白色的對象,即代表不可達。

  • 黑色:表示對象已經(jīng)被垃圾回收器訪問過,且這個對象的所有引用都已經(jīng)掃描過。黑色的對象代表已經(jīng)掃描過,它是安全存活的,如果有其它的對象引用指向了黑色對象,無須重新掃描一遍。黑色對象不可能直接(不經(jīng)過灰色對象)指向某個白色對象。

  • 灰色:表示對象已經(jīng)被垃圾回收器訪問過,但這個對象至少存在一個引用還沒有被掃描過。

image.png

可以看到,灰色對象是黑色對象與白色對象之間的中間態(tài),當(dāng)標(biāo)記過程結(jié)束后,只會有黑色和白色的對象,而白色的對象就是需要被回收的對象。

在并發(fā)標(biāo)記階段,垃圾收集器和用戶線程同時工作,這個時候就會產(chǎn)生問題;垃圾收集器在對象圖上面標(biāo)記顏色,而同時用戶線程在修改引用關(guān)系,引用關(guān)系變了,那么對象圖就變化了,這樣就會出現(xiàn)2中情況:

一種是把原本消亡的對象錯誤的標(biāo)記為存活,這種錯誤還是可以容忍的,只不過產(chǎn)生了一點逃過本次回收的浮動垃圾而已,下次清理即可!

另一種是把原本存活的對象錯誤的標(biāo)記為消亡,這就是非常嚴(yán)重的后果了,一個程序還需要使用的對象被回收了,那程序肯定會因此發(fā)生錯誤!這種情況絕對是不能容忍的!

所以接下來我們著重分析對象消失的問題;在看對象消失的情況之前我們先看一下正常的情況是什么樣子的,這樣我們更容易理解對象消失的情況;

下面的內(nèi)容引用了:why技術(shù)的一篇博客

首先是初始狀態(tài),很簡單,只有GC Roots是黑色的。同時需要注意下面的圖片的箭頭方向,代表的是有向的,比如其中的一條引用鏈?zhǔn)牵?根節(jié)點->5->6->7->8->11->10

image.png

在掃描的過程中,變化是這樣的:

image

你看上面的動圖,灰色對象始終是介于黑色和白色之間的。當(dāng)掃描順利完成后,對象圖就變成了這個樣子:

image.png

此時,黑色對象是存活的對象,白色對象是消亡了,可以回收的對象。

記住,上面演示的是一切都是那么美好的正常情況

對象消失的情況一

如果用戶線程在標(biāo)記的時候,修改了引用關(guān)系,就會出現(xiàn)下面的情況:

image

當(dāng)掃描完成后,對象圖就變成了這個樣子:

image.png

這時我們和之前分析的正常掃描結(jié)束的對象圖對比,就能清楚的看到,掃描完成后,原本還在被對象5引用的對象9由于是白色對象,所以根據(jù)三色標(biāo)記原則,對象9會被當(dāng)成垃圾回收;

對象消失情況二

下面看一下另外一種對象消失的情況

image

上面演示的是用戶線程切斷引用后重新被黑色對象引用的對象就是原來引用鏈的一部分。

對象7和對象10本來就是原引用鏈(根節(jié)點->5->6->7->8->11->10)的一部分。修改后的引用鏈變成了(根節(jié)點->5->6->7->10)。

當(dāng)掃描完成后,對象圖就變成了這個樣子:

image.png

由于黑色對象不會重新掃描,這將導(dǎo)致掃描結(jié)束后對象10和對象11都會回收了。他們都是被修改之前的原理的引用鏈的一部分;

這就是并發(fā)標(biāo)記帶來的問題,不光有浮動垃圾的產(chǎn)生,同時也產(chǎn)生了對象消失的問題;

解決對象消失的問題

上面我們演示的對象消失的兩個場景都要滿足兩個條件:

  • 條件一: 賦值器插入了一條或者多條從黑色對象到白色對象的新引用

  • 條件二:賦值器刪除了全部從灰色對象到該白色對象的直接或者間接的引用關(guān)系

當(dāng)這兩個條件同時滿足的時候,才會出現(xiàn)對象消失的情況;我們捋一捋上面的這兩個條件:

黑色對象5到白色對象9之間的引用是新建的,對應(yīng)條件一。

黑色對象6到白色對象9之間的引用被刪除了,對應(yīng)條件二。

image.png

由于這兩個條件同時滿足才會出現(xiàn)對象消失的情況,所以我們只要破壞其中一個條件就可以解決這個問題;

于是就產(chǎn)生了兩種解決方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

在HotSpot虛擬機中,CMS是基于增量更新來做并發(fā)標(biāo)記的,G1則采用的是原始快照的方式。

增量更新

增量更新破壞的是第一個條件,當(dāng)黑色對象插如新的指向白色對象的引用關(guān)系時,就將這個新插入的引用記錄下來,等并發(fā)掃描結(jié)束之后,再將這些記錄過的引用關(guān)系中黑色對象為根,重新掃描一次。

可以簡單理解為:黑色對象一旦插入了指向白色對象的引用之后它就變成了灰色對象。

下面的圖就是一次并發(fā)掃描結(jié)束之后,記錄了黑色對象5新指向了白色對象9:

image

這樣對象9又被掃描成為了黑色。也就不會被回收,所以不會出現(xiàn)對象消失的情況。

原始快照

原始快照要破壞的是第二個條件,照要破壞的是第二個條件(賦值器刪除了全部從灰色對象到該白色對象的直接或間接引用),當(dāng)灰色對象要刪除指向白色對象的引用關(guān)系時,就將這個要刪除的引用記錄下來,在并發(fā)掃描結(jié)束之后,再將這些記錄過的引用關(guān)系中的灰色對象為根,重新掃描一次。

這個可以簡化理解為:無論引用關(guān)系刪除與否,都會按照剛剛開始掃描那一刻的對象圖快照開進行搜索。

image

需要注意的是,上面的介紹中無論是對引用關(guān)系記錄的插入還是刪除,虛擬機的記錄操作都是通過寫屏障實現(xiàn)的。寫屏障也是一個重要的知識點,但是不是本文重點,就不進行詳細(xì)介紹了。

只是補充兩點:

1.這里的寫屏障和我們常說的為了解決并發(fā)亂序執(zhí)行問題的"內(nèi)存屏障"不是一碼事,需要區(qū)分開來。

2.寫屏障可以看作虛擬機層面對"引用類型字段賦值"這個動作的AOP切面,在引用對象賦值時會產(chǎn)生一個環(huán)形通知,供程序執(zhí)行額外的動作,也就是說賦值的前后都在寫屏障的覆蓋范疇內(nèi)。在賦值前的部分的寫屏障叫做寫前屏障(Pre-Write Barrier),在賦值后的則叫作寫后屏障(Post-Write Barrier)。

所以,經(jīng)過簡單的推導(dǎo)我們可以知道:

增量更新用的是寫后屏障(Post-Write Barrier),記錄了所有新增的引用關(guān)系。

原始快照用的是寫前屏障(Pre-Write Barrier),將所有即將被刪除的引用關(guān)系的舊引用記錄下來。

Full GC

這是G1(大多數(shù)收集器也同樣)的一個兜底的階段,原因主要是老年代的內(nèi)存占用太多,比如程序中使用了某一個模板模板解析引擎,但是沒有提取變量,導(dǎo)致每一次執(zhí)行都是編譯一個新的class,這種情況就很容易導(dǎo)致Full GC,或者有很多humongous object的存在,如果程序中發(fā)生了Full GC 除了GC相關(guān)的的調(diào)優(yōu),也需要多花時間去優(yōu)化業(yè)務(wù)代碼。

優(yōu)化Full GC

  • 增加堆大小,或調(diào)整老年代和年輕代的比例;對應(yīng)的效果G1可以有更多的時間去完成Concurrent Marking

  • 如果可能是大對象太多造成的,可以通過gc+heap=info查看humongous region個數(shù)??梢栽黾油ㄟ^ -XX:G1HeapRegionSize增加Region Size,避免老年代中的大對象占用過多的內(nèi)存空間。

  • 增加Concurrent Marking的線程,通過-XX:ConcGCThreads設(shè)置

  • 更早的進行并發(fā)周期,默認(rèn)是整個堆內(nèi)存的45%被占用就開始進行并發(fā)周期

參數(shù)介紹

  • -XX:+UseG1GC

    使用 G1 收集器

  • -XX:MaxGCPauseMillis=200

    指定目標(biāo)停頓時間,默認(rèn)值 200 毫秒。

    在設(shè)置 -XX:MaxGCPauseMillis 值的時候,不要指定為平均時間,而應(yīng)該指定為滿足 90% 的停頓在這個時間之內(nèi)。記住,停頓時間目標(biāo)是我們的目標(biāo),不是每次都一定能滿足的。

  • -XX:InitiatingHeapOccupancyPercent=45

    整堆使用達到這個比例后,觸發(fā)并發(fā) GC 周期,默認(rèn) 45%。

  • -XX:NewRatio=n

    老年代/年輕代,默認(rèn)值 2,即 1/3 的年輕代,2/3 的老年代

    不要設(shè)置年輕代為固定大小,否則:

    • G1 不再需要滿足我們的停頓時間目標(biāo)
    • 不能再按需擴容或縮容年輕代大小
  • -XX:SurvivorRatio=n

    Eden/Survivor,默認(rèn)值 8,這個和其他分代收集器是一樣的

  • -XX:MaxTenuringThreshold =n

    從年輕代晉升到老年代的年齡閾值,也是和其他分代收集器一樣的

  • -XX:ParallelGCThreads=n

    并行收集時候的垃圾收集線程數(shù)

  • -XX:ConcGCThreads=n

    并發(fā)標(biāo)記階段的垃圾收集線程數(shù)

    增加這個值可以讓并發(fā)標(biāo)記更快完成,如果沒有指定這個值,JVM 會通過以下公式計算得到:

    ConcGCThreads=(ParallelGCThreads + 2) / 4^3

  • -XX:G1ReservePercent=n

    堆內(nèi)存的預(yù)留空間百分比,默認(rèn) 10,用于降低晉升失敗的風(fēng)險,即默認(rèn)地會將 10% 的堆內(nèi)存預(yù)留下來。

  • -XX:G1HeapRegionSize=n

    每一個 region 的大小,默認(rèn)值為根據(jù)堆大小計算出來,取值 1MB~32MB,這個我們通常指定整堆大小就好了。

關(guān)于JVM的文章目前就先告一段落,后面學(xué)到新的東西再補充!只要努力就能解決的事情,我覺得就是最簡單的事情!

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

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

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