天天看點

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

點選檢視第一章 點選檢視第三章

第2章

G1的基本概念

通常我們所說的GC是指垃圾回收,但是在JVM的實作中GC更為準确的意思是指記憶體管理器,它有兩個職能,第一是記憶體的配置設定管理,第二是垃圾回收。這兩者是一個事物的兩個方面,每一種垃圾回收政策都和記憶體的配置設定政策息息相關,脫離記憶體的配置設定去談垃圾回收是沒有任何意義的。

本書第3章會介紹G1如何配置設定對象,第4章到第10章都是介紹G1是如何進行垃圾回收的。為了更好地了解後續章節,本章主要介紹G1的一些基本概念,主要有:G1實作中所用的一些基礎資料堆分區、G1的停頓預測模型、垃圾回收中使用到的對象頭、并發标記中涉及的卡表和位圖,以及垃圾回收過程中涉及的線程、棧幀和句柄等。

2.1 分區

分區(Heap Region,HR)或稱堆分區,是G1堆和作業系統互動的最小管理機關。G1的分區類型(HeapRegionType)大緻可以分為四類:

□自由分區(Free Heap Region,FHR)

□新生代分區(Young Heap Region,YHR)

□大對象分區(Humongous Heap Region,HHR)

□老生代分區(Old Heap Region,OHR)

其中新生代分區又可以分為Eden和Survivor;大對象分區又可以分為:大對象頭分區和大對象連續分區。

每一個分區都對應一個分區類型,在代碼中常見的is_young、is_old、is_houmongous

等判斷分區類型的函數都是基于上述的分區類型實作,關于分區類型代碼如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

在G1中每個分區的大小都是相同的。該如何設定HR的大小?設定HR的大小有哪些考慮?

HR的大小直接影響配置設定和垃圾回收效率。如果過大,一個HR可以存放多個對象,配置設定效率高,但是回收的時候花費時間過長;如果太小則導緻配置設定效率低下。為了達到配置設定效率和清理效率的平衡,HR有一個上限值和下限值,目前上限是32MB,下限是1MB(為了适應更小的記憶體配置設定,下限可能會被修改,在目前的版本中HR的大小隻能為1MB、2MB、4MB、8MB、16MB和32MB),預設情況下,整個堆空間分為2048個HR(該值可以自動根據最小的堆分區大小計算得出)。HR大小可由以下方式确定:

□可以通過參數G1HeapRegionSize來指定大小,這個參數的預設值為0。

□啟發式推斷,即在不指定HR大小的時候,由G1啟發式地推斷HR大小。

HR啟發式推斷根據堆空間的最大值和最小值以及HR個數進行推斷,設定Initial

HeapSize(預設為0)等價于設定Xms,設定MaxHeapSize(預設為96MB)等價于設定

Xmx。堆分區預設大小的計算方式在HeapRegion.cpp中的setup_heap_region_size(),代

碼如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

按照預設值計算,G1可以管理的最大記憶體為2048×32MB = 64GB。假設設定

xms = 32G,xmx = 128G,則每個堆分區的大小為32M,分區個數動态變化範圍從1024

到4096個。

G1中大對象不使用新生代空間,直接進入老生代,那麼多大的對象能稱為大對象?簡單來說是region_size的一半。

新生代大小

新生代大小指的是新生代記憶體空間的大小,前面提到G1中新生代大小按分區組織,即首先計算整個新生代的大小,然後根據上一節中的計算方法計算得到分區大小,兩者相除得到需要多少個分區。G1中與新生代大小相關的參數設定和其他GC算法類似,G1中還增加了兩個參數G1MaxNewSizePercent和G1NewSizePercent用于控制新生代的大小,整體邏輯如下:

□如果設定新生代最大值(MaxNewSize)和最小值(NewSize),可以根據這些值計算新生代包含的最大的分區和最小的分區;注意Xmn等價于設定了MaxNewSize

和NewSize,且NewSize = MaxNewSize。

□如果既設定了最大值或者最小值,又設定了NewRatio,則忽略NewRatio。

□如果沒有設定新生代最大值和最小值,但是設定了NewRatio,則新生代的最大值和最小值是相同的,都是整個堆空間/(NewRatio + 1)。

□如果沒有設定新生代最大值和最小值,或者隻設定了最大值和最小值中的一個,那麼G1将根據參數G1MaxNewSizePercent(預設值為60)和G1NewSizePercent

(預設值為5)占整個堆空間的比例來計算最大值和最小值。

值得注意的是,如果G1推斷出最大值和最小值相等,則說明新生代不會動态變化。不會動态變化意味着G1在後續對新生代垃圾回收的時候可能不能滿足期望停頓的時間,具體内容将在後文繼續介紹。新生代大小相關的代碼如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章
帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章
帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

