【JVM從小白學成大佬】5.垃圾收集器及記憶體分配策略

發表於2019-08-29

前面介紹了垃圾回收演算法,接下來我們介紹垃圾收集器和記憶體分配的策略。有沒有一種牛逼的收集器像銀彈一樣適配所有場景?很明顯,不可能有,不然我也沒必要單獨搞一篇文章來介紹垃圾收集器了。熟悉不同收集器的優缺點,在實際的場景中靈活運用,才是王道。

在開始介紹垃圾收集器前,我們可以劇透幾點:

  • 根據不同分代的特點,收集器可能不同。有些收集器可以同時用於新生代和老年代,而有些時候,則需要分別為新生代或老年代選用合適的收集器。一般來說,新生代收集器的收集頻率較高,應選用效能高效的收集器;而老年代收集器收集次數相對較少,對空間較為敏感,應當避免選擇基於複製演算法的收集器。
  • 在垃圾收集執行的時刻,應用程式需要暫停執行
  • 可以序列收集,也可以並行收集。
  • 如果能做到併發收集(應用程式不必暫停),那絕對是很妙的事情。
  • 如果收集行為可控,那也是很妙的事情。

希望大家帶著下面的問題進行閱讀,有目標的閱讀,可能收穫更多。

  1. 為什麼沒有一種牛逼的收集器像銀彈一樣適配所有場景?
  2. CMS和G1的對比,你知道他兩的區別嗎?
  3. 為什麼CMS只能用作老年代收集器,而不能應用在新生代的收集?
  4. 為什麼JVM的分代年齡是15?而不是16,20之類的呢?
  5. “動態物件年齡判定”裡有個“天坑”哦,是啥坑呢?

1 垃圾收集器

GC執行緒與應用執行緒保持相對獨立,當系統需要執行垃圾回收任務時,先停止工作執行緒,然後命令GC執行緒工作。以序列模式工作的收集器,稱為序列收集器(即Serial Collector)。與之相對的是以並行模式工作的收集器,稱為並行收集器(即Paraller Collector)

1.1 序列收集器:Serial

序列收集器採用單執行緒方式進行收集,且在GC執行緒工作時,系統不允許應用執行緒打擾。此時,應用程式進入暫停狀態,即Stop-the-world。

Stop-the-world暫停時間的長短,是度量一款收集器效能高低的重要指標。

是針對新生代的垃圾回收器,基於標記-複製演算法

1.2 並行收集器:ParNew

並行收集器充分利用了多處理器的優勢,採用多個GC執行緒並行收集。可想而知,多條GC執行緒執行顯然比只使用一條GC執行緒執行的效率更高。一般來說,與序列收集器相比,在多處理器環境下工作的並行收集器能夠極大地縮短Stop-the-world時間。

針對新生代的垃圾回收器,標記-複製演算法,可以看成是Serial的多執行緒版本

1.3 吞吐量優先收集器:Parallel Scavenge

針對新生代的垃圾回收器,標記-複製演算法,和ParNew類似,但更注重吞吐率。在ParNew的基礎上演化而來的Parallel Scanvenge收集器被譽為“吞吐量優先”收集器。吞吐量就是CPU用於執行使用者程式碼的時間與CPU總消耗時間的比值,即吞吐量=執行使用者程式碼時間 /(執行使用者程式碼時間 + 垃圾收集時間)。如虛擬機器總執行了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%。

Parallel Scanvenge收集器在ParNew的基礎上提供了一組引數,用於配置期望的收集時間或吞吐量,然後以此為目標進行收集。

通過VM選項可以控制吞吐量的大致範圍:

  • -XX:MaxGCPauseMills:期望收集時間上限。用來控制收集對應用程式停頓的影響。
  • -XX:GCTimeRatio:期望的GC時間佔總時間的比例,用來控制吞吐量。
  • -XX:UseAdaptiveSizePolicy:自動分代大小調節策略。

