@
目錄- 前言
- 正文
- 一、垃圾收集演算法
- 標記-復制
- 標記-清除
- 標記-整理
- 分代回收
- 二、常用的垃圾回收器
- Serial/SerialOld
- ParNew
- Parallel Scavenge/ParallelOld
- CMS
- Garbage First
- 一、垃圾收集演算法
- 總結
前言
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):熬過越多次垃圾收集程序的物件就越難以消
亡,
上面兩個假說共同確定了垃圾收集器一致的設計原則,即新生代和老年代,在新生代中使用復制演算法,如上所說,大部分物件朝生夕滅,所以只需要將少量存活物件復制到另一塊區域后再統一格式化之前的區域;而老年代因為大量物件存活,只能采用標記清除或標記整理演算法,
分代回收可以避免垃圾回收時總是進行全堆掃描,但是也帶來另外一個問題,不同代之間可能會存在參考,若沒有其它的處理手段,那么在進行新生代垃圾回收時除了遍歷GC Roots外,不得不再額外遍歷整個老年代中的物件,所以為解決這個問題,需要添加第三條經驗法則:
- 跨代參考假說:跨代參考相對于同代參考來說僅占極少數,
兩個相互依賴的物件,基本上是應該是同生同滅的,所以跨代參考數應該是比較少的,那么JVM在掃描時自然就沒必要也不應該去掃描整個老年代,可以在收集區域維護一個資料結構,記錄從非收集區域指向收集區域的參考,那么在掃描時只需要額外掃描這個資料結構就行了,該結構被稱為記憶集(相關細節下一篇分析),
二、常用的垃圾回收器
垃圾回收器是垃圾回收演算法的實作,在虛擬機規范中并沒有定義要如何實作垃圾回收器,所以各大廠商對垃圾回收器的實作有很大差別,但都是在朝著一個方向努力:低延遲、高吞吐量,

上圖中展示的就是目前主流的垃圾回收器,有連線的代表兩者可以搭配使用,而打“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引數開啟,
總結
本篇是對常用垃圾收集器的實作原理的整體性分析比較,這一部分是必須掌握的,下一篇則是關于演算法的實作細節,如三色標記是什么、并發標記程序中參考變動如何解決、跨代參考如何處理等等一系列問題,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/134850.html
標籤:Java