如果G1是啟發式推斷新生代的大小,那麼當新生代變化時該如何實作?簡單地說,使用一個分區清單,擴張時如果有空閑的分區清單則可以直接把空閑分區加入到新生代分區清單中,如果沒有的話則配置設定新的分區然後把它加入到新生代分區清單中。G1有一個線程專門抽樣處理預測新生代清單的長度應該多大,并動态調整。

另外還有一個問題,就是配置設定新的分區時,何時擴充?一次擴充多少記憶體?

G1是自适應擴充記憶體空間的。參數-XX:GCTimeRatio表示GC與應用的耗費時間比,G1中預設為9,計算方式為_gc_overhead_perc = 100.0×(1.0 / (1.0 + GCTimeRatio)),

即G1 GC時間與應用時間占比不超過10%時不需要動态擴充,當GC時間超過這個門檻值的10%,可以動态擴充。擴充時有一個參數G1ExpandByPercentOfAvailable(預設值是20)來控制一次擴充的比例,即每次都至少從未送出的記憶體中申請20%,有下限要求(一次申請的記憶體不能少于1M,最多是目前已配置設定的一倍),代碼如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

GC中記憶體的擴充時機在第5章介紹。

2.2 G1停頓預測模型

G1是一個響應時間優先的GC算法,使用者可以設定整個GC過程的期望停頓時間,由參數MaxGCPauseMillis控制,預設值200ms。不過它不是硬性條件,隻是期望值,G1會努力在這個目标停頓時間内完成垃圾回收的工作,但是它不能保證,即也可能完不成(比如我們設定了太小的停頓時間,新生代太大等)。

那麼G1怎麼滿足使用者的期望呢?就需要停頓預測模型了。G1根據這個模型統計計算出來的曆史資料來預測本次收集需要選擇的堆分區數量(即選擇收集哪些記憶體空間),進而盡量滿足使用者設定的目标停頓時間。如使用過去10次垃圾回收的時間和回收空間的關系,根據目前垃圾回收的目标停頓時間來預測可以收集多少的記憶體空間。比如最簡單的辦法是使用算術平均值建立一個線性關系來預測。如過去10次一共收集了10GB的記憶體,花費了1s,那麼在200ms的停頓時間要求下,最多可以收集2GB的記憶體空間。G1的預測邏輯是基于衰減平均值和衰減标準差。

衰減平均(Decaying Average)是一種簡單的數學方法,用來計算一個數列的平均值,核心是給近期的資料更高的權重,即強調近期資料對結果的影響。衰減平均計算公式如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

式中α為曆史資料權值,1-α為最近一次資料權值。即α越小,最新的資料對結果影響越大,最近一次的資料對結果影響最大。不難看出,其實傳統的平均就是α取值為(n-1) /n的情況。

同理,衰減方差的定義如下:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

停頓預測模型是以衰減标準差為理論基礎實作的,代碼如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

在這個預測計算公式中:

□davg表示衰減均值。

□sigma()傳回一個系數,來自G1ConfidencePercent(預設值為50,sigma為0.5)的配置,表示信賴度。

□dsd表示衰減标準偏差。

□confidence_factor表示可信度相關系數,confidence_factor當樣本資料不足時(小于5個)取一個大于1的值,并且樣本資料越少該值越大。當樣本資料大于5時confidence_factor取值為1。這是為了彌補樣本資料不足,起到補償作用。

□方法的參數TruncateSeq,顧名思義,是一個截斷的序列,它隻跟蹤序列中最新的n個元素。在G1 GC過程中,每個可測量的步驟花費的時間都會記錄到TruncateSeq

(繼承了AbsSeq)中,用來計算衰減均值、衰減變量、衰減标準偏差等,代碼如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

這個add方法就是上面兩個衰減公式的實作代碼。其中_davg為衰減均值,

_dvariance為衰減方差,_alpha預設值為0.7。G1的軟實時停頓就是通過這樣的預測模型來實作的。

2.3 卡表和位圖

卡表(CardTable)在CMS中是最常見的概念之一,G1中不僅保留了這個概念,還引入了RSet。卡表到底是一個什麼東西?

GC最早引入卡表的目的是為了對記憶體的引用關系做标記,進而根據引用關系快速周遊活躍對象。舉個簡單的例子,有兩個分區,假設分區大小都為1MB,分别為A和B。如果A中有一個對象objA,B中有一個對象objB,且objA.field = objB,那麼這兩個分區就有引用關系了,但是如果我們想找到分區A,要如何引用分區B?做法有兩種:

□周遊整個分區A,一個字一個字的移動(為什麼以字為機關?原因是JVM中對象會對齊,是以不需要按位元組移動),然後檢視記憶體裡面的值到底是不是指向B,這種方法效率太低,可以優化為一個對象一個對象地移動(這裡涉及JVM如何識别對象,以及如何區分指針和立即數),但效率還是太低。