但要注意停頓時間與吞吐量這兩個目標是相悖的,降低停頓時間的同時也會引起吞吐的降低。因此需要將目標控制在一個合理的範圍中。

1.4 Serial Old收集器

Serial Old是Serial收集器的老年代版本,單執行緒收集器,使用標記-整理演算法。這個收集器的主要意義也是在於給Client模式下的虛擬機器使用。

1.5 Parallel Old收集器

Parallel Old是Parallel Scanvenge收集器的老年代版本,多執行緒收集器,使用標記-整理演算法

1.6 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器

CMS收集器僅作用於老年代的收集,是基於標記-清除演算法的,它的運作過程分為4個步驟:

  • 初始標記(CMS initial mark)
  • 併發標記(CMS concurrent mark)
  • 重新標記(CMS remark)
  • 併發清除(CMS concurrent sweep)

其中,初始標記、重新標記這兩個步驟仍然需要Stop-the-world。初始標記僅僅只是標記一下GC Roots能直接關聯到的物件,速度很快,併發標記階段就是進行GC Roots Tracing的過程,而重新標記階段則是為了修正併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間一般會比初始階段稍長一些,但遠比並發標記的時間短。

CMS以流水線方式拆分了收集週期,將耗時長的操作單元保持與應用執行緒併發執行。只將那些必需STW才能執行的操作單元單獨拎出來,控制這些單元在恰當的時機執行,並能保證僅需短暫的時間就可以完成。這樣,在整個收集週期內,只有兩次短暫的暫停(初始標記和重新標記)達到了近似併發的目的

CMS收集器優點:併發收集、低停頓。

CMS收集器缺點

  • CMS收集器對CPU資源非常敏感。
  • CMS收集器無法處理浮動垃圾(Floating Garbage)。
  • CMS收集器是基於標記-清除演算法,該演算法的缺點都有。

CMS收集器之所以能夠做到併發,根本原因在於採用基於“標記-清除”的演算法並對演算法過程進行了細粒度的分解。前面篇章介紹過標記-清除演算法將產生大量的記憶體碎片這對新生代來說是難以接受的,因此新生代的收集器並未提供CMS版本。

1.7 G1收集器

G1重新定義了堆空間,打破了原有的分代模型,將堆劃分為一個個區域。這麼做的目的是在進行收集時不必在全堆範圍內進行,這是它最顯著的特點。區域劃分的好處就是帶來了停頓時間可預測的收集模型:使用者可以指定收集操作在多長時間內完成。即G1提供了接近實時的收集特性。

G1與CMS的特徵對比如下:

特徵 CMS G1
併發和分代
最大化釋放堆記憶體
低延時
吞吐量
壓實
可預測性
新生代和老年代的物理隔離

G1具備如下特點:

  • 並行與併發:G1能充分利用多CPU、多核環境下的硬體優勢,使用多個CPU來縮短Stop-the-world停頓的時間,部分其他收集器原來需要停頓Java執行緒執行的GC操作,G1收集器仍然可以通過併發的方式讓Java程式繼續執行。
  • 分代收集
  • 空間整合:與CMS的標記-清除演算法不同,G1從整體來看是基於標記-整理演算法實現的收集器,從區域性(兩個Region之間)上來看是基於“複製”演算法實現的。但無論如何,這兩種演算法都意味著G1運作期間不會產生記憶體空間碎片,收集後能提供規整的可用記憶體。這種特性有利於程式長時間執行,分配大物件時不會因為無法找到連續記憶體空間而提前觸發下一次GC
  • 可預測的停頓:這是G1相對於CMS的一個優勢,降低停頓時間是G1和CMS共同的關注點。

在G1之前的其他收集器進行收集的範圍都是整個新生代或者老年代,而G1不再是這樣。在堆的結構設計時,G1打破了以往將收集範圍固定在新生代或老年代的模式,G1將堆分成許多相同大小的區域單元,每個單元稱為Region。Region是一塊地址連續的記憶體空間,G1模組的組成如下圖所示:

G1堆的Region佈局.png

G1收集器將整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,它們都是一部分Region(不需要連續)的集合。G1收集器之所以能建立可預測的停頓時間模型,是因為它可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。G1會通過一個合理的計算模型,計算出每個Region的收整合本並量化,這樣一來,收集器在給定了“停頓”時間限制的情況下,總是能選擇一組恰當的Regions作為收集目標,讓其收集開銷滿足這個限制條件,以此達到實時收集的目的。

對於打算從CMS或者ParallelOld收集器遷移過來的應用,按照官方 的建議,如果發現符合如下特徵,可以考慮更換成G1收集器以追求更佳效能:

  • 實時資料佔用了超過半數的堆空間;
  • 物件分配率或“晉升”的速度變化明顯;
  • 期望消除耗時較長的GC或停頓(超過0.5——1秒)。

原文如下:
Applications running today with either the CMS or the ParallelOld garbage collector would benefit switching to G1 if the application has one or more of the following traits.

  • More than 50% of the Java heap is occupied with live data.
  • The rate of object allocation rate or promotion varies significantly.
  • Undesired long garbage collection or compaction pauses (longer than 0.5 to 1 second)

G1收集的運作過程大致如下:

  • 初始標記(Initial Marking):僅僅只是標記一下GC Roots能直接關聯到的物件,並且修改TAMS(Next Top at Mark Start)的值,讓下一階段使用者程式併發執行時,能在正確可用的Region中建立新物件,這階段需要停頓執行緒,但耗時很短
  • 併發標記(Concurrent Marking):是從GC Roots開始堆中物件進行可達性分析,找出存活的物件,這階段耗時較長,但可與使用者程式併發執行。
  • 最終標記(Final Marking):是為了修正併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分標記記錄,虛擬機器將這段時間物件變化記錄線上程Remembered Set Logs裡面,最終標記階段需要把Remembered Set Logs的資料合併到Remembered Set中,這階段需要停頓執行緒,但是可並行執行
  • 篩選回收(Live Data Counting and Evacuation):首先對各個Region的回收價值和成本進行排序,根據使用者所期望的GC停頓時間來制定回收計劃。這個階段也可以做到與使用者程式一起併發執行,但是因為只回收一部分Region,時間是使用者可控制的,而且停頓使用者執行緒將大幅提高收集效率。

我們可以看下官方文件對G1的展望(這段英文描述比較簡單,我就不翻譯了):

Future:
G1 is planned as the long term replacement for the Concurrent Mark-Sweep Collector (CMS). Comparing G1 with CMS, there are differences that make G1 a better solution. One difference is that G1 is a compacting collector. G1 compacts sufficiently to completely avoid the use of fine-grained free lists for allocation, and instead relies on regions. This considerably simplifies parts of the collector, and mostly eliminates potential fragmentation issues. Also, G1 offers more predictable garbage collection pauses than the CMS collector, and allows users to specify desired pause targets.

2 記憶體分配策略

物件的記憶體分配,往大方向上講,就是在上分配(但也可能經過JIT編譯後被拆散為標量型別並間接地棧上分配),物件主要分配在新生代的Eden區上,如果啟動了本地執行緒分配緩衝,將按執行緒優先在TLAB上分配。少數情況下可能會直接分配在老年代中。

2.1 物件優先在Eden分配

大多數情況下,物件在新生代Eden區中分配。當Eden區沒有足夠空間進行分配時,虛擬機器將發起一次Minor GC(前面篇章中有介紹過Minor GC)。但也有一種情況,在記憶體擔保機制下,無法安置的物件會直接進到老年代。

2.2 大物件直接進入老年代

大物件時指需要大量連續記憶體空間的Java物件,最典型的大物件就是那種很長的字串以及陣列。

虛擬機器提供了一個-XX:PretenureSizeThreshold引數,令大於這個設定值的物件直接在老年代分配。目的就是避免在Eden區及兩個Survivor區之間發生大量的記憶體複製。

2.3 長期存活的物件將進入老年代

虛擬機器給每個物件定義了一個物件年齡(Age)計數器。如果物件在Eden出生並經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,將被移動到Survivor空間中,並且物件年齡設為1 。物件在Survivor區中沒經過一次Minor GC,年齡就加1歲,當年齡達到15歲(預設值),就會被晉升到老年代中。

物件晉升老年代的年齡閾值,可以通過引數-XX:MaxTenuringThreshold設定。

接下來我們來回答為什麼JVM的分代年齡為什麼是15?而不是16,20之類的呢?

真的不是為什麼不能是其它數(除了15),著實是臣妾做不到啊!

事情是這樣的,HotSpot虛擬機器的物件頭其中一部分用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等,這部分資料的長度在32位和64位的虛擬機器(未開啟壓縮指標)中分別為32bit和64bit,官方稱它為“Mark word”。

例如,在32位的HotSpot虛擬機器中,如果物件處於未被鎖定的狀態下,那麼Mark Word的32bit空間中25bit用於儲存物件雜湊碼,4bit用於儲存物件分代年齡,2bit用於儲存鎖標誌位,1bit固定為0 。

明白是什麼原因了嗎?物件的分代年齡佔4位,也就是0000,最大值為1111也就是最大為15,而不可能為16,20之類的了。

2.4 動態物件年齡判定

為了能更好的適應不同程式的記憶體狀況,虛擬機器並不是永遠地要求兌現過的年齡必須達到了MaxTenuringThreshold才能晉升老年代。

滿足如下條件之一,物件能晉升老年代:

  • 1.物件的年齡達到了MaxTenuringThreshold(預設15)能晉升老年代。
  • 2.如果在Survivor空間中相同年齡所有物件大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。

很多文章都只是注意到了上面描述的情況(包括阿里中介軟體公眾號發的一篇文章裡也只是這麼簡單的介紹,當時給它們後臺留過言說明情況),但如果只是這麼認識的話,會發現在實際的記憶體回收中有悖於此條規定。

舉個小栗子,如物件年齡5的佔28%,年齡6的佔20%,年齡7的佔52%,按那兩個標準,物件是不能進入老年代的,但Survivor都已經100%了啊

大家可以關注這個引數TargetSurvivorRatio,目標存活率,預設為50%。大致意思就是說年齡從小到大累加,如加入某個年齡段(如栗子中的年齡7)後,總佔用超過Survivor空間*TargetSurvivorRatio的時候,從該年齡段開始及大於的年齡物件就要進入老年代(即栗子中的年齡7物件)。動態物件年齡判斷,主要是被TargetSurvivorRatio這個引數來控制。而且算的是年齡從小到大的累加和,而不是某個年齡段物件的大小。

2.5 空間分配擔保

在發生Minor GC之前,虛擬機器會先檢查老年代最大可用的連續空間是否大於新生代所有物件總空間,如果這個條件成立,那麼Minor GC可以確保是安全的。如果不成立,則虛擬機器會檢視HandlePromotionFailure設定值是否允許擔保失敗。如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代物件的平均大小,如果大於,將嘗試著進行一次Minor GC,儘管這次Minor GC是有風險的;如果小於,或者HandlePromotionFailure設定不允許冒險,那這時也要改為進行一次Full GC

上面說的風險是什麼呢?我們知道,新生代使用複製收集演算法,但為了記憶體利用率,只使用其中一個Survivor空間來作為輪換備份,因此當出現大量物件在Minor GC後仍然存活的情況(最極端的情況就是記憶體回收後新生代中所有物件都存活),就需要老年代進行分配擔保,把Survivor無法容納的物件直接進入老年代。

3 總結腦圖

記憶體分配策略.png

腦圖太大,如需高清完整大圖,請留言告知。

相關文章