深入探究JVM之垃圾回收器

夜勿語發表於2020-07-25

@

前言

JVM的自動記憶體管理得益於不斷髮展的垃圾回收器,從最初的單執行緒收集到現在併發收集,垃圾回收器的開發者們一直在致力於如何降低GC過程中的停頓時間(STW)以及提高吞吐量,但直到現在也不存在一款完美的垃圾回收器,只能根據不同的場景選擇最合適的。所以需要了解每款垃圾回收器出現的背景、原因,並掌握各種垃圾回收器的設計原理、演算法實現細節以及各個垃圾回收器的優劣對比,這樣才能讓我們在調優時做出最合適的選擇。這部分內容博主準備分為兩篇文章進行總結講解,本篇主要是對垃圾收集演算法的思想以及目前穩定商用的垃圾回收器的講解。

正文

一、垃圾收集演算法

上文分析了JVM判斷物件存活的兩種演算法:引用計數可達性分析。因此垃圾收集演算法的實現也對應的分為引用計數式收集追蹤式收集,而目前JVM中都沒有使用引用計數演算法,所以後面講解的演算法都屬於追蹤式收集。其細分又分為標記-複製標記-清除標記-整理分代回收

標記-複製

複製演算法最初的理論是將可用記憶體分為1:1的兩塊,每次只使用其中一塊,當這塊記憶體滿後,就先標記存活物件並將其複製到另一塊記憶體,然後將滿的記憶體釋放掉。這種演算法非常簡單高效,只需要將標記的存活物件複製到另一半空間,同時記憶體始終保持規整,不會出現記憶體碎片,但缺點也很明顯,可用記憶體減少了一半,另外複製的物件不能太大,否則複製的效率會比較低。
因為新生代中的物件大多“朝生夕死”,在JVM新生代中的垃圾收集器都是採用的複製演算法。但是為避免浪費的空間太多,提出了一種更為優化的複製演算法,稱為Appel式回收。該演算法不再是簡單的“半區複製”,而是將新生代分為了三塊:一塊Eden區和兩塊Survivor區(分別標記為from和to),預設的分配比例是8:1:1(-XX:SurvivorRatio=8表示兩個Survivor區和Eden區比例為2:8,即每個Survivor佔10%),每次分配物件都只使用Eden區和其中一塊Survivor區(from區)。其中Eden區最大,新物件都在該區域建立,當Eden區滿後,會進行一次MinorGC,並將Eden區和from區中存活物件都複製到to區中,然後調換from和to指標。當然肯定是存在to區裝不下一次MinorGC存活物件的情況,這時就需要老年代進行分配擔保(相關概念在上一篇已經講過)。
從上面的演算法過程中堵著門應該會有一個疑惑:為什麼需要兩個Survivor區?這裡以假設法進行分析。如果沒有Survivor區,那麼新生代每次GC後存活物件會直接進入老年代,導致老年代迅速填滿,頻繁的觸發FullGC;如果只有一塊Survivor區,那麼為了保證複製演算法的特性(記憶體規整和高效),Eden區經過一次MinorGC後會將物件複製到Survivor區,這時新物件只能在Survivor區建立,否則無法保證記憶體規整,但又由於Survivor區非常小,就會導致很快又觸發有一次MinorGC;而如果有兩塊Survivor區就很好的解決了上面所說的問題,而更多的Survivor區就沒有必要了。

標記-清除

標記清除是最早出現的垃圾回收演算法,由Lisp之父提出。這個演算法也很簡單,首先標記存活的物件,然後統一回收未被標記的物件。相較於複製演算法的缺點也很明顯,效率更低,同時會導致記憶體碎片。為什麼效率更低了呢,好比你刪除檔案,直接格式化資料夾快還是去資料夾中找到檔案一個個刪除更快?另外記憶體碎片會導致堆中明明還有足夠的記憶體,但卻沒有足夠的連續記憶體來存放大物件,導致物件直接進入老年代。

標記-整理

這個演算法就是建立在標記清除的基礎之上,多了一步整理的工作,標記完成後首先將存活的物件移動到一邊,然後清理掉另一邊的記憶體,解決了記憶體碎片帶來的問題。標記-清除標記-整理都適合用在老年代中,而前者相較於後者不用移動記憶體,而移動記憶體是一種非常“危險”的操作,需要暫停其它使用者執行緒的執行,確保記憶體指向的正確性,所以這就是STW出現的原因,就好比你不能在你媽媽打掃屋子的同時邊往地上扔垃圾。

分代回收

分代回收嚴格意義上並不算一種演算法,而是各回收演算法的實踐理論。它建立在兩個分代假說之上:

  • 弱分代假說(Weak Generational Hypothesis):絕大多數物件都是朝生夕滅的。
  • 強分代假說(Strong Generational Hypothesis):熬過越多次垃圾收集過程的物件就越難以消
    亡。

上面兩個假說共同確定了垃圾收集器一致的設計原則,即新生代老年代。在新生代中使用複製演算法,如上所說,大部分物件朝生夕滅,所以只需要將少量存活物件複製到另一塊區域後再統一格式化之前的區域;而老年代因為大量物件存活,只能採用標記清除標記整理演算法。