□借助額外的資料結構描述這種引用關系,例如使用類似位圖(bitmap)的方法,記錄A和B的記憶體塊之間的引用關系,用一個位來描述一個字,假設在32位機器上(一個字為32位),需要32KB(32KB×32 = 1M)的空間來描述一個分區。那麼我們就可以在這個對象ObjA所在分區A裡面添加一個額外的指針,這個指針指向另外一個分區B的位圖,如果我們可以把對象ObjA和指針關系進行映射,那麼當通路ObjA的時候,順便通路這個額外的指針,從這個指針指向的位圖就能找到被ObjA引用的分區B對應的記憶體塊。通常我們隻需要判定位圖裡面對應的位是否有1,有的話則認為發生了引用。

以位為粒度的位圖能準确描述每一個字的引用關系,但是一個位通常包含的資訊太少,隻能描述2個狀态:引用還是未引用。實際應用中JVM在垃圾回收的時候需要更多的狀态,如果增加至一個位元組來描述狀态,則位圖需要256KB的空間,這個數字太大,開銷占了25%。是以一個可能的做法位圖不再描述一個字,而是一個區域,JVM選擇512位元組為機關,即用一個位元組描述512位元組的引用關系。選擇一個區域除了空間使用率的問題之外,實際上還有現實的意義。我們知道Java對象實際上不是一個字能描述的(有一個參數可以控制對象最小對齊的大小,預設是8位元組,實際上Java在JVM中還有一些附加資訊,是以對齊後最小的Java對象是16位元組),很多Java對象可能是幾十個位元組或者幾百個位元組,是以用一個位元組描述一個區域是有意義的。但是我沒有找到512的來源,為什麼512效果最好?沒有相應的資料來支援這個數字,而且這個值不可以配置,不能修改,但是有理由相信512位元組的區域是為了節約記憶體額外開銷。按照這個值,1MB的記憶體隻需要2KB的額外空間就能描述引用關系。這又帶來另一個問題,就是512位元組裡面的記憶體可能被引用多次,是以這是一個粗略的關系描述,那麼在使用的時候需要周遊這512位元組。

再舉一個例子,假設有兩個對象B、C都在這512位元組的區域内。為了友善處理,記錄對象引用關系的時候,都使用對象的起始位置,然後用這個位址和512對齊,是以B和C對象的卡表指針都指向這一個卡表的位置。那麼對于引用處理也有可有兩種處理方法:

□處理的時候會以堆分區為處理機關,周遊整個堆分區,在周遊的時候,每次都會以對象大小為步長,結合卡表,如果該卡表中對應的位置被設定,則說明對象和其他分區的對象發生了引用。具體内容在後文中介紹Refine的時候還會詳細介紹。

□處理的時候借助于額外的資料結構,找到真正對象的位置,而不需要從頭開始周遊。在後文的并發标記處理時就使用了這種方法,用于找到第一個對象的起始位置。

在G1除了512位元組粒度的卡表之外,還有bitMap,例如使用bitMap可以描述一個分區對另外一個分區的引用情況。在JVM中bitMap使用非常多,例如還可以描述記憶體的配置設定情況。

G1在混合收集算法中用到了并發标記。在并發标記的時候使用了bitMap來描述對象的配置設定情況。例如1MB的分區可以用16KB(16KB×ObjectAlignmentInBytes×8 =

1MB)來描述,即16KB額外的空間。其中ObjectAlignmentInBytes是8位元組,指的是對象對齊,第二個8是指一個位元組有8位。即每一個位可以描述64位。例如一個對象長度對齊之後為24位元組,理論上它占用3個位來描述這個24位元組已被使用了,實際上并不需要,在标記的時候隻需要标記這3個位中的第一個位,再結合堆分區對象的大小資訊就能準确找出。其最主要的目的是為了效率,标記一個位和标記3個位相比能節約不少時間,如果對象很大,則更劃算。這些都是源碼的實作細節,大家在閱讀源碼時需要細細斟酌。

2.4 對象頭

我們都知道Java語言是多态,那麼如何實作多态?C++語言本身支援多态調用,衆所周知,C++完成多态依賴于一個指針:虛指針(virtual pointer),這個指針指向一個虛表(virtual table),這個虛表裡面存儲的是虛函數的位址,而這些函數的位址是在C++代碼編譯時确定的,通常虛表位于程式的資料段(Data Segment)中。

因為Java代碼首先被翻譯成位元組碼(bytecode),在JVM執行時才能确定要執行函數的位址,如何實作Java的多态調用,最直覺的想法是把Java對象映射成C++對象或者封裝成C++對象,比如增加一個額外的對象頭,裡面指向一個對象,而這個對象存儲了Java代碼的位址。是以JVM設計了對象的資料結構來描述Java對象,這個結構分為三塊區域:對象頭(Header)、執行個體資料(Instance Data)和對齊填充(Padding)。而我們剛才提到的類似虛指針的東西就可以放在對象頭中,而JVM設計者還利用對象頭來描述更多資訊,對象的鎖資訊、GC标記資訊等。我們這裡隻讨論和G1相關的資訊,更多資訊大家可以參考其他書籍或者文章。

