號稱能將STW幹掉1ms以內的Java垃圾收集器ZGC到底是個什麼東西?

CoderW喜歡寫部落格發表於2021-01-15

ZGC介紹

ZGC(The Z Garbage Collector)是JDK 11中推出的一款追求極致低延遲的實驗性質的垃圾收集器,它曾經設計目標包括:

  • 停頓時間不超過10ms;
  • 停頓時間不會隨著堆的大小,或者活躍物件的大小而增加;
  • 支援8MB~4TB級別的堆(未來支援16TB)。

當初,提出這個目標的時候,有很多人都覺得設計者在吹牛逼。

但今天看來,這些“吹下的牛逼”都在一個個被實現。

基於最新的JDK15來看,“停頓時間不超過10ms”和“支援16TB的堆”這兩個目標已經實現,並且官方明確指出JDK15中的ZGC不再是實驗性質的垃圾收集器,且建議投入生產了。

ZGC已經熟了,面試題還會遠嗎?

本文會從ZGC的設計思路出發,講清楚為何ZGC能在低延時場景中的應用中有著如此卓越的表現。

核心技術

多重對映

為了能更好的理解ZGC的記憶體管理,我們先看一下這個例子:

你在你爸爸媽媽眼中是兒子,在你女朋友眼中是男朋友。在全世界人面前就是最帥的人。你還有一個名字,但名字也只是你的一個代號,並不是你本人。將這個關係畫一張對映圖表示:

image-20210109170619143
  • 在你爸爸的眼中,你就是兒子;
  • 在你女朋友的眼中,你就說男朋友;
  • 站在全世界角度來看,你就說世界上最帥的人;

假如你的名字是全世界唯一的,通過“你的名字”、“你爸爸的兒子”、“你女朋友的男朋友”,“世界上最帥的人”最後定位到的都是你本人。

現在我們再來看看ZGC的記憶體管理。

ZGC為了能高效、靈活地管理記憶體,實現了兩級記憶體管理:虛擬記憶體和實體記憶體,並且實現了實體記憶體和虛擬記憶體的對映關係。這和作業系統中虛擬地址和實體地址設計思路基本一致。

當應用程式建立物件時,首先在堆空間申請一個虛擬地址,ZGC同時會為該物件在Marked0、Marked1和Remapped三個檢視空間分別申請一個虛擬地址,且這三個虛擬地址對應同一個實體地址。

image-20210108203719381

圖中的Marked0、Marked1和Remapped三個檢視是什麼意思呢?

對照上面的例子,這三個檢視分別對應的就是"你爸爸眼中",“你女朋友的眼中”,“全世界人眼中”。

而三個檢視裡面的地址,都是虛擬地址,對應的是“你爸爸眼中的兒子”,“你女朋友眼中的男朋友”......

最後,這些虛地址都能定位到一個實體地址,這個實體地址對應上面例子中的“你本人”。

用一段簡單的Java程式碼表示就是這樣的:

image-20210109183016935

在ZGC中這三個空間在同一時間點有且僅有一個空間有效。

為什麼這麼設計呢?這就是ZGC的高明之處,利用虛擬空間換時間,這三個空間的切換是由垃圾回收的不同階段觸發的,通過限定三個空間在同一時間點有且僅有一個空間有效高效的完成GC過程的併發操作,具體實現會後面講ZGC併發處理演算法的部分再詳細描述。

染色指標

在講ZGC併發處理演算法之前,還需要補充一個知識點——染色指標。

我們都知道,之前的垃圾收集器都是把GC資訊(標記資訊、GC分代年齡..)存在物件頭的Mark Word裡。舉個例子:

如果某個人是個垃圾人,就在這個人的頭上蓋一個“垃圾”的章;如果這個人不是垃圾了,就把這個人頭上的“垃圾”印章洗掉。

而ZGC是這樣做的:

如果某個人是垃圾人。就在這個人的身份證資訊裡面標註這個人是個垃圾,以後不管這個人在哪刷身份證,別人都知道他是個垃圾人了。也許哪一天,這個人醒悟了不再是垃圾人了,就把這個人身份證裡面的“垃圾”標誌去掉。

在這例子中,“這個人”就是一個物件,而“身份證”就是指向這個物件的指標。

