12 張圖帶你徹底理解 ZGC
大家好,我是君哥。今天來聊一聊 ZGC。
ZGC(Z Garbage Collector) 是一款性能比 G1 更加優(yōu)秀的垃圾收集器。ZGC 第一次出現(xiàn)是在 JDK 11 中以實(shí)驗(yàn)性的特性引入,這也是 JDK 11 中最大的亮點(diǎn)。在 JDK 15 中 ZGC 不再是實(shí)驗(yàn)功能,可以正式投入生產(chǎn)使用了,使用 –XX:+UseZGC 可以啟用 ZGC。
ZGC 有 3 個(gè)重要特性:
- 暫停時(shí)間不會(huì)超過 10 ms。
JDK 16 發(fā)布后,GC 暫停時(shí)間已經(jīng)縮小到 1 ms 以內(nèi),并且時(shí)間復(fù)雜度是 o(1),這也就是說 GC 停頓時(shí)間是一個(gè)固定值了,并不會(huì)受堆內(nèi)存大小影響。
下面圖片來自:https://malloc.se/blog/zgc-jdk16
- 最大支持 16TB 的大堆,最小支持 8MB 的小堆。
- 跟 G1 相比,對應(yīng)用程序吞吐量的影響小于 15 %。
1.內(nèi)存多重映射
內(nèi)存多重映射,就是使用 mmap 把不同的虛擬內(nèi)存地址映射到同一個(gè)物理內(nèi)存地址上。如下圖:
ZGC 為了更靈活高效地管理內(nèi)存,使用了內(nèi)存多重映射,把同一塊兒物理內(nèi)存映射為 Marked0、Marked1 和 Remapped 三個(gè)虛擬內(nèi)存。
當(dāng)應(yīng)用程序創(chuàng)建對象時(shí),會(huì)在堆上申請一個(gè)虛擬地址,這時(shí) ZGC 會(huì)為這個(gè)對象在 Marked0、Marked1 和 Remapped 這三個(gè)視圖空間分別申請一個(gè)虛擬地址,這三個(gè)虛擬地址映射到同一個(gè)物理地址。
Marked0、Marked1 和 Remapped 這三個(gè)虛擬內(nèi)存作為 ZGC 的三個(gè)視圖空間,在同一個(gè)時(shí)間點(diǎn)內(nèi)只能有一個(gè)有效。ZGC 就是通過這三個(gè)視圖空間的切換,來完成并發(fā)的垃圾回收。
2.染色指針
2.1 三色標(biāo)記回顧
我們知道 G1 垃圾收集器使用了三色標(biāo)記,這里先做一個(gè)回顧。下面是一個(gè)三色標(biāo)記過程中的對象引用示例圖:
總共有三種顏色,說明如下:
- 白色:本對象還沒有被標(biāo)記線程訪問過。
- 灰色:本對象已經(jīng)被訪問過,但是本對象引用的其他對象還沒有被全部訪問。
- 黑色:本對象已經(jīng)被訪問過,并且本對象引用的其他對象也都被訪問過了。
三色標(biāo)記的過程如下:
- 初始階段,所有對象都是白色。
- 將 GC Roots 直接引用的對象標(biāo)記為灰色。
- 處理灰色對象,把當(dāng)前灰色對象引用的所有對象都變成灰色,之后將當(dāng)前灰色對象變成黑色。
- 重復(fù)步驟 3,直到不存在灰色對象為止。
三色標(biāo)記結(jié)束后,白色對象就是沒有被引用的對象(比如上圖中的 H 和 G),可以被回收了。
2.2 染色指針
ZGC 出現(xiàn)之前, GC 信息保存在對象頭的 Mark Word 中。比如 64 位的 JVM,對象頭的 Mark Word 中保存的信息如下圖:
前 62位保存了 GC 信息,最后兩位保存了鎖標(biāo)志。
ZGC 的一大創(chuàng)舉是將 GC 信息保存在了染色指針上。染色指針是一種將少量信息直接存儲(chǔ)在指針上的技術(shù)。在 64 位 JVM 中,對象指針是 64 位,如下圖:
在這個(gè) 64 位的指針上,高 16 位都是 0,暫時(shí)不用來尋址。剩下的 48 位支持的內(nèi)存可以達(dá)到 256 TB(2 ^48),這可以滿足多數(shù)大型服務(wù)器的需要了。不過 ZGC 并沒有把 48 位都用來保存對象信息,而是用高 4 位保存了四個(gè)標(biāo)志位,這樣 ZGC 可以管理的最大內(nèi)存可以達(dá)到 16 TB(2 ^ 44)。
通過這四個(gè)標(biāo)志位,JVM 可以從指針上直接看到對象的三色標(biāo)記狀態(tài)(Marked0、Marked1)、是否進(jìn)入了重分配集(Remapped)、是否需要通過 finalize 方法來訪問到(Finalizable)。
無需進(jìn)行對象訪問就可以獲得 GC 信息,這大大提高了 GC 效率。
3.內(nèi)存布局
首先我們回顧一下 G1 垃圾收集器的內(nèi)存布局。G1把整個(gè)堆分成了大小相同的 region,每個(gè)堆大約可以有 2048 個(gè)region,每個(gè) region 大小為 1~32 MB (必須是 2 的次方)。如下圖:
- 跟 G1 類似,ZGC 的堆內(nèi)存也是基于 Region 來分布,不過 ZGC 是不區(qū)分新生代老年代的。不同的是,ZGC 的 Region 支持動(dòng)態(tài)地創(chuàng)建和銷毀,并且 Region 的大小不是固定的,包括三種類型的 Region :
- Small Region:2MB,主要用于放置小于 256 KB 的小對象。
- Medium Region:32MB,主要用于放置大于等于 256 KB 小于 4 MB 的對象。
- Large Region:N * 2MB。這個(gè)類型的 Region 是可以動(dòng)態(tài)變化的,不過必須是 2MB 的整數(shù)倍,最小支持 4 MB。每個(gè) Large Region 只放置一個(gè)大對象,并且是不會(huì)被重分配的。
4.讀屏障
讀屏障類似于 Spring AOP 的前置增強(qiáng),是 JVM 向應(yīng)用代碼中插入一小段代碼,當(dāng)應(yīng)用線程從堆中讀取對象的引用時(shí),會(huì)先執(zhí)行這段代碼。注意:只有從堆內(nèi)存中讀取對象的引用時(shí),才會(huì)執(zhí)行這個(gè)代碼。下面代碼只有第一行需要加入讀屏障。
Object o = obj.FieldA
Object p = o //不是從堆中讀取引用
o.dosomething() //不是從堆中讀取引用
int i = obj.FieldB //不是引用類型
讀屏障在解釋執(zhí)行時(shí)通過 load 相關(guān)的字節(jié)碼指令加載數(shù)據(jù)。作用是在對象標(biāo)記和轉(zhuǎn)移過程中,判斷對象的引用地址是否滿足條件,并作出相應(yīng)動(dòng)作。如下圖:
標(biāo)記、轉(zhuǎn)移和重定位這些過程請看下一節(jié)。
讀屏障會(huì)對應(yīng)用程序的性能有一定影響,據(jù)測試,對性能的最高影響達(dá)到 4%,但提高了 GC 并發(fā)能力,降低了 STW。
5.GC 過程
前面已經(jīng)講過,ZGC 使用內(nèi)存多重映射技術(shù),把物理內(nèi)存映射為 Marked0、Marked1 和 Remapped 三個(gè)地址視圖,利用地址視圖的切換,ZGC 實(shí)現(xiàn)了高效的并發(fā)收集。
ZGC 的垃圾收集過程包括標(biāo)記、轉(zhuǎn)移和重定位三個(gè)階段。如下圖:
ZGC 初始化后,整個(gè)內(nèi)存空間的地址視圖被設(shè)置為 Remapped。
5.1 初始標(biāo)記
從 GC Roots 出發(fā),找出 GC Roots 直接引用的對象,放入活躍對象集合,這個(gè)過程需要 STW,不過STW 的時(shí)間跟 GC Roots 數(shù)量成正比,耗時(shí)比較短。
5.2 并發(fā)標(biāo)記
并發(fā)標(biāo)記過程中,GC 線程和 Java 應(yīng)用線程會(huì)并行運(yùn)行。這個(gè)過程需要注意下面幾點(diǎn):
- GC 標(biāo)記線程訪問對象時(shí),如果對象地址視圖是 Remapped,就把對象地址視圖切換到 Marked0,如果對象地址視圖已經(jīng)是 Marked0,說明已經(jīng)被其他標(biāo)記線程訪問過了,跳過不處理。
- 標(biāo)記過程中Java 應(yīng)用線程新創(chuàng)建的對象會(huì)直接進(jìn)入 Marked0 視圖。
- 標(biāo)記過程中Java 應(yīng)用線程訪問對象時(shí),如果對象的地址視圖是 Remapped,就把對象地址視圖切換到 Marked0,可以參考前面講的讀屏障。
- 標(biāo)記結(jié)束后,如果對象地址視圖是 Marked0,那就是活躍的,如果對象地址視圖是 Remapped,那就是不活躍的。
標(biāo)記階段的活躍視圖也可能是 Marked1,為什么會(huì)采用兩個(gè)視圖呢?
這里采用兩個(gè)視圖是為了區(qū)分前一次標(biāo)記和這一次標(biāo)記。如果這次標(biāo)記的視圖是 Marked0,那下一次并發(fā)標(biāo)記就會(huì)把視圖切換到 Marked1。這樣做可以配合 ZGC 按照頁回收垃圾的做法。如下圖:
第二次標(biāo)記的時(shí)候,如果還是切換到 Marked0,那么 2 這個(gè)對象區(qū)分不出是活躍的還是上次標(biāo)記過的。如果第二次標(biāo)記切換到 Marked1,就可以區(qū)分出了。
這時(shí) Marked0 這個(gè)視圖的對象就是上次標(biāo)記過程被標(biāo)記過活躍,轉(zhuǎn)移的時(shí)候沒有被轉(zhuǎn)移,但這次標(biāo)記沒有被標(biāo)記為活躍的對象。Marked1 視圖的對象是這次標(biāo)記被標(biāo)記為活躍的對象。Remapped 視圖的對象是上次垃圾回收發(fā)生轉(zhuǎn)移或者是被 Java 應(yīng)用線程訪問過,本次垃圾回收中被標(biāo)記為不活躍的對象。
5.3 再標(biāo)記
并發(fā)標(biāo)記階段 GC 線程和 Java 應(yīng)用線程并發(fā)執(zhí)行,標(biāo)記過程中可能會(huì)有引用關(guān)系發(fā)生變化而導(dǎo)致的漏標(biāo)記問題。再標(biāo)記階段重新標(biāo)記并發(fā)標(biāo)記階段發(fā)生變化的對象,還會(huì)對非強(qiáng)引用(軟應(yīng)用,虛引用等)進(jìn)行并行標(biāo)記。
這個(gè)階段需要 STW,但是需要標(biāo)記的對象少,耗時(shí)很短。
5.4 初始轉(zhuǎn)移
轉(zhuǎn)移就是把活躍對象復(fù)制到新的內(nèi)存,之前的內(nèi)存空間可以被回收。
初始轉(zhuǎn)移需要掃描 GC Roots 直接引用的對象并進(jìn)行轉(zhuǎn)移,這個(gè)過程需要 STW,STW 時(shí)間跟 GC Roots 成正比。
5.5 并發(fā)轉(zhuǎn)移
并發(fā)轉(zhuǎn)移過程 GC 線程和 Java 線程是并發(fā)進(jìn)行的。上面已經(jīng)講過,轉(zhuǎn)移過程中對象視圖會(huì)被切回 Remapped 。轉(zhuǎn)移過程需要注意以下幾點(diǎn):
如果 GC 線程訪問對象的視圖是 Marked0,則轉(zhuǎn)移對象,并把對象視圖設(shè)置成 Remapped。
如果 GC 線程訪問對象的視圖是 Remapped,說明被其他 GC 線程處理過,跳過不再處理。
并發(fā)轉(zhuǎn)移過程中 Java 應(yīng)用線程創(chuàng)建的新對象地址視圖是 Remapped。
如果 Java 應(yīng)用線程訪問的對象被標(biāo)記為活躍并且對象視圖是 Marked0,則轉(zhuǎn)移對象,并把對象視圖設(shè)置成 Remapped。
5.6 重定位
轉(zhuǎn)移過程對象的地址發(fā)生了變化,在這個(gè)階段,把所有指向?qū)ο笈f地址的指針調(diào)整到對象的新地址上。
6.垃圾收集算法
ZGC 采用標(biāo)記 - 整理算法,算法的思想是把所有存活對象移動(dòng)到堆的一側(cè),移動(dòng)完成后回收掉邊界以外的對象。如下圖:
6.1 JDK 16 之前
在 JDK 16 之前,ZGC 會(huì)預(yù)留(Reserve)一塊兒堆內(nèi)存,這個(gè)預(yù)留內(nèi)存不能用于 Java 線程的內(nèi)存分配。即使從 Java 線程的角度看堆內(nèi)存已經(jīng)滿了也不能使用 Reserve,只有 GC 過程中搬移存活對象的時(shí)候才可以使用。如下圖:
這樣做的好處是算法簡單,非常適合并行收集。但這樣做有幾個(gè)問題:
因?yàn)橛蓄A(yù)留內(nèi)存,能給 Java 線程分配的堆內(nèi)存小于 JVM 聲明的堆內(nèi)存。
Reserve 僅僅用于存放 GC 過程中搬移的對象,有點(diǎn)內(nèi)存浪費(fèi)。
因?yàn)?Reserve 不能給 GC 過程中搬移對象的 Java 線程使用,搬移線程可能會(huì)因?yàn)樯暾埐坏阶銐騼?nèi)存而不能完成對象搬移,這返回過來又會(huì)導(dǎo)致應(yīng)用程序的 OOM。
6.2 JDK 16 改進(jìn)
JDK 16 發(fā)布后,ZGC 支持就地搬移對象(G1 在 Full GC 的時(shí)候也是就地搬移)。這樣做的好處是不用預(yù)留空閑內(nèi)存了。如下圖:
不過就地搬移也有一定的挑戰(zhàn)。比如:必須考慮搬移對象的順序,否則可能會(huì)覆蓋尚未移動(dòng)的對象。這就需要 GC 線程之間更好的進(jìn)行協(xié)作,不利于并發(fā)收集,同時(shí)也會(huì)導(dǎo)致搬移對象的 Java 線程需要考慮什么可以做什么不可以做。
為了獲得更好的 GC 表現(xiàn),JDK 16 在支持就地搬移的同時(shí),也支持預(yù)留(Reserve)堆內(nèi)存的方式,并且 ZGC 不需要真的預(yù)留空閑的堆內(nèi)存。默認(rèn)情況下,只要有空閑的 region,ZGC 就會(huì)使用預(yù)留堆內(nèi)存的方式,如果沒有空閑的 region,否則 ZGC 就會(huì)啟用就地搬移。如果有了空閑的 region, ZGC 又會(huì)切換到預(yù)留堆內(nèi)存的搬移方式。
7.總結(jié)
內(nèi)存多重映射和染色指針的引入,使 ZGC 的并發(fā)性能大幅度提升。
ZGC 只有 3 個(gè)需要 STW 的階段,其中初始標(biāo)記和初始轉(zhuǎn)移只需要掃描所有 GC Roots,STW 時(shí)間 GC Roots 的數(shù)量成正比,不會(huì)耗費(fèi)太多時(shí)間。再標(biāo)記過程主要處理并發(fā)標(biāo)記引用地址發(fā)生變化的對象,這些對象數(shù)量比較少,耗時(shí)非常短??梢娬麄€(gè) ZGC 的 STW 時(shí)間幾乎只跟 GC Roots 數(shù)量有關(guān)系,不會(huì)隨著堆大小和對象數(shù)量的變化而變化。
ZGC 也有一個(gè)缺點(diǎn),就是浮動(dòng)垃圾。因?yàn)?ZGC 沒有分代概念,雖然 ZGC 的 STW 時(shí)間在 1ms 以內(nèi),但是 ZGC 的整個(gè)執(zhí)行過程耗時(shí)還是挺長的。在這個(gè)過程中 Java 線程可能會(huì)創(chuàng)建大量的新對象,這些對象會(huì)成為浮動(dòng)垃圾,只能等下次 GC 的時(shí)候進(jìn)行回收。