JVM中對象頭分為兩部分:标記資訊、中繼資料資訊,代碼如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

1.标記資訊

第一部分标記資訊位于MarkOop。

根據JVM源碼的注釋,針對标記資訊在32位JVM用32位來描述,我們可以總結出這32位的組合情況,如表2-1所示。

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

另外在源代碼中我們還看到一個Promoted的狀态,Promoted指的是對象從新生代晉升到老生代時,正常的情況需要對這個對象頭進行儲存,主要的原因是如果發生晉升失敗,需要重新恢複對象頭。如果晉升成功這個儲存的對象頭就沒有意義。是以為了提高晉升失敗時對象頭的恢複效率,設計了promo_bits,這個其實是重用了加鎖位(包括偏向鎖),實際上隻需要在以下三種情況時才需要儲存對象頭:

□使用了偏向鎖,并且偏向鎖被設定了。

□對象被加鎖了。

□對象設定了hash_code。

這裡和GC直接相關的就是标記位11,前面的30位指針是非常有用的。在GC垃圾回收時,當對象被設定為marked(11)時,ptr指向什麼位置?簡單來說這個ptr是為了配合對象晉升時發生的對象複制(copy)。在對象複制時,先配置設定空間,再把原來對象的所有資料都複制過去,再修改對象引用的指針,就完成了。但是我們要思考這樣一個問題,當有多個引用對象的字段指向同一個被引用對象時,我們完成一個被引用對象的複制之後,其他引用對象還沒有被周遊(即還指向被引用對象老的位址),如何處理這種情況?這個時候簡單設定狀态為marked,表示被引用對象已經被标記且被複制了,ptr就是指向新的複制的位址。當周遊其他引用對象的時候,發現被引用對象已經完成标記,則不再需要複制對象,直接完成對象引用更新就可以了。我們在講述垃圾回收的時候會通過示意圖再幫助大家鞏固了解這個字段的意義。

2.中繼資料資訊

第二部分中繼資料資訊字段指向的是Klass對象(Klass對象是中繼資料對象,如Instance

Klass描述Java對象的類結構),這個字段也和垃圾回收有關系。

這裡大家先思考一個問題,就是在垃圾回收的時候如何差別一個立即數和指針位址?比如從Java的根集合中發現有一個值(如:0X12345678),那麼這個數到底是一個整數還是一個Java對象的位址?實際上垃圾回收器不能差別,但是為了準确地回收垃圾,必須差別出來。一個簡單的辦法就是,把0X12345678先看成一個位址,即強制轉換成OOP結構,再判定這個OOP是否是含有Klass指針,如果有的話即認為是一個指針,如果是NULL的話則認為是一個立即數。那麼這裡會有一個誤判,即把一個立即數識别成一個OOP,當這個立即數剛好和一個OOP的位址相同的時候。是以JVM維護了一個全局的OOpMap,用于标記棧裡面的數是立即數還是值。每一個InstanceKlass都維護了一個Map(OopMapBlock)用于标記Java類裡面的字段到底是OOP還是int這樣的立即數類型。這裡面的字段Klass很多時候用于再次确認。

由此可見,可以從根集合出發開始标記,通過外部的資料結構來辨別是否為OOP對象。但是我們在JVM源碼中還是看到了很多地方會根據對象頭裡面的Klass指針是否為NULL來判斷是不是OOP對象,這似乎是多此一舉。理論上根據額外的資料結構已經不需要再次判斷,但是在垃圾回收的時候,通常是對整個區域的一塊記憶體進行完全周遊,在對象配置設定時都是連續配置設定,當堆的尾部有尚未配置設定對象的時候,比如在新生代一個字通常初始化為0x20202020,需要對這些空白位址進行轉換以判斷是否為OOP,是否需要垃圾回收。在這裡即使誤判影響也不大,因為會根據RSet來判定是否為活躍對象(live object),如果是的話繼續,即使誤判之後也沒關系,這相當于是浮動垃圾,在下一次回收的時候仍然可能被回收。

2.5 記憶體配置設定和管理

C/C++程式員和Java程式員最大的差別之一就是對記憶體管理的工作,Java程式員不需要管理記憶體,因為有JVM幫助管理。是以JVM的所謂開發必然涉及記憶體的配置設定和管理。我們這裡盡可能地簡化描述記憶體配置設定和管理,隻描述和GC算法相關的部分。本質上來說,了解這一部分内容越多,特别是了解JVM如何與作業系統互動的部分,越容易對JVM調優。

