前言
前面兩篇主要整理了性能測試的主要觀察名額資訊:性能測試篇,以及JVM性能調優的工具:JVM篇。這一篇先簡單總結一下GC的種類,然後側重總結下G1(Garbage-First)垃圾收集器的分代,結合open-jdk源碼分析下重要算法如SATB,重要存儲結構如CSet、RSet、TLAB、PLAB、Card Table等。最後會再梳理下G1 GC的YoungGC,MixedGC收集過程。
GC的分類
GC的主要回收區域就是年輕代(young gen)、老年代(tenured gen)、持久區(perm gen),在jdk8之後,perm gen消失,被替換成了元空間(Metaspace),元空間會在普通的堆區進行配置設定。垃圾收集為了提高效率,采用分代收集的方式,對于不同特點的回收區域使用不同的垃圾收集器。系統正常運作情況young是比較頻繁的,full gc會觸發整個heap的掃描和回收。在G1垃圾收集器中,最好的優化狀态就是通過不斷調整分區空間,避免進行full gc,可以大幅提高吞吐量。下面會詳細介紹。
串行垃圾回收器
JDK 1.3之前的垃圾回收器,單線程回收,并且會有stop theworld(下文會簡稱STW),也即GC時,暫停所有使用者線程。其運作方式是單線程的,适合Client模式的應用,适合單CPU環境。串行的垃圾收集器有兩種,Serial以及SerialOld,一般會搭配使用。新生代使用Serial采取複制算法,老年代使用Serial Old采取标記整理算法。Client應用或者指令行程式可以,通過-XX:+UseSerialGC可以開啟上述回收模式。
- Serial:用于新生代垃圾收集,複制算法
- SerialOld:用于老年代垃圾收集,标記整理算法
并行垃圾回收器
整體來說,并行垃圾回收相對于串行,是通過多線程運作垃圾收集的。也會stop-the-world。适合Server模式以及多CPU環境。一般會和jdk1.5之後出現的CMS搭配使用。并行的垃圾回收器有以下幾種:
- ParNew:Serial收集器的多線程版本,預設開啟的收集線程數和cpu數量一樣,運作數量可以通過修改ParallelGCThreads設定。用于新生代收集,複制算法。使用-XX:+UseParNewGC,和Serial Old收集器組合進行記憶體回收。
-
Parallel Scavenge: 關注吞吐量,吞吐量優先,吞吐量=代碼運作時間/(代碼運作時間+垃圾收集時間),也就是高效率利用cpu時間,盡快完成程式的運算任務
可以設定最大停頓時間MaxGCPauseMillis以及,吞吐量大小GCTimeRatio。如果設定了-XX:+UseAdaptiveSizePolicy參數,則随着GC,會動态調整新生代的大小,Eden,Survivor比例等,以提供最合适的停頓時間或者最大的吞吐量。用于新生代收集,複制算法。通過-XX:+UseParallelGC參數,Server模式下預設提供了其和SerialOld進行搭配的分代收集方式。
- Parllel Old:Parallel Scavenge的老年代版本。JDK 1.6開始提供的。在此之前Parallel Scavenge的地位也很尴尬,而有了Parllel Old之後,通過-XX:+UseParallelOldGC參數使用Parallel Scavenge + Parallel Old器組合進行記憶體回收。
并發标記掃描垃圾回收器(CMS)
CMS(Concurrent Mark Sweep)基于“标記—清除”算法,用于老年代,是以其關注點在于減少“pause time”也即因垃圾回收導緻的stop the world時間。對于重視服務的響應速度的應用可以使用CMS。因為CMS是“并發”運作的,也即垃圾收集線程可以和使用者線程同時運作。 缺點就是會産生記憶體碎片。
CMS的回收分為幾個階段:
- 初始标記:标記一下GC Roots能直接關聯到的對象,會“Stop The World”
- 并發标記:GC Roots Tracing,可以和使用者線程并發執行。
- 重新标記:标記期間産生的對象存活的再次判斷,修正對這些對象的标記,執行時間相對并發标記短,會“Stop The World”。
- 并發清除:清除對象,可以和使用者線程并發執行。
CMS最主要解決了pause time,但是會占用CPU資源,犧牲吞吐量。CMS預設啟動的回收線程數是(CPU數量+3)/ 4,當CPU<4個時,會影響使用者線程的執行。另外一個缺點就是記憶體碎片的問題了,碎片會給大對象的記憶體配置設定造成麻煩,如果老年代的可用的連續空間也無法配置設定時,會觸發full gc。并且full gc時如果發生young gc會被young gc打斷,執行完young gc之後再繼續執行full gc。
-XX:UseConcMarkSweepGC參數可以開啟CMS,年輕代使用ParNew,老年代使用CMS,同時Serial Old收集器将作為CMS收集器出現Concurrent Mode Failure失敗後的後備收集器使用。
G1垃圾收集器
G1(Garbage-First)是在JDK 7u4版本之後釋出的垃圾收集器,并在jdk9中成為預設垃圾收集器。通過“-XX:+UseG1GC”啟動參數即可指定使用G1 GC。從整體來說,G1也是利用多CPU來縮短stop the world時間,并且是高效的并發垃圾收集器。但是G1不再像上文所述的垃圾收集器,需要分代配合不同的垃圾收集器,因為G1中的垃圾收集區域是“分區”(Region)的。G1的分代收集和以上垃圾收集器不同的就是除了有年輕代的ygc,全堆掃描的fullgc外,還有包含所有年輕代以及部分老年代Region的MixedGC。G1的優勢還有可以通過調整參數,指定垃圾收集的最大允許pause time。下面會詳細闡述下G1分區以及分代的概念,以及G1 GC的幾種收集過程的分類。
G1分區的概念
在G1之前的垃圾收集器,将堆區主要劃分了Eden區,Old區,Survivor區。其中對于Eden,Survivor對回收過程來說叫做“年輕代垃圾收集”。并且年輕代和老年代都分别是連續的記憶體空間。
G1将堆分成了若幹Region,以下和”分區”代表同一概念。Region的大小可以通過G1HeapRegionSize參數進行設定,其必須是2的幂,範圍允許為1Mb到32Mb。 JVM的會基于堆記憶體的初始值和最大值的平均數計算分區的尺寸,平均的堆尺寸會分出約2000個Region。分區大小一旦設定,則啟動之後不會再變化。如下圖簡單畫了下G1分區模型。
- Eden regions(年輕代-Eden區)
- Survivor regions(年輕代-Survivor區)
- Old regions(老年代)
- Humongous regions(巨型對象區域)
- Free resgions(未配置設定區域,也會叫做可用分區)-上圖中空白的區域
關于分區有幾個重要的概念:
- G1還是采用分代回收,但是不同的分代之間記憶體不一定是連續的,不同分代的Region的占用數也不一定是固定的(不建議通過相關選項顯式設定年輕代大小。會覆寫暫停時間目标。)。年輕代的Eden,Survivor數量會随着每一次GC發生相應的改變。
- 分區是不固定屬于哪個分代的,是以比如一次ygc過後,原來的Eden的分區就會變成空閑的可用分區,随後也可能被用作配置設定巨型對象,成為H區等。
- G1中的巨型對象是指,占用了Region容量的50%以上的一個對象。Humongous區,就專門用來存儲巨型對象。如果一個H區裝不下一個巨型對象,則會通過連續的若幹H分區來存儲。因為巨型對象的轉移會影響GC效率,是以并發标記階段發現巨型對象不再存活時,會将其直接回收。ygc也會在某些情況下對巨型對象進行回收。
- 通過上圖可以看出,分區可以有效利用記憶體空間,因為收集整體是使用“标記-整理”,Region之間基于“複制”算法,GC後會将存活對象複制到可用分區(未配置設定的分區),是以不會産生空間碎片。
- G1類似CMS,也會在比如一次fullgc中基于堆尺寸的計算重新調整(增加)堆的空間。但是相較于執行fullgc,G1 GC會在無法配置設定對象或者巨型對象無法獲得連續分區來配置設定空間時,優先嘗試擴充堆空間來獲得更多的可用分區。原則上就是G1會計算執行GC的時間,并且極力減少花在GC上的時間(包括ygc,mixgc),如果可能,會通過不斷擴充堆空間來滿足對象配置設定、轉移的需要。
- 因為G1提供了“可預測的暫停時間”,也是基于G1的啟發式算法,是以G1會估算年輕代需要多少分區,以及還有多少分區要被回收。ygc觸發的契機就是在Eden分區數量達到上限時。一次ygc會回收所有的Eden和survivor區。其中存活的對象會被轉移到另一個新的survivor區或者old區,如果轉移的目标分區滿了,會再将可用區标記成S或者O區。
G1 中的重要資料結構、算法
在提及G1的垃圾收集過程時,需要了解幾個G1的重要的分區内部的詳細資料結構、以及核心算法。
TLAB(Thread Local Allocation Buffer)本地線程緩沖區
G1 GC會預設會啟用Tlab優化。其作用就是在并發情況下,基于CAS的獨享線程(mutator threads)可以優先将對象配置設定在一塊記憶體區域(屬于Java堆的Eden中),隻是因為是Java線程獨享的記憶體區,沒有鎖競争,是以配置設定速度更快,每個Tlab都是一個線程獨享的。如果待配置設定的對象被判斷是巨型對象,則不使用TLAB。
下面把TLAB配置設定對象記憶體的open jdk部分源碼附上,有助了解。
HeapWord* G1CollectedHeap::allocate_new_tlab(size_t min_size,
size_t requested_size,
size_t* actual_size) {
assert_heap_not_locked_and_not_at_safepoint();
assert(!is_humongous(requested_size), "we do not allow humongous TLABs");
return attempt_allocation(min_size, requested_size, actual_size);
}
inline HeapWord* G1CollectedHeap::attempt_allocation(size_t min_word_size,
size_t desired_word_size,
size_t* actual_word_size) {
assert_heap_not_locked_and_not_at_safepoint();
// 排除巨型對象
assert(!is_humongous(desired_word_size), "attempt_allocation() should not "
"be called for humongous allocation requests");
// 在目前的region配置設定
HeapWord* result = _allocator->attempt_allocation(min_word_size, desired_word_size, actual_word_size);
// 可用空間不夠,申請新的region配置設定
if (result == NULL) {
*actual_word_size = desired_word_size;
// 可能存在多線程申請,是以通過加鎖的方式申請,如果young區沒有超出閥值,則會擷取新的region
result = attempt_allocation_slow(desired_word_size);
}
// 判斷沒有因gc導緻堆locked
assert_heap_not_locked();
if (result != NULL) {
assert(*actual_word_size != , "Actual size must have been set here");
// 髒化年輕代的card(卡片)資料
dirty_young_block(result, *actual_word_size);
} else {
*actual_word_size = ;
}
return result;
}
PLAB(Promotion Local Allocation Buffer) 晉升本地配置設定緩沖區
在ygc中,對象會将全部Eden區存貨的對象轉移(複制)到S區分區。也會存在S區對象晉升(Promotion)到老年代。這個決定晉升的閥值可以通過MaxTenuringThreshold設定。晉升的過程,無論是晉升到S還是O區,都是在GC線程的PLAB中進行。每個GC線程都有一個PLAB。
Collection Sets(CSets)待收集集合
GC中待回收的region的集合。CSet中可能存放着各個分代的Region。CSet中的存活對象會在gc中被移動(複制)。GC後CSet中的region會成為可用分區。
Card Table 卡表
将Java堆劃分為相等大小的一個個區域,這個小的區域(一般size在128-512位元組)被當做Card,而Card Table維護着所有的Card。Card Table的結構是一個位元組數組,Card Table用單位元組的資訊映射着一個Card。當Card中存儲了對象時,稱為這個Card被髒化了(dirty card)。 對于一些熱點Card會存放到Hot card cache。同Card Table一樣,Hot card cache也是全局的結構。
Remembered Sets(RSets)已記憶集合
已記憶集合在每個分區中都存在,并且每個分區隻有一個RSet。其中存儲着其他分區中的對象對本分區對象的引用,是一種points-in結構。ygc的時候,隻要掃描RSet中的其他old區對象對于本young區的引用,不需要掃描所有old區。mixed gc時,掃描Old區的RSet中,其他old區對于本old分區的引用,一樣不用掃描所有的old區。提高了GC效率。因為每次GC都會掃描所有young區對象,是以RSet隻有在掃描old引用young,old引用old時會被使用。
為了防止RSet溢出,對于一些比較“Hot”的RSet會通過存儲粒度級别來控制。RSet有三種粒度,對于“Hot”的RSet在存儲時,根據細粒度的存儲閥值,可能會采取粗粒度。
這三種粒度的RSet都是通過PerRegionTable來維護内部資料的。可以檢視其部分源碼如下:
class PerRegionTable: public CHeapObj<mtGC> {
friend class OtherRegionsTable;
friend class HeapRegionRemSetIterator;
HeapRegion* _hr; // 來自其他分區的引用
CHeapBitMap _bm; // card索引存放的位圖
jint _occupied; // 已占用的容量
// next pointer for free/allocated 'all' list
PerRegionTable* _next;
// prev pointer for the allocated 'all' list
PerRegionTable* _prev;
// next pointer in collision list
PerRegionTable * _collision_list_next;
// Global free list of PRTs
static PerRegionTable* volatile _free_list;
簡要結構如下圖(圖檔來源)
下面是三種粒度級别,以及對應的簡要資料結構:
- 細粒度(fine),其PerRegionTable存儲了所有對于本Resgion的引用的卡片的索引,其卡片索引都存儲在CHeapBitMap結構裡。僞代碼類似:hash_map
Snapshot-At-The-Beginning(SATB)
SATB是在G1 GC在并發标記階段使用的增量式的标記算法。并發标記是并發多線程的,但并發線程在同一時刻隻掃描一個分區。
在解釋SATB前先要了解三色标記法。三色标記法是将對象的存活狀态用三種顔色标記,從黑色到灰色逐層标記:
- 黑:該對象被标記了,并且其引用的對象也都被标記完成。
- 灰:對象被标記了,但其引用的對象還沒有被标記完。
- 白:對象還沒有被标記,标記階段結束後,會被回收。
在CMS GC中,并發标記階段使用的是Incremental update批量更新算法,在增加引用時的寫屏障中觸發新的對象引用的标記(三色标記法)。
G1的并發标記算法,使用的是SATB。在GC開始時先建立一個對象快照,STAB可以在并發标記時标記所有快照中當時的存活對象。标記過程中新配置設定的對象也會被标記為存活對象,不會被回收。STAB核心的兩個結構就是兩個BitMap。如下:
// from G1ConcurrentMark-可以認為Bitmap的内部存儲着對象位址(reference 是8byte,是以Bitmap存儲着一個個64bit結構)
G1CMBitMap* _prev_mark_bitmap; // 全局的bitmap,存儲PTAMS偏移位置,也即目前标記的對象的位址(初始值是對應上次已經标記完成的位址)
G1CMBitMap* _next_mark_bitmap; // 全局的bitmap,存儲NTAMS偏移位置。标記過程不斷移動,标記完成後會和prev_map 互換。
bitmap分别存儲着每個分區中,并發标記過程裡的兩個重要的變量:PTAMS(pre-top-at-mark-start,代表着分區上一次完成标記的位置) 以及NTAMS(next-top-at-mark-start,随着标記的進行會不斷移動,一開始在top位置)。SATB通過控制兩個變量的移動來進行标記。為了直覺了解标記過程,如下圖所示:(原圖論文)
A:初始标記,因為要掃描所有Root Trace可達的對象,會有STW的暫停時間,會将掃描分區的NTAMS值設定為分區的頂部(Top)。
B:最終标記,因為并發導緻會有新配置設定的對象,因為并發标記過程中對象會被配置設定到NTAMS~TOP中間的區域。這些對象會被定義為”隐式對象“。因為NTAMS有很多值了,是以_next_mark_bitmap也會開始存儲NTAMS标記的對象的位址。
C:清除階段:_next_mark_bitmap和_prev_mark_bitmap會進行Swap。PTAMS和NTAMS也會互換值。清除所有Bottom~PTAMS的對象。對于”隐式對象“會在下次垃圾收集過程進行回收(如圖F過程)。這也是SATB存在弊端,會一定程度産生未能在本次标記中識别的浮動垃圾。
另,以上過程省略了根分區掃描和并發标記。上圖是包含了兩次标記過程,主要是為了展示B-E過程中,并發情況新對象的配置設定。
G1 GC的分類和過程
JDK10 之前的G1中的GC隻有YoungGC,MixedGC。FullGC處理會交給單線程的Serial Old垃圾收集器。
YoungGC年輕代收集
在配置設定一般對象(非巨型對象)時,當所有eden region使用達到最大閥值并且無法申請足夠記憶體時,會觸發一次YoungGC。每次younggc會回收所有Eden以及Survivor區,并且将存活對象複制到Old區以及另一部分的Survivor區。到Old區的标準就是在PLAB中得到的計算結果。因為YoungGC會進行根掃描,是以會stop the world。
YoungGC的回收過程如下:
- 根掃描,跟CMS類似,Stop the world,掃描GC Roots對象。
- 處理Dirty card,更新RSet.
- 掃描RSet,掃描RSet中所有old區對掃描到的young區或者survivor去的引用。
- 拷貝掃描出的存活的對象到survivor2/old區
- 處理引用隊列,軟引用,弱引用,虛引用(下一篇優化中會再講一下這三種引用對gc的影響)
MixGC混合收集
MixedGC是G1 GC特有的,跟Full GC不同的是Mixed GC隻回收部分老年代的Region。哪些old region能夠放到CSet裡面,有很多參數可以控制。比如G1HeapWastePercent參數,在一次younggc之後,可以允許的堆垃圾百占比,超過這個值就會觸發mixedGC。G1MixedGCLiveThresholdPercent參數控制的,old代分區中的存活對象比,達到閥值時,這個old分區會被放入CSet。源碼可以看下gc/g1/collectionSetChooser。
MixedGC一般會發生在一次YoungGC後面,為了提高效率,MixedGC會複用YoungGC的全局的根掃描結果,因為這個Stop the world過程是必須的,整體上來說縮短了暫停時間。
MixGC的回收過程可以了解為YoungGC後附加的全局concurrent marking,全局的并發标記主要用來處理old區(包含H區)的存活對象标記,過程如下:
1. 初始标記(InitingMark)。标記GC Roots,會STW,一般會複用YoungGC的暫停時間。如前文所述,初始标記會設定好所有分區的NTAMS值。
2. 根分區掃描(RootRegionScan)。這個階段GC的線程可以和應用線程并發運作。其主要掃描初始标記以及之前YoungGC對象轉移到的Survivor分區,并标記Survivor區中引用的對象。是以此階段的Survivor分區也叫根分區(RootRegion)。部分源碼如下:
// 當有需要掃描的的S分區時,該Task會被開啟,掃描後會執行scan_finished,notify其他GC活動,如youngGC
class G1CMRootRegionScanTask : public AbstractGangTask {
G1ConcurrentMark* _cm;
public:
G1CMRootRegionScanTask(G1ConcurrentMark* cm) :
AbstractGangTask("G1 Root Region Scan"), _cm(cm) { }
void work(uint worker_id) {
assert(Thread::current()->is_ConcurrentGC_thread(),
"this should only be done by a conc GC thread");
G1CMRootRegions* root_regions = _cm->root_regions(); // _root_regions 初始化為待掃描的Survivor分區。
HeapRegion* hr = root_regions->claim_next();
while (hr != NULL) { // 循環分别處理所有待掃描的S分區
_cm->scan_root_region(hr, worker_id); //方法如下
hr = root_regions->claim_next();
}
}
};
// 掃描Survivor區 (HeapRegion* hr)
void G1ConcurrentMark::scan_root_region(HeapRegion* hr, uint worker_id) {
assert(hr->next_top_at_mark_start() == hr->bottom(), "invariant");
G1RootRegionScanClosure cl(_g1h, this, worker_id);
const uintx interval = PrefetchScanIntervalInBytes;
HeapWord* curr = hr->bottom(); // 掃描分區的bottom
const HeapWord* end = hr->top(); // 掃描分區的top
while (curr < end) { // 掃描所有bottom到top的分區的對象
Prefetch::read(curr, interval);
oop obj = oop(curr);
int size = obj->oop_iterate_size(&cl);
assert(size == obj->size(), "sanity");
curr += size;
}
}
3. 并發标記(ConcurrentMark)。會并發标記所有非完全空閑的分區的存活對象,也即使用了SATB算法,标記各個分區。
4. 最終标記(Remark)。主要處理SATB緩沖區,以及并發标記階段未标記到的漏網之魚(存活對象),會STW,可以參考上文的SATB處理。
5. 清除階段(Clean UP)。上述SATB也提到了,會進行bitmap的swap,以及PTAMS,NTAMS互換。整理堆分區,調整相應的RSet(比如如果其中記錄的Card中的對象都被回收,則這個卡片的也會從RSet中移除),如果識别到了完全空的分區,則會清理這個分區的RSet。這個過程會STW。
清除階段之後,還會對存活對象進行轉移(複制算法),轉移到其他可用分區,是以目前的分區就變成了新的可用分區。複制轉移主要是為了解決分區内的碎片問題。
FullGC
G1在對象複制/轉移失敗或者沒法配置設定足夠記憶體(比如巨型對象沒有足夠的連續分區配置設定)時,會觸發FullGC。FullGC使用的是stop the world的單線程的Serial Old模式,是以一旦觸發FullGC則會STW應用線程,并且執行效率很慢。JDK 8版本的G1是不提供Full gc的處理的。對于G1 GC的優化,很大的目标就是沒有FullGC。
總結
文章對目前JVM的幾種垃圾收集器做了簡單總結。詳細梳理了一下G1 GC的關鍵概念。本來想一起把G1 GC參數優化和GC Log也加上的,但篇幅有點長了,下一篇會加上[TODO->G1 垃圾收集器性能調優篇]。
本文内容都是基于JDK 8的版本的,在jdk10版本的G1 GC會有很多優化。Full CG方面,将提供并發标記的Full GC方案:Parallelize Mark-Sweep-Compact。Card Table的掃描也會得到加速。RSet也優化了,目前的RSet會存儲在所有的分區裡,新版本的RSet隻需要在CSet中,并且是在Remark到Clean階段之間并發建構RSet。這項優化會增加整個并發标記的周期,但是縮減了很多RSet的占用空間。另外,對于PauseTime會有更精準的處理,在MixedGC的對象拷貝階段,提供了可放棄拷貝的(Abortable)選項。MixedGC會計算下一個Region的對象拷貝,如果可能會超過預期的pause time,則會放棄這次拷貝。對于JDK10的G1 GC更多資訊可以看一下2018-Oracle G1 GC。
參考文獻
Garbage-First Garbage Collection
introduction-g1-garbage-collector
tuning-tips-G1-GC
2018-Oracle G1 GC