本文的閱讀有必定的門檻,請先了解 GC 的基本只知識。前端
現代垃圾收集器的演進大部分都是往減小停頓方向發展。java
像 CMS 就是分離出一些階段使得應用線程能夠和垃圾回收線程併發,固然還有利用回收線程的並行來減小停頓的時間。面試
基本上 STW 階段都是利用多線程並行來減小停頓時間,而併發階段不會有太多的回收線程工做,這是爲了避免和應用線程爭搶 CPU,反正都併發了慢就慢點(不過仍是得考慮內存分配速率)。算法
而 G1 能夠認爲是打開了另外一個方向的大門:只回收部分垃圾來減小停頓時間。微信
不過爲了達到只回收部分 reigon,每一個 region 都須要 RememberSet 來記錄各 region 之間的引用。這個內存的開銷其實仍是挺大的,可能會佔據整堆的20%或以上。多線程
而且 G1 還有寫屏障的開銷,雖然說用了 logging wtire barrier,但也仍是有開銷的。架構
固然 CMS 也用了寫屏障,不過邏輯比較簡單,啥都沒判斷就單純的記錄。併發
其實 G1 相對於 CMS 只有在大堆的場景下才有優點,CMS 比較傷的是 remark 階段,若是堆太大須要掃描的東西太多。app
而 G1 在大堆的時候能夠選擇部分收集,因此停頓時間有優點。負載均衡
今天的主角 ZGC 和 G1 同樣是基於 reigon 的,幾乎全部階段都是併發的,整堆掃描,部分收集。
並且 ZGC 還不分代,就是沒分新生代和老年代。
那它爲啥比 G1 要牛皮?今天我們就來盤一盤。
本文會先介紹 ZGC 的特性,或者說幾個關鍵點,而後再簡述下總體回收流程。
基本上看下來對 ZCG 心中就有數了,做爲普通的 Javaer,瞭解到這個程度就差很少了。
好了,讓咱們進入今天的正題!
ZGC 的目標
垃圾收集器設計出來都有目標的,有些是爲了更高的吞吐,有些是爲了更低的延遲。
因此咱們先看看 ZGC 的目標:
能夠看到它的目標就是低延遲,保證最大停頓時間在幾毫秒以內,無論你堆多大或者存活的對象有多少。
能夠處理 8MB-16TB 的堆。
我們就按 openjdk 的 wiki 來展開今天的內容。
關鍵字:併發、基於Region、整理內存、支持NUMA、用了染色指針、用了讀屏障,對了 ZGC 用的是 STAB。
Concurrent
這個 Concurrent 的意思是和應用線程併發執行,ZGC 一共分了 10 個階段,只有 3 個很短暫的階段是 STW 的。
能夠看到只有初始標記、再標記、初始轉移階段是 STW 的。
初始標記就掃描 GC Roots 直接可達的,耗時很短,從新標記通常而言也很短,若是超過 1ms 會再次進入併發標記階段再來一遍,因此影響不大。
初始轉移階段也是掃描 GC Roots 也很短,因此能夠認爲 ZGC 幾乎是併發的。
並且之因此說停頓時間不會隨着堆的大小和存活對象的數量增長而增長,是由於 STW 幾乎只和 GC Roots 集合大小有關,和堆大小沒啥關係。
這其實就是 ZGC 超過 G1 很關鍵的一個地方, G1 的對象轉移須要 STW 因此堆大須要轉移對象多,停頓的時間就長了,而 ZGC 有併發轉移。
不過併發回收有個狀況就是回收的時候應用線程仍是在產生新的對象,因此須要預留一些空間給併發時候生成的新對象。
若是對象分配過快致使內存不夠,在 CMS 中是發生 Full gc,而 ZGC 則是阻塞應用線程。
因此要注意 ZGC 觸發的時間。
ZGC 有自適應算法來觸發也有固定時間觸發,因此能夠根據實際場景來修改 ZGC 觸發時間,防止過晚觸發而內存分配過快致使線程阻塞。
還有設置 ParallelGCThreads 和 ConcGCThreads,分別是 STW 並行時候的線程數和併發階段的線程數來加快回收的速度。
不過 ConcGCThreads 數量須要注意,由於此階段是和應用線程併發,若是線程數過多會影響應用線程。
其實 ZGC 的每一個階段都是串行的,因此理論上其實能夠不須要分兩類線程,那爲何分了這兩類線程?
就是爲了靈活設置。分紅兩類就能夠經過配置來調優,達到性能最大值。
對了上面提到 ZGC 的 STW 和 GC Roots 集合大小有關係,因此若是在會生成不少線程、動態加載不少 ClassLoader 等狀況下會增長 ZGC 的停頓時間。
這點須要注意。
Region-based
爲了能更細粒度的控制內存的分配,和 G1 同樣 ZGC 也將堆劃分紅不少分區。
分了三種:2MB、32MB 和 X*MB(受操做系統控制)。
下圖爲源碼中的註釋:
對於回收的策略是優先收集小區,中、大區儘可能不回收。
Compacting
和 G1 同樣都分區了因此確定從總體來看像是標記-複製算法,因此也是會整理的。
所以 ZGC 也不會產生內存碎片。
具體的流程下文再作分析。
NUMA-aware
之前的 G1 是不支持的,不過在 JDK14 G1 也支持了。
可能有的同窗對 NUMA 不太熟悉,沒事我先來解釋一波。
在早期處理器都是單核的,由於根據摩爾定律,處理器的性能每隔一段時間就能夠成指數型增加。
而近年來這個增加的速度逐漸變緩,因而不少廠商就推出了雙核多核的計算機。
早期 CPU 經過前端總線到北橋到內存總線而後才訪問到內存。
這個架構被稱爲 SMP (Symmetric Multi-Processor),由於任一個 CPU 對內存的訪問速度是一致的,不用考慮不一樣內存地址之間的差別,因此也稱一致內存訪問(Uniform Memory Access, UMA )。
這個核心越加越多,漸漸的總線和北橋就成爲瓶頸,那不可以啊,因而就想了個辦法。
把 CPU 和內存集成到一個單元上,這個就是非一致內存訪問 (Non-Uniform Memory Access,NUMA)。
簡單的說就是把內存分一分,每一個 CPU 訪問本身的本地的內存比較快,訪問別人的遠程內存就比較慢。
固然也能夠多個 CPU 享受一塊內存或者多塊,以下圖所示:
可是由於內存被切分爲本地內存和遠程內存,當某個模塊比較「熱」的時候,就可能產生本地內存爆滿,而遠程內存都很空閒的狀況。
好比 64G 內存一分爲二,模塊一的內存用了31G,而另外一個模塊的內存用了5G,且模塊一隻能用本地內存,這就產生了內存不平衡問題。
若是有些策略規定不能訪問遠程內存的時候,就會出現明明還有不少內存卻產生 SWAP(將部份內存置換到硬盤中) 的狀況。
即便容許訪問遠程內存那也比本地內存訪問速率相差較大,這是使用 NUMA 須要考慮的問題。
ZGC 對 NUMA 的支持是小分區分配時會優先從本地內存分配,若是本地內存不足則從遠程內存分配。
對於中、大分區的話就交由操做系統決定。
上述作法的緣由是生成的絕大部分都是小分區對象,所以優先本地分配速度較快,並且也不易形成內存不平衡的狀況。
而中、大分區對象較大,若是都從本地分配則可能會致使內存不平衡的狀況。
Using colored pointers
染色指針其實就是從 64 位的指針中,拿幾位來標識對象此時的狀況,分別表示 Marked0、Marked一、Remapped、Finalizable。
咱們再來看下源碼中的註釋,很是的清晰直觀:
0-41 這 42 位就是正常的地址,因此說 ZGC 最大支持 4TB (理論上能夠16TB)的內存,由於就 42 位用來表示地址。
也所以 ZGC 不支持 32 位指針,也不支持指針壓縮。
而後用 42-45 位來做爲標誌位,其實無論這個標誌位是啥指向的都是同一個對象。
這是經過多重映射來作的,很簡單就是多個虛擬地址指向同一個物理地址,不過對象地址是 0001.... 仍是0010....仍是0100..... 對應的都是同一個物理地址便可。
具體這幾個標記位怎麼用的,待下文回收流程分析再解釋。
不過這裏先提個問題,爲何就支持 4TB,不是還有不少位沒用嗎?
首先 X86_64 的地址總線只有 48 條 ,因此最多其實只能用 48 位,指令集是 64 位沒錯,可是硬件層面就支持 48 位。
由於基本上沒有多少系統支持這麼大的內存,那支持 64 位就不必了,因此就支持到 48 位。
那如今對象地址就用了 42 位,染色指針用了 4 位,不是還有 2 位能夠用嗎?
是的,理論上能夠支持 16 TB,不過暫時認爲 4TB 夠了,因此暫作保留,僅此而已沒啥特別的含義。
Using load barriers
在 CMS 和 G1 中都用到了寫屏障,而 ZGC 用到了讀屏障。
寫屏障是在對象引用賦值時候的 AOP,而讀屏障是在讀取引用時的 AOP。
好比 Object a = obj.foo;
,這個過程就會觸發讀屏障。
也正是用了讀屏障,ZGC 能夠併發轉移對象,而 G1 用的是寫屏障,因此轉移對象時候只能 STW。
簡單的說就是 GC 線程轉移對象以後,應用線程讀取對象時,能夠利用讀屏障經過指針上的標誌來判斷對象是否被轉移。
若是是的話修正對象的引用,按照上面的例子,不只 a 能獲得最新的引用地址,obj.foo 也會被更新,這樣下次訪問的時候一切都是正常的,就沒有消耗了。
下圖展現了讀屏障的效果,其實就是轉移的時候找地方記一下即 forwardingTable,而後讀的時候觸發引用的修正。
這種也稱之爲「自愈」,不只賦值的引用時最新的,自身引用也修正了。
染色指針和讀屏障是 ZGC 能實現併發轉移的關鍵所在。
ZGC 回收流程解析
ZGC 的步驟大體可分爲三大階段分別是標記、轉移、重定位。
- 標記:從根開始標記全部存活對象
- 轉移:選擇部分活躍對象轉移到新的內存空間上
- 重定位:由於對象地址變了,因此以前指向老對象的指針都要換到新對象地址上。
而且這三個階段都是併發的。
這是意識上的階段,具體的實現上重定位實際上是糅合在標記階段的。
在標記的時候若是發現引用的仍是老的地址則會修正成新的地址,而後再進行標記。
簡單的說就是從第一個 GC 開始經歷了標記,而後轉移了對象,這個時候不會重定位,只會記錄對象都轉移到哪裏了。
在第二個 GC 開始標記的時候發現這個對象是被轉移了,而後發現引用仍是老的,則進行重定位,即修改爲新的引用。
因此說重定位是糅合在下一步的標記階段中。
我再簡單說一下十個步驟。
不過步驟裏有些不影響總體回收流程的,我就很少加分析了。
這篇文章的目的不是深刻 ZGC 實現的細節,而是瞭解 ZGC 大體的突出點和簡單流程便可。
所以想知道細節的自行查閱,或者能夠看看我文末推薦的書籍。
初始標記
這個階段其實你們應該很熟悉,CMS、G1 都有這個階段,這個階段是 STW 的,僅標記根直接可達的對象,壓到標記棧中。
固然還有其餘動做,好比重置 TLAB、判斷是否要清除軟引用等等,不作具體分析。
併發標記
就是根據初始標記的對象開始併發遍歷對象圖,還會統計每一個 region 的存活對象的數量。
這個併發標記其實有個細節,標記棧其實只有一個,可是併發標記的線程有多個。
爲了減小之間的競爭每一個線程其實會分到不一樣的標記帶來執行。
你就理解爲標記棧被分割爲好幾塊,每一個線程負責其中的一塊進行遍歷標記對象,就和1.7 Hashmap 的segment 同樣。
那確定有的線程標記的快,有的標記的慢,那麼先空閒下來的線程會去竊取別人的任務來執行,從而實現負載均衡。
看到這有沒有想到啥?沒錯就是 ForkJoinPool 的工做竊取機制!
再標記階段
這一階段是 STW 的,由於併發階段應用線程仍是在運行的,因此會修改對象的引用致使漏標的狀況。
所以須要個再標記階段來標記漏標的那些對象。
若是這個階段執行的時間過長,就會再次進入到併發標記階段,由於 ZGC 的目標就是低延遲,因此一有高延遲的苗頭就得扼制。
這個階段還會作非強根並行標記,非強根指的是:系統字典、JVMTI、JFR、字符串表。
有些非強根能夠併發,有些不行,具體不作分析。
非強引用併發標記和引用併發處理
就是上一步非強根的遍歷,而後引用就軟引用、弱引用、虛引用的一些處理。
這個階段是併發的。
重置轉移集
還記得標記時候的重定位麼?在寫讀屏障時候提到的 forwardingTable 就是個映射集,你能夠理解爲 key 就是對象轉移前的地址,value 是對象轉移後的地址。
不過這個映射集在標記階段已經用了,也就是說標記的時候已經重定位完了,因此如今沒用了。
但新一輪的垃圾回收須要仍是要用到這個映射集的。
所以在這個階段對那些轉移分區的地址映射集作一個復位的操做。
回收無效分區
回收那些物理內存已經被釋放的無效的虛擬內存頁面。
就是在內存緊張的時候會釋放物理內存,若是同時釋放虛擬空間的話也不能釋放分區,由於分區須要在新一輪標記完成以後才能釋放。
因此就會有無效的虛擬內存頁面存在,在這個階段回收。
選擇待回收的分區
這和 G1 同樣,由於會有不少能夠回收的分區,會篩選垃圾較多的分區,來做爲此次回收的分區集合。
初始化待轉移集合的轉移表
這一步就是初始化待回收的分區的 forwardingTable。
初始轉移
這個階段其實就是從根集合出發,若是對象在轉移的分區集合中,則在新的分區分配對象空間。
若是不在轉移分區集合中,則將對象標記爲 Remapped。
注意這個階段是 STW,只轉移根直接可達的對象。
併發轉移
這個階段和併發標記階段就很相似了,從上一步轉移的對象開始遍歷,作併發轉移。
這一步很關鍵。
G1 的轉移對象總體都須要 STW,而 ZGC 作到了併發轉移,因此延遲會低不少。
至此十個步驟就完畢了,一次 GC 結束。
能夠還能同窗對染色指針的幾個標記位有點懵,沒事看了下文就懂了。
染色指針的標記位
來分析下幾個標記位,M0、M一、Remapped。
先來介紹個名詞,地址視圖:指的就是此時地址指針的標記位。
好比標記位如今是 M0,那麼此時的視圖就是 M0 視圖。
在垃圾回收開始前視圖是 Remapped 。
在進入標記標記時。
標記線程訪問發現對象地址視圖是 Remapped 這時候將指針標記爲 M0,即將地址視圖置爲 M0,表示活躍對象。
若是掃描到對象地址視圖是 M0 則說明這個對象是標記開始以後新分配的或者已經標記過的對象,因此無需處理。
應用線程 若是建立新對象,則將其地址視圖置爲 M0,若是訪問的對象地址視圖是 Remapped 則將其置爲 M0,而且遞歸標記其引用的對象。
若是訪問到的是 M0 ,則無需操做。
標記階段結束後,ZGC 會使用一個對象活躍表來存儲這些對象地址,此時活躍的對象地址視圖是 M0。
併發轉移階段,地址視圖被置爲 Remapped 。
也就是說 GC 線程若是訪問到對象,此時對象地址視圖是 M0,而且存在或活躍表中,則將其轉移,並將地址視圖置爲 Remapped 。
若是在活躍表中,可是地址視圖已是 Remapped 說明已經被轉移了,不作處理。
應用線程此時建立新對象,地址視圖會設爲 Remapped 。
此時訪問對象若是對象在活躍表中,且地址視圖爲 Remapped 說明轉移過了,不作處理。
若是地址視圖爲 M0,則說明還未轉移,則須要轉移,並將其地址視圖置爲 Remapped 。
若是訪問到的對象不在活躍表中,則不作處理。
那 M1 什麼用?
M1 是在下一次 GC 時候用的,下一次的 GC 就用 M1來標記,不用 M0。
再下一次再換過來。
簡單的說就是 M1 標識本次垃圾回收中活躍的對象,而 M0 是上一次回收被標記的對象,可是沒有被轉移,在本次回收中也沒有被標記活躍的對象。
其實從上面的分析以及得知,若是沒有被轉移就會停留在 M0 這個地址視圖。
而下一次 GC 若是仍是用 M0 來標識那混淆了這兩種對象。
因此搞了個 M1。
至此染色指針這幾個標誌位應該就很清晰了,我在用圖來示意一下。
不清晰的同窗建議再多看幾遍標記位的變動,不復雜的。
最後
簡單的總結下,ZGC 就是經過多階段的併發和幾個短暫的 STW 階段來達到低延遲的特性。
利用指針染色技術和讀屏障實現併發轉移對象,利用 STAB 保證併發階段不會漏標對象。
這一波一下相信你們對於 ZGC 有了必定的瞭解。
我我的認爲重點就掌握官網羅列的那幾個要點就行,畢竟我們也不是寫 GC 的,做爲了解便可。
到時候和學妹呀,或者在面試官前面呀均可以小吹一下。
若是想深刻了解固然能夠,可先看看《新一代垃圾回收器ZGC設計與實現》這本書,而後再源碼走起。
ZGC 的不分代實際上是它的缺點,由於分代比較難實現,不過之後應該會加上吧。
其實從現代垃圾收集器的演進能夠看出就是往併發上面靠,目標就是減小停頓的時間。
不過併發須要注意內存分配的速率,由於併發致使一次垃圾回收總的時間變長了。
若是內存分配過快那就回收不過來了,所以都須要預留內存空間或者說要更大的內存空間來應對快速的分配速率。
可能大夥還惦記這標題吧?ZGC 的 Z 是什麼意思?
其實沒啥意思,就是個名字而已。
歡迎關注個人公衆號【yes的練級攻略】,更多硬核文章等你來讀。
巨人的肩膀
https://www.iteye.com/blog/user/rednaxelafx R大的博客 https://malloc.se/blog/zgc-jdk15 https://wiki.openjdk.java.net/display/zgc/Main 《新一代垃圾回收器ZGC設計與實現》
微信搜索【yes的練級攻略】,關注 yes,回覆【123】一份20W字的算法刷題筆記等你來領,從一點點到億點點,咱們下篇見。