JVM作為記憶體配置設定的管理器,一定涉及如何與記憶體互動。那麼JVM是如何管理記憶體的?實際上記憶體管理的算法很多,簡單來說JVM從作業系統申請一塊記憶體,然後根據不同的GC算法進行管理。下面以Linux為例看一下JVM是如何做的。

首先JVM先通過作業系統的系統調用(system call)進行記憶體的申請,典型的就是mmap。在這裡提一個問題,衆所周知glibc提供了我們常用的記憶體管理函數如malloc/free/realloc/memcopy/memset等。為什麼JVM不直接使用這些函數?glibc裡面的malloc也是通過mmap等系統調用來完成記憶體的配置設定,之後glibc再對已經配置設定到的記憶體進行管理。GC算法實作了一套自己的管理方式,是以再基于malloc/free實作效率肯定不高。mmap必須以PAGE_SIZE為機關進行映射,而記憶體也隻能以頁為機關進行映射,若要映射非PAGE_SIZE整數倍的位址範圍,要先進行記憶體對齊,強行以PAGE_SIZE的倍數大小進行映射。還要注意一點,作業系統對記憶體的配置設定管理典型地分為兩個階段:保留(reserve)和送出(commit)。保留階段告知系統從某一位址開始到後面的dwSize大小的連續虛拟記憶體需要供程式使用,程序其他配置設定記憶體的操作不得使用這段記憶體;送出階段将虛拟位址映射到對應的真實實體記憶體中,這樣這塊記憶體就可以正常使用。

對于保留和送出,Windows在使用VirtualAlloc配置設定記憶體時傳遞不同的參數MEM_RESERVE/MEM_COMMIT,Linux在mmap保留記憶體時使用MAP_PRIVATE | MAP_NORESERVE | MAP_ANONYMOUS,送出記憶體時使用MAP_PRIVATE | MAP_FIXED | MAP_ANONYMOUS。其中MAP_NORESERVE指不要為這個映射保留交換空間,MAP_FIXED使用指定的映射起始位址。

在JVM中我們還看到了使用類庫函數malloc/free的地方。這和JVM記憶體管理政策有關,JVM内部也有很多資料需要在堆中配置設定,而這和Java堆空間沒有關系,是以直接使用類庫函數。另外需要提一下JVM推薦使用jemalloc替代glibc,原因是其效率更高。

JVM中常見的對象類型有以下6種:

□ResourceObj:線程有一個資源空間(Resource Area),一般ResourceObj都位于這裡。定義資源空間的目的是對JVM其他功能的支援,如CFG、在C1/C2優化時可能需要通路運作時資訊(這些資訊可以儲存線上程的資源區)。

□StackObj:棧對象,聲明的對象使用棧管理。其實棧對象并不提供任何功能,且禁止New/Delete操作。對象配置設定線上程棧中,或者使用自定義的棧容器進行管理。

□ValueObj:值對象,該對象在堆對象需要進行嵌套時使用,簡單地說就是對象配置設定的位置和宿主對象(即擁有這個ValueObj對象的對象)是一樣的。

□AllStatic:靜态對象,全局對象,隻有一個。值得一提的是C++中靜态對象的初始化并沒有通過規範保證,可能會有一個問題,就是兩個靜态對象互相依賴,那麼在初始化的時候可能出錯。JVM中的很多靜态對象的初始化,都是顯式調用靜态初始化函數。

□MetaspaceObj:元對象,比如InstanceKlass這樣的中繼資料就是元對象。

□CHeapObj:這是堆空間的對象,由new/delete/free/malloc管理。其包含的内容很多,比如Java對象、InstanceOop(後面提到的G1對象配置設定出來的對象)。除了Java對象,還有其他的對象也在堆中。

JVM中為了準确描述這些堆中的對象,以友善對JVM進行優化,是以又定義了更具體的子類型,代碼如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章
帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

這些資訊描述了JVM使用記憶體的情況,這一部分資訊能夠幫助定位JVM本身運作時出現的問題,我們将在最後的附錄B中通過本地記憶體跟蹤(Native Memory Tracking)來進一步解讀這些資訊。

2.6 線程

線程是程式執行的基本單元,在JVM中也定義封裝了線程。圖2-1是JVM的線程類圖。

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

這裡隻介紹G1中涉及的幾類線程:

□JavaThread:就是要執行Java代碼的線程,比如Java代碼的啟動會建立一個JavaThread運作;對于Java代碼的啟動,可以通過JNI_CreateJavaVM來建立一個JavaThread,而對于一般的Java線程,都是調用java.lang.thread中的start方法,這個方法通過JNI調用建立JavaThread對象,完成真正的線程建立。

□CompilerThread:執行JIT的線程。

□WatcherThread:執行周期性任務,JVM裡面有很多周期性任務,例如記憶體管理中對小對象使用了ChunkPool,而這種管理需要周期性的清理動作Cleaner;JVM中記憶體抽樣任務MemProf?ilerTask等都是周期性任務。

