「每日分享」Java程式設計師的榮光,聽R大論JDK11的ZGC

我在這裡,等風也等你

前言

ZGC來了 !!! Java程序員可以光榮的遠離討厭的GC停頓和調優了。ZGC的成績是,無論你開了多大的堆內存(1288G? 2T?),硬是能保證低於10毫秒的JVM停頓。

SPECjbb 2015基準測試,在128G的大堆下,最大停頓時間才 1.68ms (不是平均,不是90%,99%,是Max ! ),遠低於最初的目標-那保守的10ms,也遠勝前代的G1。

「每日分享」Java程序員的榮光,聽R大論JDK11的ZGC

大家的第一反應都是這麼顛覆性的東西怎麼來的,G1 通過每次只回收部分Region而不是全堆,改善了大堆下的停頓時間,但在普通大小的堆裡表現並沒驚喜,現在怎麼突然就翻天了,一點心理準備都沒有啊。

如果文章太長不想看下去,你只要記住R大下面這句話就夠了:

與標記對象的傳統算法相比,ZGC在指針上做標記,在訪問指針時加入Load Barrier(讀屏障),比如當對象正被GC移動,指針上的顏色就會不對,這個屏障就會先把指針更新為有效地址再返回,也就是,永遠只有單個對象讀取時有概率被減速,而不存在為了保持應用與GC一致而粗暴整體的Stop The World。

其實Azul JDK的皇牌 C4 垃圾收集 ,早就同樣以最高十毫秒停頓成為江湖傳說。 曾在Azul的R大, 看著JDK11 ZGC的算法和結果倍感熟悉,與ZGC的領隊Per Liden大大聊完之後,確認了ZGC跟Azul Pauseless GC,是,等,價,的。(R大御覽本文時 - 其他同學是預覽,R大是御覽,想半天,選定了“等價”這個字眼)

「每日分享」Java程序員的榮光,聽R大論JDK11的ZGC

(R大拍的Per大大在JVMLS)

嗯,如果你還有空,下面讓我們來繼續聊聊ZGC的八大特徵。

一、所有階段幾乎都是併發執行的


這裡的併發(Concurrent),說的是應用線程與GC線程齊頭並進,互不添堵。

說幾乎,就是還有三個非常短暫的STW的階段,所以ZGC並不是Zero Pause GC啦。

R大:“比如開始的Pause Mark Start階段,要做根集合(root set)掃描,包括全局變量啊、線程棧啊啥的裡面的對象指針,但不包括GC堆裡的對象指針,所以這個暫停就不會隨著GC堆的大小而變化(不過會根據線程的多少啊、線程棧的大小之類的而變化)” -- 因此ZGC可以拍胸脯,無論堆多大停頓都小於10ms。

二、併發執行的保證機制,就是Colored Pointer 和 Load Barrier

原理前面R大一句話已經說完了。Colored Pointer 從64位的指針中,借了幾位出來表示Finalizable、Remapped、Marked1、Marked0。 所以它不支持32位指針也不支持壓縮指針, 且堆的上限是4TB。

「每日分享」Java程序員的榮光,聽R大論JDK11的ZGC

有Load barrier在,就會在不同階段,根據指針顏色看看要不要做些特別的事情(Slow Path)。注意下圖裡只有第一種語句需要讀屏障,後面三種都不需要,比如值是原始類型的時候。

「每日分享」Java程序員的榮光,聽R大論JDK11的ZGC

R大還提到了ZGC的Load Value Barrier,與Red Hat的Shenandoah收集器的不同,後者選擇了70年代的比較基礎的Brooks Pointer ,而前者在也是很老的Baker barrier上加入了self healing的特性,比如下面的代碼:

Object a = obj.x;

Object b = obj.x;

兩行代碼都插入了讀屏障,但ZGC在第一個讀屏障之後,不但a的值是新的,self healing下obj.x的值自身也會修正,第二個讀屏障時就直接進入FastPath,沒有消耗了; 而Shenandoah 則不會修正obj.x的值,第二個讀屏障又要SlowPath一次。

三、像G1一樣劃分Region,但更加靈活

ZGC將堆劃分為Region作為清理,移動,以及並行GC線程工作分配的單位。

不過G1一開始就把堆劃分成固定大小的Region,而ZGC 可以有2MB,32MB,N× 2MB 三種Size Groups,動態地創建和銷燬Region,動態地決定Region的大小。

256k以下的對象分配在Small Page, 4M以下對象在Medium Page,以上在Large Page。

所以ZGC能更好的處理大對象的分配。

「每日分享」Java程序員的榮光,聽R大論JDK11的ZGC

四、和G1一樣會做Compacting-壓縮

CMS是Mark-Swap,標記過期對象後原地回收,這樣就會造成內存碎片,越來越難以找到連續的空間,直到發生Full GC才進行壓縮整理。

ZGC是Mark-Compact ,會將活著的對象都移動到另一個Region,整個回收掉原來的Region。

而G1 是 incremental copying collector,一樣會做壓縮。

下面粗略了幾十倍地過一波回收流程,小階段都被略過了哈:

1. Pause Mark Start -初始停頓標記


停頓JVM地標記Root對象,1,2,4三個被標為live。

「每日分享」Java程序員的榮光,聽R大論JDK11的ZGC

2. Concurrent Mark -併發標記

併發地遞歸標記其他對象,5和8也被標記為live。

「每日分享」Java程序員的榮光,聽R大論JDK11的ZGC

3. Relocate - 移動對象

對比發現3、6、7是過期對象,也就是中間的兩個灰色region需要被壓縮清理,所以陸續將4、5、8 對象移動到最右邊的新Region。移動過程中,有個forward table紀錄這種轉向。

「每日分享」Java程序員的榮光,聽R大論JDK11的ZGC

R大這裡又讚揚了一下C4/ZGC的Quick Release特性:活的對象都移走之後,這個region可以立即釋放掉,並且用來當作下一個要掃描的region的to region。所以理論上要收集整個堆,只需要有一個空region就OK了。

而RedHat的Shenandoah 因為它的forward pointer的設計,則需要有1/2個Heap是空的。

4. Remap - 修正指針

最後將指針都妥帖地更新指向新地址。這裡R大還提到一個亮點: “上一個階段的Remap,和下一個階段的Mark是混搭在一起完成的,這樣非常高效,省卻了重複遍歷對象圖的開銷。”

「每日分享」Java程序員的榮光,聽R大論JDK11的ZGC

五、沒有G1佔內存的Remember Set,沒有Write Barrier的開銷

G1 保證“每次GC停頓時間不會過長”的方式,是“每次只清理一部分而不是全部的Region”的增量式清理。

那獨立清理某個Region時 , 就需要有RememberSet來記錄Region之間的對象引用關係, 這樣就能依賴它來輔助計算對象的存活性而不用掃描全堆, RS通常佔了整個Heap的20%或更高。

這裡還需要使用Write Barrier(寫屏障)技術,G1在平時寫引用時,GC移動對象時,都要同步去更新RememberSe,跟蹤跨代跨Region間的引用,特別的重。而CMS裡只有新老生代間的CardTable,要輕很多。

ZGC幾乎沒有停頓,所以劃分Region並不是為了增量回收,每次都會對所有Region進行回收,所以也就不需要這個佔內存的RememberSet了,又因為它暫時連分代都還沒實現,所以完全沒有Write Barrier。

六、支持Numa架構

現在多CPU插槽的服務器都是Numa架構了,比如兩顆CPU插槽(24核),64G內存的服務器,那其中一顆CPU上的12個核,訪問從屬於它的32G本地內存,要比訪問另外32G遠端內存要快得多。

JDK的 Parallel Scavenger 算法支持Numa架構,在SPEC JBB 2005 基準測試裡獲得40%的提升。

原理嘛,就是申請堆內存時,對每個Numa Node的內存都申請一些,當一條線程分配對象時,根據當前是哪個CPU在運行的,就在靠近這個CPU的內存中分配,這條線程繼續往下走,通常會重新訪問這個對象,而且如果線程還沒被切換出去,就還是這位CPU同志在訪問,所以就快了。

但可惜CMS,G1不支持Numa,現在ZGC 又重新做了簡單支持,哈哈哈。

R大補充,G1也打算支持了Numa了: http://openjdk.java.net/jeps/157

七、並行

在ZGC 官網上有介紹,前面基準測試中的32核服務器,128G堆的場景下,它的配置是:

20條ParallelGCThreads,在那三個極短的STW階段並行的幹活 - mark roots, weak root processing(StringTable, JNI Weak Handles,etc)和 relocate roots ;

4條ConcGCThreads,在其他階段與應用併發地幹活 - Mark,Process Reference,Relocate。 僅僅四條,高風亮節地儘量不與應用爭搶CPU 。

ConcCGCThreads開始時各自忙著自己平均分配下來的Region,如果有線程先忙完了,會嘗試“偷”其他線程還沒做的Region來幹活,非常勤奮。

八、單代

沒分代,應該是ZGC唯一的弱點了。所以R大說ZGC的水平,處於AZul早期的PauselessGC 與 分代的C4算法之間 - C4在代碼裡就叫GPGC,Generational Pauseless GC。

分代原本是因為most object die young的假設,而讓新生代和老生代使用不同的GC算法。但C4已經是全程併發算法了,為什麼還要分代呢?

R大說:

“因為分代的C4能承受的對象分配速度(Allocation Rate), 大概是原始PGC的10倍。

如果對整個堆做一個完整併發收集週期,持續的時間可能很長比如幾分鐘,而此期間新創建的對象,大致上只能當作活對象來處理,即使它們在這週期裡其實早就死掉可以被收集了。如果有分代算法,新生對象都在一個專門的區域創建,專門針對這個區域的收集能更頻繁更快,意外留活的對象更也少。

而Per大大因為分代實現起來麻煩,就先實現出比較簡單可用的單代版本。所以ZGC如果遇上非常高的對象分配速率,目前唯一有效的“調優”方式就是增大整個GC堆的大小來讓ZGC有更大的喘息空間。”


分享到:


相關文章: