您好,登錄后才能下訂單哦!
這篇文章主要講解了“ZGC的特性有哪些”,文中的講解內容簡單清晰,易于學習與理解,下面請大家跟著小編的思路慢慢深入,一起來研究和學習“ZGC的特性有哪些”吧!
垃圾收集器設計出來都有目標的,有些是為了更高的吞吐,有些是為了更低的延遲。
所以我們先看看 ZGC 的目標:
可以看到它的目標就是低延遲,保證最大停頓時間在幾毫秒之內,不管你堆多大或者存活的對象有多少。
可以處理 8MB-16TB 的堆。
咱們就按 openjdk 的 wiki 來展開今天的內容。
關鍵字:并發、基于Region、整理內存、支持NUMA、用了染色指針、用了讀屏障,對了 ZGC 用的是 STAB。
這個 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 的停頓時間。
這點需要注意。
為了能更細粒度的控制內存的分配,和 G1 一樣 ZGC 也將堆劃分成很多分區。
分了三種:2MB、32MB 和 X*MB(受操作系統控制)。
下圖為源碼中的注釋:
對于回收的策略是優先收集小區,中、大區盡量不回收。
和 G1 一樣都分區了所以肯定從整體來看像是標記-復制算法,所以也是會整理的。
因此 ZGC 也不會產生內存碎片。
具體的流程下文再做分析。
以前的 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 的支持是小分區分配時會優先從本地內存分配,如果本地內存不足則從遠程內存分配。
對于中、大分區的話就交由操作系統決定。
上述做法的原因是生成的絕大部分都是小分區對象,因此優先本地分配速度較快,而且也不易造成內存不平衡的情況。
而中、大分區對象較大,如果都從本地分配則可能會導致內存不平衡的情況。
染色指針其實就是從 64 位的指針中,拿幾位來標識對象此時的情況,分別表示 Marked0、Marked1、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 夠了,所以暫做保留,僅此而已沒啥特別的含義。
在 CMS 和 G1 中都用到了寫屏障,而 ZGC 用到了讀屏障。
寫屏障是在對象引用賦值時候的 AOP,而讀屏障是在讀取引用時的 AOP。
比如 Object a = obj.foo;
,這個過程就會觸發讀屏障。
也正是用了讀屏障,ZGC 可以并發轉移對象,而 G1 用的是寫屏障,所以轉移對象時候只能 STW。
簡單的說就是 GC 線程轉移對象之后,應用線程讀取對象時,可以利用讀屏障通過指針上的標志來判斷對象是否被轉移。
如果是的話修正對象的引用,按照上面的例子,不僅 a 能得到最新的引用地址,obj.foo 也會被更新,這樣下次訪問的時候一切都是正常的,就沒有消耗了。
下圖展示了讀屏障的效果,其實就是轉移的時候找地方記一下即 forwardingTable,然后讀的時候觸發引用的修正。
這種也稱之為“自愈”,不僅賦值的引用時最新的,自身引用也修正了。
染色指針和讀屏障是 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、M1、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的特性有哪些”的內容了,經過本文的學習后,相信大家對ZGC的特性有哪些這一問題有了更深刻的體會,具體使用情況還需要大家實踐驗證。這里是億速云,小編將為大家推送更多相關知識點的文章,歡迎關注!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。