□NameThread:是JVM内部使用的線程,分類如圖2-1所示。

□VMThread:JVM執行GC的同步線程,這個是JVM最關鍵的線程之一,主要是用于處理垃圾回收。簡單地說,所有的垃圾回收操作都是從VMThread觸發的,如果是多線程回收,則啟動多個線程,如果是單線程回收,則使用VMThread進行。VMThread提供了一個隊列,任何要執行GC的操作都實作了VM_GC_Operation,在JavaThread中執行VMThread::execute(VM_GC_Operation)把GC操作放入到隊列中,然後再用VMThread的run方法輪詢這個隊列就可以了。當這個隊列有内容的時候它就開始嘗試進入安全點,然後執行相應的GC任務,完成GC任務後會退出安全點。

□ConcurrentGCThread:并發執行GC任務的線程,比如G1中的ConcurrentMark

Thread和ConcurrentG1Ref?ineThread,分别處理并發标記和并發Ref?ine,這兩個線程将在混合垃圾收集和新生代垃圾回收中介紹。

□WorkerThread:工作線程,在G1中使用了FlexibleWorkGang,這個線程是并行執行的(個數一般和CPU個數相關),是以可以認為這是一個線程池。線程池裡面的線程是為了執行任務(在G1中是G1ParTask),也就是做GC工作的地方。VMThread會觸發這些任務的排程執行(其實是把G1ParTask放入到這些工作線程中,然後由工作線程進行排程)。

從線程的實作角度來看,JVM中的每一個線程都對應一個作業系統(OS)線程。JVM為了提供統一的處理,設計了JVM線程狀态,代碼如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章
帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

JVM可以運作在不同的作業系統之上,是以它也統一定義了作業系統線程的狀态,代碼如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

這裡定義不同的線程狀态有兩個目的:第一、統一管理,第二、根據狀态可以做一些同步處理,相關内容在VMThread進入安全點時會有涉及。關于安全點的内容并不影響G1的閱讀,後文将會詳細介紹。

當線程建立時,它的狀态為NEW,當執行時轉變為RUNNABLE。線程在Windows

和Linux上的實作稍有差別。在Linux上建立線程後,雖然設定成NEW,但是Linux的線程建立完之後就可以執行,是以為了讓線程隻有在執行Java代碼的start之後才能執行,當線程初始化之後,通過等待一個信号将線程暫停,代碼如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

在調用start方法時,發送通知事件,讓線程真正運作起來。

2.6.1 棧幀

棧幀(frame)線上程執行時和運作過程中用于儲存線程的上下文資料,JVM設計了Java棧幀,這是垃圾回收中最重要的根,棧幀的結構在不同的CPU中并不相同,在x86中代碼如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

在實際應用中主要使用vframe,它包含了棧幀的字段和線程對象。在JaveThread中定義了JavaFrameAnchor,這個結構儲存的是最後一個棧幀的sp、fp。每一個JavaThread都有一個JavaFrameAnchor,即最後一次調用棧的sp、fp。而通過這兩個值可以構造棧幀結構,并且根據棧幀的内容,能夠周遊整個JavaThread運作時的所有調用鍊。擷取的方法就是根據JavaFrameAnchor裡面的sp、fp構造棧幀,再根據棧幀構造vframe結構,代碼如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

在周遊的時候主要通過sender獲得下一個棧,其中sender位于棧幀中,其具體的位置依賴于棧的布局,比如彙編解釋器在執行時棧幀的代碼如下:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

棧幀也是和GC密切相關的,在GC過程中,通常第一步就是周遊根,Java線程棧幀就是根元素之一,周遊整個棧幀的方式是通過StackFrameStream,其中封裝了一個next指針,其原理和上述的代碼一樣,通過sender獲得調用者的棧幀。

值得一提的是,我們将Java的棧幀作為根來周遊堆,對對象進行标記并收集垃圾。

2.6.2 句柄

實際上線程既可以支援Java代碼的執行也可以執行本地代碼,如果本地代碼(這裡的本地代碼指的是JVM裡面的本地代碼,而不是使用者自定義的本地代碼)引用了堆裡面的對象該如何處理?是不是也是通過棧?理論上是可行的,實際上JVM并沒有區分Java棧和本地方法棧,如果通過棧進行處理則必須要區分這兩種情況。JVM設計了另一個概念,handleArea,這是一塊線程的資源區,在這個區域配置設定句柄(handle),并且管理所有的句柄,如果函數還在調用中,那麼句柄有效,句柄關聯的對象也就是活躍對象。為了管理句柄的生命周期,引入了HandleMark,通常HandleMark配置設定在棧上,在建立HandleMark的時候标記handleArea對象有效,在HandleMark對象析構的時候,從HandleArea中删除對象的引用。由于所有句柄都形成了一個連結清單,那麼通路這個句柄連結清單就可以獲得本地代碼執行中對堆對象的引用。

