12 張圖帶你徹底理解 ZGC
大家好,我是君哥。今天來聊一聊 ZGC。
ZGC(Z Garbage Collector) 是一款性能比 G1 更加優秀的垃圾收集器。ZGC 第一次出現是在 JDK 11 中以實驗性的特性引入,這也是 JDK 11 中最大的亮點。在 JDK 15 中 ZGC 不再是實驗功能,可以正式投入生產使用了,使用 –XX:+UseZGC 可以啓用 ZGC。
ZGC 有 3 個重要特性:
- 暫停時間不會超過 10 ms。
JDK 16 發佈後,GC 暫停時間已經縮小到 1 ms 以內,並且時間複雜度是 o(1),這也就是說 GC 停頓時間是一個固定值了,並不會受堆內存大小影響。
下面圖片來自: https://malloc.se/blog/zgc-jdk16
-
最大支持 16TB 的大堆,最小支持 8MB 的小堆。
-
跟 G1 相比,對應用程序吞吐量的影響小於 15 %。
1 內存多重映射
內存多重映射,就是使用 mmap 把不同的虛擬內存地址映射到同一個物理內存地址上。如下圖:
ZGC 爲了更靈活高效地管理內存,使用了內存多重映射,把同一塊兒物理內存映射爲 Marked0、Marked1 和 Remapped 三個虛擬內存。
當應用程序創建對象時,會在堆上申請一個虛擬地址,這時 ZGC 會爲這個對象在 Marked0、Marked1 和 Remapped 這三個視圖空間分別申請一個虛擬地址,這三個虛擬地址映射到同一個物理地址。
Marked0、Marked1 和 Remapped 這三個虛擬內存作爲 ZGC 的三個視圖空間,在同一個時間點內只能有一個有效。ZGC 就是通過這三個視圖空間的切換,來完成併發的垃圾回收。
2 染色指針
2.1 三色標記回顧
我們知道 G1 垃圾收集器使用了三色標記,這裏先做一個回顧。下面是一個三色標記過程中的對象引用示例圖:
總共有三種顏色,說明如下:
-
白色:本對象還沒有被標記線程訪問過。
-
灰色:本對象已經被訪問過,但是本對象引用的其他對象還沒有被全部訪問。
-
黑色:本對象已經被訪問過,並且本對象引用的其他對象也都被訪問過了。
三色標記的過程如下:
-
初始階段,所有對象都是白色。
-
將 GC Roots 直接引用的對象標記爲灰色。
-
處理灰色對象,把當前灰色對象引用的所有對象都變成灰色,之後將當前灰色對象變成黑色。
-
重複步驟 3,直到不存在灰色對象爲止。
三色標記結束後,白色對象就是沒有被引用的對象(比如上圖中的 H 和 G),可以被回收了。
2.2 染色指針
ZGC 出現之前, GC 信息保存在對象頭的 Mark Word 中。比如 64 位的 JVM,對象頭的 Mark Word 中保存的信息如下圖:
前 62 位保存了 GC 信息,最後兩位保存了鎖標誌。
ZGC 的一大創舉是將 GC 信息保存在了染色指針上。染色指針是一種將少量信息直接存儲在指針上的技術。在 64 位 JVM 中,對象指針是 64 位,如下圖:
在這個 64 位的指針上,高 16 位都是 0,暫時不用來尋址。剩下的 48 位支持的內存可以達到 256 TB(2 ^48), 這可以滿足多數大型服務器的需要了。不過 ZGC 並沒有把 48 位都用來保存對象信息,而是用高 4 位保存了四個標誌位,這樣 ZGC 可以管理的最大內存可以達到 16 TB(2 ^ 44)。
通過這四個標誌位,JVM 可以從指針上直接看到對象的三色標記狀態(Marked0、Marked1)、是否進入了重分配集(Remapped)、是否需要通過 finalize 方法來訪問到(Finalizable)。
無需進行對象訪問就可以獲得 GC 信息,這大大提高了 GC 效率。
3 內存佈局
首先我們回顧一下 G1 垃圾收集器的內存佈局。G1 把整個堆分成了大小相同的 region,每個堆大約可以有 2048 個 region,每個 region 大小爲 1~32 MB (必須是 2 的次方)。如下圖:
跟 G1 類似,ZGC 的堆內存也是基於 Region 來分佈,不過 ZGC 是不區分新生代老年代的。不同的是,ZGC 的 Region 支持動態地創建和銷燬,並且 Region 的大小不是固定的,包括三種類型的 Region :
-
Small Region:2MB,主要用於放置小於 256 KB 的小對象。
-
Medium Region:32MB,主要用於放置大於等於 256 KB 小於 4 MB 的對象。
-
Large Region:N * 2MB。這個類型的 Region 是可以動態變化的,不過必須是 2MB 的整數倍,最小支持 4 MB。每個 Large Region 只放置一個大對象,並且是不會被重分配的。
4 讀屏障
讀屏障類似於 Spring AOP 的前置增強,是 JVM 嚮應用代碼中插入一小段代碼,當應用線程從堆中讀取對象的引用時,會先執行這段代碼。注意:只有從堆內存中讀取對象的引用時,纔會執行這個代碼。下面代碼只有第一行需要加入讀屏障。
Object o = obj.FieldA
Object p = o //不是從堆中讀取引用
o.dosomething() //不是從堆中讀取引用
int i = obj.FieldB //不是引用類型
讀屏障在解釋執行時通過 load 相關的字節碼指令加載數據。作用是在對象標記和轉移過程中,判斷對象的引用地址是否滿足條件,並作出相應動作。如下圖:
標記、轉移和重定位這些過程請看下一節。
讀屏障會對應用程序的性能有一定影響,據測試,對性能的最高影響達到 4%,但提高了 GC 併發能力,降低了 STW。
5 GC 過程
前面已經講過,ZGC 使用內存多重映射技術,把物理內存映射爲 Marked0、Marked1 和 Remapped 三個地址視圖,利用地址視圖的切換,ZGC 實現了高效的併發收集。
ZGC 的垃圾收集過程包括標記、轉移和重定位三個階段。如下圖:
ZGC 初始化後,整個內存空間的地址視圖被設置爲 Remapped。
5.1 初始標記
從 GC Roots 出發,找出 GC Roots 直接引用的對象,放入活躍對象集合,這個過程需要 STW,不過 STW 的時間跟 GC Roots 數量成正比,耗時比較短。
5.2 併發標記
併發標記過程中,GC 線程和 Java 應用線程會並行運行。這個過程需要注意下面幾點:
-
GC 標記線程訪問對象時,如果對象地址視圖是 Remapped,就把對象地址視圖切換到 Marked0,如果對象地址視圖已經是 Marked0,說明已經被其他標記線程訪問過了,跳過不處理。
-
標記過程中 Java 應用線程新創建的對象會直接進入 Marked0 視圖。
-
標記過程中 Java 應用線程訪問對象時,如果對象的地址視圖是 Remapped,就把對象地址視圖切換到 Marked0,可以參考前面講的讀屏障。
-
標記結束後,如果對象地址視圖是 Marked0,那就是活躍的,如果對象地址視圖是 Remapped,那就是不活躍的。
標記階段的活躍視圖也可能是 Marked1,爲什麼會採用兩個視圖呢?
這裏採用兩個視圖是爲了區分前一次標記和這一次標記。如果這次標記的視圖是 Marked0,那下一次併發標記就會把視圖切換到 Marked1。這樣做可以配合 ZGC 按照頁回收垃圾的做法。如下圖:
第二次標記的時候,如果還是切換到 Marked0,那麼 2 這個對象區分不出是活躍的還是上次標記過的。如果第二次標記切換到 Marked1,就可以區分出了。
這時 Marked0 這個視圖的對象就是上次標記過程被標記過活躍,轉移的時候沒有被轉移,但這次標記沒有被標記爲活躍的對象。Marked1 視圖的對象是這次標記被標記爲活躍的對象。Remapped 視圖的對象是上次垃圾回收發生轉移或者是被 Java 應用線程訪問過,本次垃圾回收中被標記爲不活躍的對象。
5.3 再標記
併發標記階段 GC 線程和 Java 應用線程併發執行,標記過程中可能會有引用關係發生變化而導致的漏標記問題。再標記階段重新標記併發標記階段發生變化的對象,還會對非強引用(軟應用,虛引用等)進行並行標記。
這個階段需要 STW,但是需要標記的對象少,耗時很短。
5.4 初始轉移
轉移就是把活躍對象複製到新的內存,之前的內存空間可以被回收。
初始轉移需要掃描 GC Roots 直接引用的對象並進行轉移,這個過程需要 STW,STW 時間跟 GC Roots 成正比。
5.5 併發轉移
併發轉移過程 GC 線程和 Java 線程是併發進行的。上面已經講過,轉移過程中對象視圖會被切回 Remapped 。轉移過程需要注意以下幾點:
-
如果 GC 線程訪問對象的視圖是 Marked0,則轉移對象,並把對象視圖設置成 Remapped。
-
如果 GC 線程訪問對象的視圖是 Remapped,說明被其他 GC 線程處理過,跳過不再處理。
-
併發轉移過程中 Java 應用線程創建的新對象地址視圖是 Remapped。
-
如果 Java 應用線程訪問的對象被標記爲活躍並且對象視圖是 Marked0,則轉移對象,並把對象視圖設置成 Remapped。
5.6 重定位
轉移過程對象的地址發生了變化,在這個階段,把所有指向對象舊地址的指針調整到對象的新地址上。
6 垃圾收集算法
ZGC 採用標記 - 整理算法,算法的思想是把所有存活對象移動到堆的一側,移動完成後回收掉邊界以外的對象。如下圖:
4.1 JDK 16 之前
在 JDK 16 之前,ZGC 會預留(Reserve)一塊兒堆內存,這個預留內存不能用於 Java 線程的內存分配。即使從 Java 線程的角度看堆內存已經滿了也不能使用 Reserve,只有 GC 過程中搬移存活對象的時候纔可以使用。如下圖:
這樣做的好處是算法簡單,非常適合並行收集。但這樣做有幾個問題:
-
因爲有預留內存,能給 Java 線程分配的堆內存小於 JVM 聲明的堆內存。
-
Reserve 僅僅用於存放 GC 過程中搬移的對象,有點內存浪費。
-
因爲 Reserve 不能給 GC 過程中搬移對象的 Java 線程使用,搬移線程可能會因爲申請不到足夠內存而不能完成對象搬移,這返回過來又會導致應用程序的 OOM。
4.2 JDK 16 改進
JDK 16 發佈後,ZGC 支持就地搬移對象(G1 在 Full GC 的時候也是就地搬移)。這樣做的好處是不用預留空閒內存了。如下圖:
不過就地搬移也有一定的挑戰。比如:必須考慮搬移對象的順序,否則可能會覆蓋尚未移動的對象。這就需要 GC 線程之間更好的進行協作,不利於併發收集,同時也會導致搬移對象的 Java 線程需要考慮什麼可以做什麼不可以做。
爲了獲得更好的 GC 表現,JDK 16 在支持就地搬移的同時,也支持預留(Reserve)堆內存的方式,並且 ZGC 不需要真的預留空閒的堆內存。默認情況下,只要有空閒的 region,ZGC 就會使用預留堆內存的方式,如果沒有空閒的 region,否則 ZGC 就會啓用就地搬移。如果有了空閒的 region, ZGC 又會切換到預留堆內存的搬移方式。
7 總結
內存多重映射和染色指針的引入,使 ZGC 的併發性能大幅度提升。
ZGC 只有 3 個需要 STW 的階段,其中初始標記和初始轉移只需要掃描所有 GC Roots,STW 時間 GC Roots 的數量成正比,不會耗費太多時間。再標記過程主要處理併發標記引用地址發生變化的對象,這些對象數量比較少,耗時非常短。可見整個 ZGC 的 STW 時間幾乎只跟 GC Roots 數量有關係,不會隨着堆大小和對象數量的變化而變化。
ZGC 也有一個缺點,就是浮動垃圾。因爲 ZGC 沒有分代概念,雖然 ZGC 的 STW 時間在 1ms 以內,但是 ZGC 的整個執行過程耗時還是挺長的。在這個過程中 Java 線程可能會創建大量的新對象,這些對象會成爲浮動垃圾,只能等下次 GC 的時候進行回收。
參考:
1.https://wiki.openjdk.java.net/display/zgc
2.https://openjdk.java.net/jeps/304
3.https://openjdk.java.net/jeps/376
4.https://malloc.se/blog/zgc-jdk16
5.https://mp.weixin.qq.com/s/ag5u2EPObx7bZr7hkcrOTg
6.https://mp.weixin.qq.com/s/FIr6r2dcrm1pqZj5Bubbmw
7.https://www.jianshu.com/p/664e4da05b2c
8.https://www.cnblogs.com/jimoer/p/13170249.html
9.https://www.jianshu.com/p/12544c0ad5c1
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/UuelBa8vasVbDjnUzlIs4w