Java进阶--Java垃圾回收机制全面解析
本文同步發布在我的個人博客 ,如需轉載請注明出處。
提起Java的垃圾回收機制大家應該都有所了解,它不僅是面試的常客,也是Java體系中相當重要的一塊知識。深入理解Java的GC機制,不僅有助于我們在開發中提高程序的性能,更有了在面試官面前炫(zhuang)技(X)的資本。本篇文章將全面且深入的分析JVM的垃圾回收機制,同時還會對常用的垃圾收集器(包括最前沿的ZGC收集器和Shenandoah收集器)進行講解。
一、GC機制概述
在《深入JVM–Java運行時內存區域詳解》這篇文章中我們對JVM的運行時內存區域進行了詳細的分析。我們知道對象的創建是由JVM完成的,在對象創建的時候JVM會在Java堆中開辟一塊空間用來存儲這個對象。而當對象“死亡”的時候,同樣是由JVM來處理的,JVM處理“死亡”對象的過程就是我們今天要講的垃圾回收機制。
1.堆內存的區域劃分
關于堆內存區域的劃分,其實是由垃圾收集器的特性決定的。本節將要講到的內存區域劃分主要是指的G1收集器之前的經典垃圾收集器對堆內存的劃分。
為了方便JVM更好的管理和回收對象,Java的設計者們將Java的堆內存成為了兩大塊,分別為:新生代(Young Generation) 和 老年代(Old Generation)。而根據新生代的特性,又將新生代分成了一塊較大的Eden區域和兩塊較小但大小相等的Survivor區域。至于新時代和老年代這兩塊區域,是我們今天要探討的重點,后文中將會多次出現。
了解了堆內存的劃分后我們再來看垃圾回收的特點。垃圾收集器在執行一次垃圾回收時,可能是部分收集(Partical GC)也可能是整堆收集(Full GC),部分收集又可以分為新生代收集(Minor GC/Young GC)和老年代收集(Major GC/Old GC)。既然有這樣的劃分,那收集器回收區域的規則是根據什么條件確定的呢? 在JDK6 update 24之后,回收區域的規則為:只要老年代的連續空間大于新生代對象總大小或者歷次晉升的平均大小,就會進行Minor GC,否則將進行Full GC。
對象通常是在Eden區域被創建,JVM會給每個對象定義一個年齡(Age)計數器,存儲在對象頭中。如果經過第一次Minor GC后對象仍然存活,并且能被Survivor區域容納的話,對象則會被移動到Survivor區域,同時會將對象的年齡設置為1歲。接下來,該對象會經歷多次的垃圾回收,Survivor區中的對象每熬過一次Minor GC,它的年齡就會增加一歲。如果對這個象增加到一定年齡(默認15,可通過-XX:MaxTenuringThreshold參數設置),就會被移動到老年代中。
當然,為了更好的適應不同程序的內存情況,HotSpot虛擬機并不是絕對要求對象年齡達到-XX:MaxTenuringThreshold后才能轉移到老年代,特殊情況有如下兩種:
- 如果Survivor空間中相同年齡所有對象大小的總和大于Survivor空間的一半,則年齡大于或等于該年齡的對象就可以直接進入老年代。
- 另外,對于大對象,HotSpot虛擬機可通過-XX:PretenureSizeThreshold參數進行設置,當對象內存大于設定的值的話,這個對象會繞過Eden區域直接被分配到老年代。
正所謂條條大路通羅馬,而有些人的家就在羅馬。看來即便是計算機也繞不過特權階級這個坎兒呀。。。
2.永久代(Permanent Generation)
在JDK7以及之前,HotSpot虛擬機還有另外一塊叫永久代(Permanent Generation) 的存儲區域,這塊區域并不屬于堆內存,而是對于方法區的實現。主要用于存放Class和Meta(元數據)的信息,Class在類加載的時候被放入永久代。永久代和存放實例的堆內存區域不同,GC不會在主程序運行期對永久代進行清理。所以這也導致了永久代的區域會隨著加載的Class的增多而爆滿,最終拋出OOM異常。
雖然被稱為永久代,但這塊內存區域也會進行垃圾回收。永久代的垃圾收集主要包廢棄常量和無用的類(被類加載器卸載的Class)。永久代觸發垃圾回收的條件比較困難,需要同時滿足以下三點:
- 該類所有的實例都已經被回收,也就是Java堆中不存在該類的任何實例;
- 加載該類的ClassLoader已經被回收;
- 該類對應的java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法;
3.元空間(MetaSpace)
由于永久代可能存在內存溢出的問題,在JDK8之后永久代已經不復存在,取而代之的是元空間(MetaSpace)。
元空間的本質和永久代類似,都是對JVM規范中方法區的實現。不過元空間與永久代之間最大的區別在于:元空間并不在虛擬機中,而是使用本地內存。因此,默認情況下,元空間的大小僅受本地內存限制,但可以通過-XX:MetaspaceSize這個參數來指定初始空間大小,當達到設置的最大值就會觸發垃圾收集進行類型卸載,同時GC會對該值進行調整:如果釋放了大量的空間,就適當降低該值;如果釋放了很少的空間,那么在不超過MaxMetaspaceSize時,適當提高該值。可以通過-XX:MaxMetaspaceSize來設置元空間能夠使用的最大內存,默認是沒有限制的。
除了上面兩個指定大小的選項以外,還有兩個與 GC 相關的屬性:
-XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空間容量的百分比,減少為分配空間所導致的垃圾收集
-XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空間容量的百分比,減少為釋放空間所導致的垃圾收集
有關垃圾回收的區域如下圖所示:
上圖中的Permanet Generation區域,在Jdk8中,被MetaSpace區取代了。
二、垃圾收集的標記算法
垃圾收集器回收垃圾的第一步先要確定哪些對象是可以被回收的。因此,JVM會掃描堆內存中的所有對象,并標記出可被回收的對象。而垃圾收集的標記算法有以下兩種:
1.引用計數算法
引用計數算法通過在每個對象中添加一個計數器,當有一個地方引用它的時候計數器的值就會增加1;當引用失效的時候計數器的值則會減1。當計數器的值為0時,則可認為這個對象已經不再使用。因此對于引用計數算法,垃圾收集器只需要回收計數器為0的對象即可。
引用計數算法的優點是效率很高,不需要遍歷所有對象。但它是存在一個致命的缺點,即無法解決對象之間循環引用的問題。比如對象A引用了對象B,對象B也引用了對象A,除此之外,A、B兩個對象再也沒有被其他地方引用。此時對象A和對象B的計數器均不為0,所以A、B兩個對象都無法被回收。所以,目前商用的Java虛擬機都沒有選用引用計數算法來進行標記。
2.可達性分析算法
可達性分析算法也被稱為根搜索算法。這一算法的基本思路是用一系列的“GC Roots"的根對象作為起始節點集,從這些節點開始,根據引用關系向下搜索,搜索過程所走過的路徑被稱為”引用鏈“(Reference Chain)。如果一個對象到”GC Roots"沒有任何的引用鏈相連,則證明此對象可能不再被使用。
如下圖所示,灰色部分的對象沒有關聯到引用鏈上,此時這些對象就會被判定為可回收對象。
哪些對象可以被作為GC Roots呢?主要包括以下幾種:
- 在虛擬機棧(棧幀中的本地變量表)中引用的對象。
- 方法區中類靜態屬性引用的對象。
- 在方法區中引用的對象,如字符串常量池(String Table)里的引用
- 本地方法棧中JNI引用的對象
- Java虛擬機內部的引用,如基本數據類型對應的Class對象以及一些常駐的異常對象等。
- 所有同步鎖持有的對象
- 反應Java虛擬機內部情況的JMXBean、JVMTI中注冊的回調、本地代碼緩存等。
三、垃圾收集算法
1.標記-清除算法(Mark-Sweep)
標記-清除算法是最早出現也是最基礎的一種垃圾收集算法。該算法分為“標記”和”清除“兩個階段,標記階段就是上邊講到的對垃圾的標記。首先會通過可達性分析算法標記出所有需要回收的對象,然后統一回收掉所有被標記的對象。標記-清除算法的執行過程如下圖所示:
圖中深灰色區域為可回收區域,在標記完成后直接將深灰色區域進行清理。這一算法很容易理解,實現起來也很便捷,但是也存在兩個缺點:
1.執行效率會隨對象增多而降低。如果Java堆中包含大量需要回收的對象。此時需要進行大量標記和清除操作。導致標記和清除這兩個過程需要大量的時間,降低了執行效率。
2.引起嚴重的內存碎片化問題。標記、清除之后會產生大量不連續的內存空間,這可能會導致在需要分配大對象時無法找到足夠的連續空間,進而引發GC。
2.標記-復制算法(Copying)
標記-復制算法也被簡稱為復制算法。它是對標記-清除算法的改進。復制算法將內存劃分為大小相等的兩塊,分配對象時只使用其中的一塊。當這塊內存用完時,就將存活的對象復制到另外一塊上面,然后把已使用的這塊內存一次性清理掉。復制算法的執行過程如下圖所示:
復制算法雖然解決了標記-清除算法的一些問題。但其缺陷也顯而易見,直接導致了可用內存變為原來的一半,內存使用率太低!
3.標記-整理算法(Mark-Compact)
標記整理算法在標記了存活對象之后,會讓所有存活的對象向內存的一端移動,然后直接清除掉邊界外的內存。該算法的示意圖如下圖所示:
移動存活對象并更新所有被移動對象的引用是一個比較耗時的操作。而且,在移動對象時必須暫停所有用戶線程才能進行(這一操作有個專有名詞叫“Stop The World”,簡稱STW)。拖累了用戶程序的執行效率
4.分代收集(Generational Collection)
分代收集不能稱得上是一種算法,它會根據堆內存的不同區域采用不同的收集算法,因地制宜嘛。
比如上邊我們已經說過的,在G1收集器之前,所有的收集器都是將Java堆劃分為新生代和老年代,由于新生代中對象存活率比較低,因此在新時代采用優化了的復制算法。HotSpot虛擬機中將Eden和Survivor的大小大小劃分為8:1的比例,分配對象只使用Eden和其中的一塊Surivor區域,在標記完成后將存活的對象復制到另外一塊Survior空間中,然后清除Eden和使用的一塊Surivor。這樣,新生代的空間利用率就達到了90%。
對于老年代每次垃圾回收存活的對象比較多,因此這一區域采用的是標記-整理算法進行垃圾回收。
四、垃圾收集器
通過前面幾節我們認識了垃圾收集的原理,那么,本節就來認識一下各具特色的垃圾收集器。垃圾收集器其實就是對于前面講到的原理的實現,只不過在Java的發展史中出現了一代又一代的垃圾收集器,而新一代的垃圾收集器都是對上一代垃圾收集器缺點的彌補。直到前幾天(2020年9月15日),在Oracle JDK15中又引入了新的垃圾收集器Shenandoah。可見直到今天Java的設計者們依然還在對收集器進行優化。
我們先來通過一張圖片看下經典的幾款垃圾收集器,圖中連線表示這兩款收集器可以配合使用。
接下來我們我們來認識下這幾款收集器,另外除了圖中的這幾款收集器還會詳細講解ZGC和Shenandoah。
1.新生代收集器
1.1 Serial收集器
Serial收集器是最基礎、發展歷史最悠久的收集器。它是一個單線程工作的收集器,對于早期的單核處理器或處理器核心數較少的情況下,Serial收集器由于沒有線程交互的開銷,所以收集效率比較高。但是,Serial收集器整個收集過程是需要STW的。這也是導致了早期的Java程序慢的主要原因之一。Serial收集器新生代采用的是標記-復制算法,運行過程如下圖所示:
1.2 ParNew收集器
ParNew收集器實質上是Serial收集器的多線程版本。除了使用多條線程進行垃圾收集之外,其它特性包括Serial收集器的所有控制參數、收集算法、Stop The World、對象分配規則、回收策略等都與Serial收集器完全一致。ParNew收集器的工作過程如下圖所示:
ParNew是JDK 7之前的遺留系統中首選的新生代收集器,因為除了Serial收集器外它是唯一能和CMS收集器配合工作的新生代收集器。
1.3 Parallel Scavenge收集器
Parallel Scavenge收集器也是一款新生代收集器,它同樣是基于標記-復制算法實現。也是能夠并行收集的多線程收集器,從表面上看它與ParNew非常相似。Parallel Scavenge收集器的目標是達到一個可控制的吞吐量(Throughput)。
吞吐量=(運行用戶代碼的時間)/(運行用戶代碼時間+運行垃圾收集時間)
Parallel Scavenge收集器運行過程如下圖所示:
2.老年代收集器
2.1 Serial Old收集器
Serial Old是Serial收集器的老年代版本,它與Serial一樣都是單線程收集器。Serial Old使用的是標記-整理算法。它的主要意義也是提供客戶端模式下的HotSpot虛擬機使用。
2.2 Parallel Old收集器
Parallel Old 是Parallel Scavenge收集器的老年代版本,支持多線程并發收集,基于標記-整理算法。這個收集器是在JDK 6時開始提供。
2.3 CMS收集器
CMS(Concurrent Mark Sweep)收集器是一款具有劃時代意義的收集器。前面我們提到的幾款收集器在工作期間全程都需要STW,而CMS第一次實現了垃圾收集的并發處理。因此,這款收集器可以有效的減少垃圾收集過程中的停頓時間。CMS收集器是基于標記-清除算法實現的。我們來詳細了解一下CMS的工作過程:
(1)初始標記: 從GC Roots出發標記全部直接子節點的過程,該階段是STW的。由于GC Roots數量不多,通常該階段耗時非常短。
(2)并發標記: 并發標記階段是指從GC Roots開始對堆中對象進行可達性分析,找出存活對象。該階段是并發的,即應用線程和GC線程可以同時活動。并發標記耗時相對長很多,但因為不是STW,所以我們不太關心該階段耗時的長短。
(3)重新標記: 重新標記那些在并發標記階段發生變化的對象。該階段是STW的。
(4)并非清除: 并行清理, 開啟用戶線程,同時GC線程開始對為標記的區域做清掃。
CMS運行過程如下圖所示:
從上面描述可以看到,CMS能夠并發收集,有效減少停頓時間。但CMS并不是一款完美的垃圾收集器,不然也不會在JDK15中將其移除。它的缺點主要有以下幾個:
- 并發收集占用CPU資源。 雖然并發階段不會導致用戶停頓,并發時的收集線程卻占用了一部分CPU資源,導致應用程序變慢,降低了吞吐量。
- 無法處理浮動垃圾。 CMS的并發標記和并發清理階段,用戶線程是繼續運行的,這期間必然會有新的垃圾對象產生。對于已經收集過的區域,CMS無法再去回頭處理它們,只能等到下一次垃圾收集時再清理掉。
- 并發清理階段需要保證內存充足。 由于在垃圾收集階段用戶線程依然在運行,所有不得不預留足夠的空間提供給用戶線程使用。因此CMS收集器在垃圾收集開始時需要預留足夠的內存。JDK 5的默認設置,當老年代使用了68%的空間后就垃圾收集會被激活。雖然可以通過參數-XX:CMSInitiatingOccupancyFraction來調高CMS的觸發百分比,但這樣又會導致CMS運行期間可能出現預留內存不足的情況。此時,CMS就會出現一次”并發失敗“(Concurrent Mode Failure),虛擬機不得不啟動后備預案,停止用戶線程的執行,啟動Serial Old收集器重新進行老年代的垃圾收集。
- 產生大量碎片空間 。 由于CMS使用的是“標記-清除”算法,因此會導致大量空間碎片產生。
3.全局收集器
3.1 Garbage Firs 收集器
Garbage First收集器簡稱G1收集器,它是在JDK 6中被添加到Hotspot虛擬機的。與其它收集器相比G1收集器引入了很多新特點。如下:
(1)獨具特色的分代收集方式。
G1收集器在堆內存的分代上做了很大的改變。它不再將堆內存簡單的分為新生代和老年代,而是將堆劃分為若干個大小相等、內存連續的Region。每個Region都可以根據需要扮演Eden空間、Survivor空間 、Old空間或者Humongous。如下圖所示:
其中Humomngous區域我們比較陌生,它是G1收集器獨有的用于存放大對象的區域。如果對象的大小超過了Region容量一半即可被判定為大對象放入Humongous空間。如果一個非常大的對象超過了整個Humongous的內存,則這個對象會被存放在N個連續的Humongous Region之中。這么看來,Humongous更像是分擔了部分老年代的功能。
Region的大小可以通過參數-XX:G1HeapRegionSize設置,取值范圍為1~32MB,且應該為2的N次冪。
當然,別具一格的分代方式也會帶來別具一格的問題。垃圾收集器應該如何跨Region進行收集?因為一個對象被分配到某個Region中,這個對象不可能只被本Region的對象引用,而是可能與堆中任意一個Region中的對象建立引用關系。這種情況其實在新生代與老年代中一樣會碰到,只不過在G1中會顯得更加突出。對于這種問題的處理虛擬機都是使用Remembered Set來避免全堆掃描的。在G1的每個Region區都維護著一個Remembered Set。當虛擬機發現程序在堆引用類型的數據進行寫操作時會產生一個寫屏障(Write Barrier),檢查引用的對象是否處于不同的Region。如果是,便通過CardTable將引用信息記錄到被引用對象所屬的Region的Remembered Set中。當垃圾回收啟動時,在GC跟節點的枚舉范圍中加入Remembered Set即可保證不對全堆掃描,同時也不會有遺漏。
Remembered Set在存儲結構的本質上是一種哈希表,Key是別的Region的起始地址,Value是一個集合,里邊存儲的是CardTable的索引號。CardTable存儲的是“我指向誰”,而整個Remembered Set中的一個鍵值對(Key+Value)其實構成了一個雙向卡表,即:”我指向誰,誰指向我“
由于Region的數量要比傳統的收集器的分代數量要多的多,因此G1收集器要比其它傳統收集器有著更高的內存占用負擔。根據相關經驗,G1至少要耗費大約相當于Java堆容量10%至20%的額外內存來維持收集工作。
(2)能建立可預測的停頓時間模型。
G1收集器可以有計劃的避免整個Java堆進行全區域的垃圾收集。他會跟蹤各個Region里面的垃圾堆積的價值大小,在后臺維護一個優先列表(Collection Set 簡稱CSet)。并且允許用戶通過-XX:MaxGCPauseMillis參數設定允許的收集停頓時間。G1根據設定的停頓時間優先處理回收價值收益最大的那些Region,這也是Garbage First名字的由來。這種使用Region劃分內存空間以及有優先級堆回收方式保證了G1收集器在有限時間內可以獲取盡可能高堆收集效率。但是這個停頓時間必須切合實際,如果設置得非常低,會導致每次只能回收很小的一部分內存,最終垃圾慢慢堆積,最終占滿整個堆內存,導致Full GC而產生長時間的“STW”,影響了性能。
(3)具有整合空間碎片的能力。
G1收集器從整體來看是基于“標記-整理”算法實現的,從局部(兩個Region之間)是基于“復制”算法實現的,這兩種算法在執行期間都不會產生內存的空間碎片。
(4)可以并行和并發收集。
G1收集器的運作過程可以分為四個步驟:
初始標記(Initial Marking): 初始標記只會標記GC Roots能直接關聯到的對象,這一階段是需要Stop The World的,但是由于GC Roots數量有限,所以這一階段并不會消耗太多時間。
并發標記(Concurrent Marking): 從GC Roots開始對堆中對象進行可達性分析,找出存活對象。并發標記階段耗時較長,但是這一階段是并發執行的,因此,對性能不會造成影響。
最終標記(Final Marking): 重新標記在并發標記階段發生變化的對象,這一階段是需要STW的,但耗時很短。
篩選回收階段(Live Data Counting And Evacuation) 負責跟新Region跟新的數據,對各個Region的回收加之和成本進行排序,根據用戶所期望的停頓時間來制定回收計劃。可自由選擇任意多個Region構成回收集,然后把決定回收的那一部分Region的存活對象復制到空的Region中,再清理掉舊Region的全部空間。這里涉及到存活對象的移動是必須STW,由多個收集器線程并行完成。
3.2 ZGC收集器
ZGC收集器是目前為止垃圾收集器最前沿的成果,它針對的目標是大內存、低延遲的后端服務器。ZGC可以將T級別內存回收的停頓時間控制在10ms以內,并且停頓時間不會隨著內存增加而增大。ZGC能有這樣的成果主要得益于它使用了讀屏障、染色指針、多重映射等技術,實現了可并發的標記-整理算法。
(1)ZGC的堆內存劃分
ZGC與G1收集器類似,都是將堆內存劃分成了許多Region。但與G1不同的是,ZGC的Region區域大小是不相等的,且具有動態創建和動態銷毀的特性。ZGC將Region分為大、中、小三類容量。其中,小型Region的容量固定為2MB,用于存放內存小于256k的小對象;中型Region容量固定為32MB,用于存放大小大于等于256kb但小于4M的對象;而大型Region的容量不固定,但必須是2MB的整數(N大于2)倍,用來存儲大于4MB的對象。大Region容量不固定的這一特性也導致了它的內存可能比中型Region還小的情況。
ZGC的堆內存劃分如下圖所示:
(2)ZGC的關鍵技術
前面我們已經提到ZGC之所以能將停頓時間控制在10ms以內,是因為它使用了讀屏障、著色指針以及多重映射等技術。但是由于到目前為止筆者對于這些技術中的很多原理還沒完全搞明白,因為它涉及到了很多操作系統層面的知識,而這些知識在期末考試完的那一刻就全部還給了操作系統老師。所以這里我也只能是簡單分析,也歡迎大家留言討論。
染色指針
我們知道如果需要在對象中存儲一些額外的信息時,可以在對象頭中添加額外的存儲字段。比如前邊章節中提到的對象的分代年齡,另外還有像對象的hash等都會這樣存儲。而ZGC的染色指針是直接把標記信息存儲在了對象的引用指針上。這樣,在進行可達性分析遍歷對象圖來標記對象的時候其實是遍歷了“引用圖”來標記了引用。
為什么指針可以存儲信息?在64位系統中,理論可以訪問的內存高達16EB(2的64次冪)字節。但實際上,基于需求、性能、成本等方面的考慮,在AMD64架構中只支持到52位的地址總線和48位的虛擬地址空間,所以目前64位的硬件實際能夠支持的最大內存只有256TB。此外,操作系統中還會施加自己的約束,64位的Linux僅支持47位的進程虛擬地址和46位的物理空間地址,64位的Windows系統甚至只支持44位的物理地址空間。(這里涉及到操作系統分頁調度的相關知識,感興趣的同學可以查閱相關資料)
鑒于此,虛擬機實際僅使用64位地址空間的第0到41位,而第42到45位存儲對象的標記信息。通過高四位的這些標志,虛擬機可以直接從指針中看到其引用對象的三色標記狀態、是否進入了重新分配(即被移動過)、是否通過finalize()方法才能被訪問到,具體如下圖所示:
因為標記信息又額外占用了46位中的四位,導致ZGC能夠映射的內存只剩42位,因此,ZGC能夠管理的內存不可以超過4TB(2的42次冪,這里指Linux平臺下,Windows上實際會更小,只有40位)。
讀屏障
讀屏障這個技術應該不難理解,因為在G1中我們已經知道了寫屏障。
讀屏障是JVM向應用代碼插入一小段代碼的技術。當應用線程從堆中讀取對象引用時,就會執行這段代碼。需要注意的是,僅“從堆中讀取對象引用”才會觸發這段代碼。
讀屏障示例:
Object o = obj.FieldA // 從堆中讀取引用,需要加入屏障 <Load barrier> Object p = o // 無需加入屏障,因為不是從堆中讀取引用 o.dosomething() // 無需加入屏障,因為不是從堆中讀取引用 int i = obj.FieldB //無需加入屏障,因為不是對象引用因為有讀屏障在,在對象標記和轉移過程中,會根據指針染色情況首先確定對象的引用地址是否滿足條件,并作出相應動作。
(3)ZGC的并發收集過程
ZGC的回收算法采用的是Mark-Compact ,它會將活著的對象都移動到另一個Region,然后整個回收掉原來的Region。ZGC的執行過程可以分為四個階段,這四大階段全部是可以并發執行的,但是在其中兩個階段中會有短暫的停頓。
并發標記(Concurrent Mark )
這一階段也與G1類似,也會經歷初始標記、最終標記的短暫停頓。與G1不同的是,ZGC的標記是在指針上,標記階段會更新染色指針中的Marked0、Marked1標志位。
初始標記會標記與GCRoots直接關聯的對象。
接著會遍歷對象圖標記處全部可達的對象。這一階段耗時比較多,但是是并發的,因此不會發生STW。
并發預備重分配(Concurrent Prepare for Relocate)
這一階段會根據特定的查詢條件統計出本次收集過程要清理的Region,將這些Region組成重分配集(Relocation Set)。
發重分配(Concurrent Relocate)
這一階段是ZGC的核心階段。這一階段會把存活的對象復制到新的Region,并未重分配集中的每個Region維護一個轉發表(Forward Table),記錄舊對象到新對象的轉向關系。由于有染色指針的存在,ZGC收集器能從引用上明確知道這個對象是否處于重分配集中,如果此時用戶線程并發訪問了重分配集中的對象,就會觸發讀屏障,然后根據Region上的轉發記錄將訪問轉發到新復制的對象上。同時,修正更新該引用的值,使其指向新對象。
并發重映射(Concurrent Remap)
重映射就是修正整個堆中指向重分配集中舊對象的所有引用。因為有并發重分配的存在,這里即使不去做重映射也不會出現任何問題。因此,為了節省性能ZGC把這個階段合并到了下一次垃圾收集循環中的并發標記階段來完成。這樣節省了一次遍歷對象圖的性能開銷。當所有指針被修正后,記錄新舊關系的轉發表也會被釋放掉。
可見,ZGC的整個收集過程只有在標記階段有短暫的停頓,這是為什么ZGC能將停頓控制在10ms以內的原因。在最近發布的JDK15中,ZGC已經結束了實驗階段,成為了JDK15正式的垃圾收集器。
3.3 Shenandoah收集器
其實,開始并沒有打算寫Shenanoah這個收集器。但是,看到剛剛發布的JDK15中已經將Shenanoah轉正了。所以還是花點時間聊一聊這款收集器。Shenanoah收集器其實并非Oracle公司開發的,而是RedHat公司開發,并在2014年將它貢獻給了Open JDK。既然在JDK15中Oracle將Shenanoah正式納入JDK中,必然證明Shenanoah至少是一款可以媲美ZGC的收集器。事實也確實如此,它與ZGC一樣可以將垃圾收集時的停頓時間控制在10ms以內。
Shenandoah的堆內存劃分
Shenandoah收集器對堆內存的劃分與G1更為相似,它也是基于Region的堆內存布局,同樣有存放大對象的Humongous Region。但是,Shenandoah沒有將Region再分為新生代和老年代。同時,Shenandoah摒棄了G1收集器中耗費大量內存和計算資源維護的記憶集,改用了鏈接矩陣的全局數據結構來記錄跨Region的引用關系。
Shenandoah的關鍵技術
(1)鏈接矩陣
上邊提到Shenandoah使用了鏈接矩陣來處理跨Region引用的問題。什么是鏈接矩陣呢?我們可以將它簡單理解為一張二維表格,如果Region N有對象指向RegionM,就在表格的N行M列中打上一個標記,如下圖所示,如果Region 5中的對象Baz引用了Region 3的Foo,Foo又引用了Region 1的Bar,那連接矩陣中的5行3列、3行1列就應該被打上標記。在回收時通過這張表格就可以得出哪些Region之間產生了跨代引用。
(2)轉發指針
轉發指針的核心內容就是,在原有對象布局結構的最前面增加一個新的引用字段,在正常不處于并發移動的情況下,該引用指向對象自己。如下圖:
轉發指針加入后帶來的收益自然是當對象擁有了一份新的副本時,只需要修改一處指針的值,即舊對象上轉發指針的引用位置,使其指向新對象,便可將所有對該對象的訪問轉發到新的副本上。這樣只要對象的內存仍然存在,未被清理掉,虛擬機內存中所有通過舊引用地址訪問的代碼仍然可用,都會被自動轉發到新對象上繼續工作。
Brooks Pointers 轉發指針在設計上決定了它是必然會出現多線程競爭問題的。Shenandoah收集器是通過比較交換(Compare And Swap,CAS)操作來保證并發時堆中的訪問正確性的。
Shenandoah的并發收集過程
Shenandoah收集器的工作過程大致可以劃分為以下九個階段:
(1)初始標記: 與G1一樣,首先標記與GC Roots直接關聯的對象,這個階段仍是STW的,這一階段停頓時間很短。
(2)并發標記: 與G1一樣,從GC Roots開始對堆中對象進行可達性分析,找出存活對象。并發標記階段耗時較長,但是這一階段是并發執行的,因此,對性能不會造成影響。
(3)最終標記: 與G1一樣,處理剩余的SATB掃描,并在這個階段統計出回收價值最高的Region,將這些Region構成一組回收集。此階段也會有一小段短暫的停頓。
(4)并發清理: 這個階段用于清理那些整個區域內連一個存活對象都沒有找到的Region。
(5)并發回收: 這個階段是Shenandoah與之前HotSpot中其他收集器的核心差異。在這個階段,Shenandoah要把回收集里面的存活對象先復制一份到其他未被使用的Region中。但是有個難點是在移動對象的同時,用戶線程仍然可能不停的對被移動的對象進行讀寫訪問,移動對象之后整個內存中所有指向該對象的引用都還是舊對象的地址,這是很難一瞬間全部改變過來的。對于這個難點,Shenandoah將會通過讀屏障和被稱為“Brooks Pointers”的轉發指針來解決。
并發回收階段運行時間的長短取決于回收集的大小。
(6)初始引用更新: 并發回收階段復制對象結束后,還需要把堆中所有指向舊對象的引用修正蛋糕復制后的新地址,這個操作稱為引用更新。這個階段就是對這個操作進行初始化的,初始引用更新時間很短,會產生一個非常短暫的停頓。
并發引用更新: 真正開始進行引用更新操作,這個階段是與用戶線程一起并發的,時間長短取決于內存中涉及的引用數量的多少。
(7)最終引用更新: 解決了堆中的引用更新后,還要修正存在于GC Roots 中的引用。這個階段是Shenandoah的最后一次停頓,時間長短與GC Roots的數量有關。
(8)并發清理: 經過并發回收和引用更新之后,整個回收集中所有的Region已再無存活對象,最后再調用一次并發清理過程來回收這些Region 的內存空間,供以后新對象分配使用。
總結
本篇文章從堆的分代到垃圾收集算法再到垃圾收集器都做了比較詳細的講解。關于垃圾收集器這部分內容其實本來沒打算寫的太詳細,因為筆者本身做的是客戶端,而客戶端對于收集器的性能并沒有太高的要求,加之Android端到目前為止僅支持到Java8版本。所以一般情況下,客戶端開發者不太會關注到Java8之后比較前沿的Java知識。像ZGC、Shenandoah這種前沿的垃圾收集器更不會關注太多。但是對于而后臺開發而言卻是完全不同。拿垃圾收集器來說,后臺相比客戶端會比較迫切的需要能夠支持大內存、低延遲的收集器。所以,既然寫Java GC的文章,就不能只面向客戶端。講不明白垃圾收集器總覺得會少了點什么。所以在寫ZGC和Shenandoah收集器的過程中花了大量時間去查閱了很多資料,往往是看懂了,理解了,然后才能動手來寫。因此,免不了的也會有些疏漏或錯誤,還望大家多多包含。同時,希望大家通過這篇文章能夠得到自己想要的知識。
參考&推薦閱讀
《深入理解Java虛擬機 第二版/第三版》作者:周志明
一文看懂JVM內存布局及GC原理
深入理解 JVM 垃圾回收機制及其實現原理
Java8內存模型—永久代(PermGen)和元空間(Metaspace)
搞定JVM垃圾回收就是這么簡單
Java程序員的榮光,聽R大論JDK11的ZGC
新一代垃圾回收器ZGC的探索與實踐
深入理解JVM(③)低延遲的Shenandoah收集器
總結
以上是生活随笔為你收集整理的Java进阶--Java垃圾回收机制全面解析的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 转使用jQuery Ajax的内存回收
- 下一篇: 面试官:说说什么是 Java 内存模型(