二、常用的垃圾回收器

垃圾回收器是垃圾回收演算法的實現,在虛擬機器規範中並沒有定義要如何實現垃圾回收器,所以各大廠商對垃圾回收器的實現有很大差別,但都是在朝著一個方向努力:低延遲、高吞吐量。
在這裡插入圖片描述

上圖中展示的就是目前主流的垃圾回收器,有連線的代表兩者可以搭配使用,而打“X”的表示在JDK9中已經廢棄的組合,另外從圖中我們還可以發現除了G1,其它垃圾回收器都只能作用於新生代老年代中的其中一個區域,那麼G1是不是表示廢除了分代理論呢?下面來逐個介紹。

Serial/SerialOld

這兩個是最早出現的垃圾回收器,如其名,它們都是單執行緒的垃圾回收器,只適合幾十兆到一兩百兆的堆空間的垃圾回收,如果用於更大的堆空間會導致系統停頓時間較長,想象一下系統每隔一段時間就要停止處理請求幾分鐘甚至更長時間,你能接受麼?下圖是他們的工作原理:
在這裡插入圖片描述
可以看到新生代或老年代在進行垃圾回收時都會暫停所有的使用者執行緒,圖中的SafePoint表示執行緒能夠安全暫停的時機,即JVM要進行垃圾回收時,不可能立馬就停止所有的執行緒,那樣是非常危險的,必須要確保執行緒處於安全點才能暫停它。這裡先有這個概念,細節在下一篇進行闡述。
該組合可以通過-XX:+UseSerialGC引數開啟。

ParNew

該收集器就是Serial的多執行緒版本,但在單核處理器環境中表現還不如Serial(涉及執行緒的切換)。它預設開啟的收集執行緒數與處理器核心數量相同,在處理器核心非常多的環境中,可以使用-XX:ParallelGCThreads引數來限制垃圾收集的執行緒數。
在這裡插入圖片描述
另外需要注意的是它是除了Serial之外唯一可以與CMS配合的垃圾收集器,在啟用CMS後(使用-XX:+UseConcMarkSweepGC選項)的預設新生代收集器,也可以使用-XX:+/-UseParNewGC選項來強制指定或者禁用它,在JDK9以後ParNew成為了CMS的一部分。

Parallel Scavenge/ParallelOld

Parallel Scavenge與其它垃圾收集器不同,其它的是追求儘可能小的GC停頓時間,而它主要關注吞吐量,所謂吞吐量就是程式碼執行時間/(程式碼執行時間 + 垃圾回收時間)。比如虛擬機器執行100分鐘,垃圾回收耗時1分鐘,那麼吞吐量就是99%。但是這款收集器在JDK1.6之前比較尷尬,沒有與之對應的並行的老年代收集器,只能採用SerialOld老年代收集器,使得表現比不上PareNew+CMS的組合。直到ParallelOld出現後,Parallel Scavenge才能真正的展現它吞吐量的優勢。
在這裡插入圖片描述
Parallel Scavenge有以下幾個重要的引數:

  • -XX:MaxGCPauseMillis:該引數的值是一個大於0的毫秒數,收集器儘量保證GC停頓時間不超過該值,但是不要天真的認為該值越小越好。該值設定的太小會導致每次GC的回收率降低,垃圾堆積,GC發生的越來越頻繁。比如原先需要100ms收集500M空間,現在設定為50ms,那麼可能就只能回收300M或者更小的垃圾。
  • -XX:GCTimeRatio:控制垃圾回收時間比率。比如允許最大垃圾回收時間佔總時間的5%,那麼需要將該值設定為19(公式是1/(1 + 19))。
  • -XX:+UseAdaptiveSizePolicy:這個引數啟用後,就不再需要我們手動設定新生代各區(Eden、from、to)的比例(-XX:SurvivorRatio),晉升老年代物件的大小(-XX:PretenureSizeThreshold),虛擬機器會監控執行時的狀態,進行動態的調整,這種方式稱為垃圾收集的自適應調節策略(GC Ergonomics)。

CMS

CMS(Concurrent Mark Sweep)是第一款併發垃圾收集器,併發是指垃圾收集可以和使用者執行緒同時進行。同時它也是唯一採用標記清除演算法對老年代進行回收的垃圾回收器。它包含了以下幾個階段:

  • 初始標記:STW,只標記與GC Roots直接關聯的物件
  • 併發標記:和使用者執行緒同時執行,進行可達性分析
  • 重新標記:STW,暫停使用者執行緒,修正上一階段變動的物件
  • 併發清除:最後是併發的清除掉垃圾