句柄和OOP對象關聯,在HandleArea中有一個slot用于指向OOP對象。

本節源碼都在下面兩個檔案中,為了便于閱讀和減少篇幅,我們對其中的類代碼進行了重組,代碼如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章
帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

在HandleMark中标記Chunk的位址,這個就是找到目前本地方法代碼中活躍的句柄,是以也就可以找到對應的活躍的OOP對象。下面是HandleMark的構造函數和析構函數,它們的主要工作就是建構句柄連結清單,代碼如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章
帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

在這裡我們提到了Chunk,Chunk的回收是通過前面我們提到的周期性線程Watcher

Thread完成的。

還需要提到一點,就是JVM中的本地代碼指的是JVM内部的代碼,除了JVM内部的本地代碼,還有JNI代碼也是本地代碼。對于本地代碼,并不歸JVM直接管理,在執行JNI代碼的時候,也有可能通路堆中的OOP對象。是以也需要一個機制進行管理,JVM引入了類似的句柄機制,稱為JNIHandle。JNIHandle分為兩種,全局和局部對象引用,大部分對象的引用屬于局部對象引用,最終還是調用了JNIHandleBlock來管理,因為JNIHandle沒有設計一個JNIHandleMark的機制,是以在建立時需要明确調用make_local,在回收時也需要明确調用destory_local。對于全局對象,比如在編譯任務compilerTask中會通路Method對象,這時候就需要把這些對象設定為全局的(否則在GC時可能會被回收的)。這兩部分在垃圾回收時的處理是不同的,局部JNIhandle是通過線程,全局JNIhandle則是通過全局變量開始。

2.6.3 JVM本地方法棧中的對象

上節介紹本地方法棧是如何管理和連結對象的。每一個Java線程都私有一個句柄區_handle_area來存儲其運作過程中建立的臨時對象,這個句柄區是随着Java線程的棧幀變化的,我們看一下HandleMark是如何管理的。HandleArea的作用上一節已經介紹過了,這裡我們先看一下它們的結構圖(如圖2-2所示),然後再通過代碼示範如何管理句柄。

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

Java線程每調用一個Java方法就會建立一個對應HandleMark來儲存已配置設定的對象句柄,然後等調用傳回後即行恢複,代碼如下所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

是以當Java線程運作一段時間之後,通過HandleMark建構的對象識别鍊如圖2-3所示:

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

這裡Chunk的管理是動态變化的,第一個Chunk可能為256或者1024個位元組,每一個Chunk都有一個額外空間,主要是調用malloc時會有一段額外的資訊,比如位址的長度等,在32位機器上一般為20個位元組,是以每一個Chunk都會比最大值少5個OOP對象。另外,一般的Chunk塊通常為32KB。最後還需要提一點的就是,Handle

Mark通常都是配置設定線上程棧中,也意味着無需額外的管理,隻需要找到HandleMark就能找到哪些對象是存活的。我們來看一個簡單的例子,看看如何周遊堆空間。

下面這個代碼片段是為了輸出堆空間裡面的對象,例如我們執行jmap指令來擷取堆空間對象的時候最終會調用到VM_HeapDumper::do_thread()來周遊所有的對象。通過下面的代碼我們能非常清楚地看到,如果JavaThread執行的是Java代碼,則直接通過StackValueCollection通路局部變量,如果執行的是本地代碼,線程則通過active_handles()通路句柄而通路對象。

帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章
帶你讀《JVM G1源碼分析和調優》之二:G1的基本概念第2章

2.6.4 Java本地方法棧中的對象

Java線程使用一個對象句柄存儲塊JNIHandleBlock來為其在本地方法中申請的臨時對象建立對應的句柄,每個JNIHandleBlock裡面有一個oop數組,長度為32,如果超過數組長度則申請新的Block并通過next指針形成連結清單。另外JNIHandleBlock中還有一個_pop_frame_link屬性,用來儲存Java線程切換方法時配置設定本地對象句柄的上下文環境,進而形成調用handle的連結清單。

2.7 日志解讀

如果啟動JVM的時候我們沒有指定參數,則可以通過設定Java -XX:+Print

CommandLineFlags這個參數讓JVM列印出那些已經被使用者或者JVM設定過的詳細的XX參數的名稱和值。例如我們可以得到JVM使用的預設垃圾收集器,如下所示:

-XX:InitialHeapSize=266930688 -XX:MaxHeapSize=4270891008

-XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers

-XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation

-XX:+UseParallelGC

如果指定G1作為垃圾回收,但是沒有指定堆空間的參數,當發生GC的時候,我們可以看到:

-Xmx256M -XX:+UseG1GC -XX:+UnlockExperimentalVMOptions

-XX:G1LogLevel=f?inest -XX:+PrintGCDetails -XX:+PrintGCTimeStamps

-XX:+UseAdaptiveSizePolicy

garbage-f?irst heap total 131072K, used 37569K [0x00000000f8000000,

0x00000000f8100400, 0x0000000100000000)

region size 1024K, 24 young (24576K), 0 survivors (0K)

Eden開始之前是24MB,主要來自于預測值,且24個分區,即每個分區都是1MB。

第一次GC後的堆空間資訊如下所示:

[Eden: 24.0M(24.0M)->0.0B(13.0M) Survivors: 0.0B->3072.0K Heap:

24.0M(128.0M)->12.9M(128.0M)]

GC之後Eden設定為13M,來自于256M×5% = 12.8MB,取整後就是13MB,并且滿足預測時間。其中,256M是堆的大小,5%是G1 NewSizePercent指定的預設值。

2.8 參數介紹和調優

上文已經詳細介紹了G1中堆大小和新生代大小的計算、分區設定、G1的停頓預測模型以及停頓預測模型中的幾個參數。這裡給出使用中的一些注意事項:

□參數G1HeapRegionSize指定堆分區大小。分區大小可以指定,也可以不指定;不指定時,由記憶體管理器啟發式推斷分區大小。

□參數xms/xmx指定堆空間的最小值/最大值。一定要正确設定xms/xmx,否則将使用預設配置,将影響分區大小推斷。

□在以前的記憶體管理器中(非G1),為了防止新生代因為記憶體不斷地重新配置設定導緻性能變低,通常設定Xmn或者NewRatio。但是G1中不要設定MaxNewSize、

NewSize、Xmn和NewRatio。原因有兩個,第一G1對記憶體的管理不是連續的,是以即使重新配置設定一個堆分區代價也不高,第二也是最重要的,G1的目标滿足垃圾收集停頓,這需要G1根據停頓時間動态調整收集的分區,如果設定了固定的分區數,即G1不能調整新生代的大小,那麼G1可能不能滿足停頓時間的要求。具體情況本書後續還會繼續讨論。

□參數GCTimeRatio指的是GC與應用程式之間的時間占比,預設值為9,表示GC與應用程式時間占比為10%。增大該值将減少GC占用的時間,帶來的後果就是動态擴充記憶體更容易發生;在很多情況下10%已經很大,例如可以将該值設定為19,則表示GC時間不超過5%。

□根據業務請求變化的情況,設定合适的擴充G1ExpandByPercentOfAvailable速率,保持效率。

□JVM在對新生代記憶體配置設定管理時,還有一個參數就是保留記憶體G1ReservePercent(預設值是10),即在初始化,或者記憶體擴充/收縮的時候會計算更新有多少個分區是保留的,在新生代分區初始化的時候,在空閑清單中保留一定比例的分區不使用,那麼在對象晉升的時候就可以使用了,是以能有效地減小晉升失敗的機率。這個值最大不超過50,即最多保留50%的空間,但是保留過多會導緻新生代可用空間少,過少可能會增加新生代晉升失敗,那将會導緻更為複雜的串行回收。

□G1NewSizePercent是一個實驗參數,需要使用-XX:+UnlockExperimentalVMOptions

才能改變選項。有實驗表明G1在回收Eden分區的時候,大概每GB需要100ms,是以可以根據停頓時間,相應地調整。這個值在記憶體比較大的時候需要減少,例如32G可以設定-XX:G1NewSizePercent = 3,這樣Eden至少保留大約1GB的空間,進而保證收集效率。

□參數MaxGCPauseMillis指期望停頓時間,可根據系統配置和業務動态調整。因為G1在垃圾收集的時候一定會收集新生代,是以需要配合新生代大小的設定來确定,如果該值太小,連新生代都不能收集完成,則沒有任何意義,每次除了新生代之外隻能多收集一個額外老生代分區。

□參數GCPauseIntervalMillisGC指GC間隔時間,預設值為0,GC啟發式推斷為MaxGCPauseMillis + 1,設定該值必須要大于MaxGCPauseMillis。

□參數G1ConfidencePercent指GC預測置信度,該值越小說明基于過去曆史資料的預測越準确,例如設定為0則表示收集的分區基本和過去的衰減均值相關,無波動,是以可以根據過去的衰減均值直接預測下一次預測的時間。反之該值越大,說明波動越大,越不準确,需要加上衰減方差來補償。

□JVM中提供了一個對象對齊的值ObjectAlignmentInBytes,預設值為8,需要明白該值對記憶體使用的影響,這個影響不僅僅是在JVM對對象的配置設定上面,正如上面看到的它也會影響對象在配置設定時的标記情況。注意這個值最少要和作業系統支援的位數一緻才能提高對象配置設定的效率。是以32位系統最少是4,64位最少是8。一般不用修改該值。