ZGC將資訊儲存在指標中,這種技術有一個高大上的名字——染色指標(Colored Pointer)。

image-20210107185850957

在64位的機器中,物件指標是64位的。

  • ZGC使用64位地址空間的第0~43位儲存物件地址,2^44 = 16TB,所以ZGC最大支援16TB的堆。
  • 而第44~47位作為顏色標誌位,Marked0、Marked1和Remapped代表三個檢視標誌位,Finalizable表示這個物件只能通過finalizer才能訪問。
  • 第48~63位固定為0沒有利用。

讀屏障

讀屏障是JVM嚮應用程式碼插入一小段程式碼的技術。當應用執行緒從堆中讀取物件引用時,就會執行這段程式碼。千萬不要把這個讀屏障和Java記憶體模型裡面的讀屏障搞混了,兩者根本不是同一個東西,ZGC中的讀屏障更像是一種AOP技術,在位元組碼層面或者編譯程式碼層面給讀操作增加一個額外的處理。

讀屏障例項:

Object o = obj.FieldA      // 從堆中讀取物件引用,需要加入讀屏障
<load barrier needed here>
  
Object p = o               // 無需加入讀屏障,因為不是從堆中讀取引用
o.dosomething()            // 無需加入讀屏障,因為不是從堆中讀取引用
int i =  obj.FieldB        // 無需加入讀屏障,因為不是物件引用

ZGC中讀屏障的程式碼作用:

GC執行緒和應用執行緒是併發執行的,所以存在應用執行緒去A物件內部的引用所指向的物件B的時候,這個物件B正在被GC執行緒移動或者其他操作,加上讀屏障之後,應用執行緒會去探測物件B是否被GC執行緒操作,然後等待操作完成再讀取物件,確保資料的準確性。具體的探測和操作步驟如下:

image-20210109214711345

這樣會影響程式的效能嗎?

會。據測試,最多百分之4的效能損耗。但這是ZGC併發轉移的基礎,為了降低STW,設計者認為這點犧牲是可接受的。

ZGC併發處理演算法

ZGC併發處理演算法利用全域性空間檢視的切換和物件地址檢視的切換,結合SATB演算法實現了高效的併發。

以上所有的鋪墊,都是為了講清楚ZGC的併發處理演算法,在一些博文上,都說染色指標和讀屏障是ZGC的核心,但都沒有講清楚兩者是如何在演算法裡面被利用的,我認為,ZGC的併發處理演算法才是ZGC的核心,染色指標和讀屏障只不過是為演算法服務而已。

ZGC的併發處理演算法三個階段的全域性檢視切換如下:

  • 初始化階段:ZGC初始化之後,整個記憶體空間的地址檢視被設定為Remapped
  • 標記階段:當進入標記階段時的檢視轉變為Marked0(以下皆簡稱M0)或者Marked1(以下皆簡稱M1)
  • 轉移階段:從標記階段結束進入轉移階段時的檢視再次設定為Remapped
image-20210109225947144

標記階段

標記階段全域性檢視切換到M0檢視。因為應用程式和標記執行緒併發執行,那麼物件的訪問可能來自標記執行緒和應用程式執行緒。

image-20210110023544830

在標記階段結束之後,物件的地址檢視要麼是M0,要麼是Remapped。

  • 如果物件的地址檢視是M0,說明物件是活躍的;
  • 如果物件的地址檢視是Remapped,說明物件是不活躍的,即物件所使用的記憶體可以被回收。

當標記階段結束後,ZGC會把所有活躍物件的地址存到物件活躍資訊表,活躍物件的地址檢視都是M0。

image-20210110015433901

轉移階段

轉移階段切換到Remapped檢視。因為應用程式和轉移執行緒也是併發執行,那麼物件的訪問可能來自轉移執行緒和應用程式執行緒。

image-20210110023443317

至此,ZGC的一個垃圾回收週期中,併發標記和併發轉移就結束了。

為何要設計M0和M1

我們提到在標記階段存在兩個地址檢視M0和M1,上面的演算法過程顯示只用到了一個地址檢視,為什麼設計成兩個?簡單地說是為了區別前一次標記和當前標記。