在這裡插入圖片描述
從上面我們可以發現CMS的整個過程中只有初始標記重新標記是需要暫停使用者執行緒的,而初始標記只是標記與GC Roots直接關聯的物件,所以耗時只和GC Roots的數量有關,非常快;重新標記的耗時會比初始標記略長,但也遠遠比並發標記用時短,所以CMS就是通過細分GC的階段來降低GC的停頓時間。
你可能會好奇為什麼需要重新標記並且暫停所有使用者執行緒,因為在與使用者執行緒併發執行的同時肯定會存在引用變動的情況,而要處理這個問題,都是必須要暫停使用者執行緒的,關於引用變動的處理在下一篇會詳細分析。
CMS可以說是一款跨時代的垃圾收集器,可以回收幾個G到-20G左右的堆空間,但它存在以下幾個明顯的缺點:

  • CPU敏感:雖然併發標記併發標記是和使用者執行緒併發執行的,但是也因此佔用了系統的資源,導致應用程式忽然變慢,降低吞吐量。CMS預設啟動的執行緒數是(處理器核心數+3)/4,因此當核心數量大於等於4時,GC佔用資源不超過25%,但核心數小於4時,就會佔用大量系統資源。
  • 大量的記憶體碎片:因為CMS是使用標記清除演算法實現垃圾回收,所以會產生大量的記憶體碎片。為了避免這個問題,CMS採用了一個折中的辦法,即提供一個-XX:+UseCMS-CompactAtFullCollection引數,該引數預設開啟,控制CMS在進行FullGC的同時進行空間整理,但這樣又會導致停頓時間加長,所以還提供了-XX:CMSFullGCsBefore-Compaction引數,控制CMS在進行了多少次不帶整理的FullGC後進行一次帶整理的FullGC,預設值是0,即每次FullGC都會整理,該引數JDK9後被廢棄。
  • 浮動垃圾:因為最終清除的過程也是和使用者執行緒併發執行的,因此這個過程中必然會產生新的垃圾,這一部分垃圾需要預留空間來存放,等待下一次GC的時候再清理,因此會浪費一部分空間。在JDK5的預設配置下,當老年代使用空間超過68%時就會進行GC,到JDK6時,這個閾值就提高到了92%,另外也可以通過-XX:CMSInitiatingOccu-pancyFraction引數控制。但該值越高,那麼併發清理過程中可使用的記憶體就越小,當放不下時,就會出現一次Concurrent Mode Failure,這時候虛擬機器就會凍結執行緒並採用SerialOld進行垃圾回收,導致停頓時間變得更長。

Garbage First

G1是目前最前沿且可商用的垃圾收集器,另外還有ZGC等更為前沿的垃圾收集器還處於試驗階段。它與其它垃圾收集器不同的是,他將堆空間化整為零,將記憶體區域劃分為多個大小相等的獨立區域(Region),使得它可以回收堆中的任何一個區域,而不是像其它的垃圾收集器要麼只能回收新生代,要麼只能回收老年代。但不是說G1就沒有新生代和老年代了,它的每個Region都可以根據需要扮演Eden、Survivor或老年代,垃圾收集器也會針對不同角色的Region採用不同的策略去處理。
在這裡插入圖片描述
每個Region的大小可以通過-XX:G1HeapRegionSize設定,取值範圍為1M~32M,且必須為2的N次冪。超過單個Region一半容量的物件即為大物件,而對於超過整個Region的物件將會使用多個連續的Humongous空間存放,G1大多數情況下都把Humongous作為老年代一部分看待。
在這裡插入圖片描述
G1的執行過程如上,它也包含了以下4個步驟:

  • 初始標記:STW,也是隻標記GC Roots直接關聯的物件,並修改TAMS的指標值(G1為每一個Region設計了兩個名為TAMS(Top at Mark Start)的指標,把Region中的一部分空間劃分出來用於併發回收過程中的新物件分配,併發回收時新分配的物件地址都必須要在這兩個指標位置以上,垃圾回收時也不會回收這部分空間),這個過程耗時很短,而且是借用進行 Minor GC 的時候同步完成的,所以 G1 收集器在這個階段實際並沒有額外的停頓。
  • 併發標記:可達性分析找出要回收的物件,在物件掃描完成後,由於是與使用者執行緒併發執行的,所以存在引用變動的物件,這部分物件會由SATB演算法來解決(原始快照,下一篇詳細分析)。
  • 最終標記:STW,處理併發階段遺留的少量遺留的SATB記錄。
  • 篩選回收:根據使用者設定的-XX:MaxGCPauseMillis最大GC停頓時間對Region進行排序,並回收價值最大的Region,儘量保證滿足引數設定的值(該值效果和Parallel Scavenge部分講解的是一樣的)。這裡的回收演算法就是講存活的物件複製到空的Region中,即G1區域性Region之間採用的是複製演算法,而整體上採用的是標記整理演算法

G1適合上百G的堆空間回收,與CMS的權衡在6~8G之間,較大的堆記憶體才能凸顯G1的優勢,可以通過-XX:+UseG1GC引數開啟。

總結

本篇是對常用垃圾收集器的實現原理的整體性分析比較,這一部分是必須掌握的,下一篇則是關於演算法的實現細節,如三色標記是什麼、併發標記過程中引用變動如何解決、跨代引用如何處理等等一系列問題。

相關文章