ZGC是按照頁面進行部分記憶體垃圾回收的,也就是說當物件所在的頁面需要回收時,頁面裡面的物件需要被轉移,如果頁面不需要轉移,頁面裡面的物件也就不需要轉移。

image-20210110024405384

如圖,這個物件在第二次GC週期開始的時候,地址檢視還是M0。如果第二次GC的標記階段還切到M0檢視的話,就不能區分出物件是活躍的,還是上一次垃圾回收標記過的。這個時候,第二次GC週期的標記階段切到M1檢視的話就可以區分了,此時這3個地址檢視代表的含義是:

  • M1:本次垃圾回收中識別的活躍物件。

  • M0:前一次垃圾回收的標記階段被標記過的活躍物件,物件在轉移階段未被轉移,但是在本次垃圾回收中被識別為不活躍物件。

  • Remapped:前一次垃圾回收的轉移階段發生轉移的物件或者是被應用程式執行緒訪問的物件,但是在本次垃圾回收中被識別為不活躍物件。

現在,我們可以回答“使用地址檢視和染色指標有什麼好處”這個問題了

使用地址檢視和染色指標可以加快標記和轉移的速度。以前的垃圾回收器通過修改物件頭的標記位來標記GC資訊,這是有記憶體存取訪問的,而ZGC通過地址檢視和染色指標技術,無需任何物件訪問,只需要設定地址中對應的標誌位即可。這就是ZGC在標記和轉移階段速度更快的原因。

當GC資訊不再儲存在物件頭上時而存在引用指標上時,當確定一個物件已經無用的時候,可以立即重用對應的記憶體空間,這是把GC資訊放到物件頭所做不到的。

ZGC步驟

ZGC採用的是標記-複製演算法,標記、轉移和重定位階段幾乎都是併發的,ZGC垃圾回收週期如下圖所示:

image-20210110031200708

ZGC只有三個STW階段:初始標記再標記初始轉移

其中,初始標記和初始轉移分別都只需要掃描所有GC Roots,其處理時間和GC Roots的數量成正比,一般情況耗時非常短;

再標記階段STW時間很短,最多1ms,超過1ms則再次進入併發標記階段。即,ZGC幾乎所有暫停都只依賴於GC Roots集合大小,停頓時間不會隨著堆的大小或者活躍物件的大小而增加。與ZGC對比,G1的轉移階段完全STW的,且停頓時間隨存活物件的大小增加而增加。

ZGC的發展

ZGC誕生於JDK11,經過不斷的完善,JDK15中的ZGC已經不再是實驗性質的了。

從只支援Linux/x64,到現在支援多平臺;從不支援指標壓縮,到支援壓縮類指標.....

image-20210107152628306

在JDK16,ZGC將支援併發執行緒棧掃描(Concurrent Thread Stack Scanning),根據SPECjbb2015測試結果,實現併發執行緒棧掃描之後,ZGC的STW時間又能降低一個數量級,停頓時間將進入毫秒時代。

image-20210109153519927

ZGC已然是一款優秀的垃圾收集器了,它借鑑了Pauseless GC,也似乎在朝著C4 GC的方向發展——引入分代思想。

Oracle的努力,讓我們開發者看到了商用級別的GC“飛入尋常百姓家”的希望,隨著JDK的發展,我相信在未來的某一天,JVM調優這種反人類的操作將不復存在,底層的GC會自適應各種情況自動優化。

ZGC確實是Java的最前沿的技術,但在G1都沒有普及的今天,談論ZGC似乎為時過早。但也許我們探討的不是ZGC,而是ZGC背後的設計思路。

希望你能有所收穫!

寫在最後

為了對每一篇發出去的文章負責,力求準確,我一般是參考官方文件和業界權威的書籍,有些時候,還需要看一些論文,看一部分原始碼。而官方文件和論文一般都是英文,對於一個英語四級只考了456分的人來說,非常艱難,整個過程都是谷歌翻譯和有道詞典陪伴著我的。因為一些專業術語翻譯的不夠準確,還需要英文和翻譯對照慢慢理解。

但即使這樣,也難免會有紕漏,如果你發現了,歡迎提出,我會對其修正。

你的正反饋對我來說非常重要,點個贊,點個再看,點個關注都是對我最大的支援!

謝謝您的閱讀,我們下期再見!

相關文章