JVM 系列文章之 GC 演算法淺析

pjmike_pj發表於2018-09-07

Java的堆結構

再介紹垃圾回收演算法之前,先來看看 Java中的堆,Java裡的堆指的是用於存放 Java 物件的記憶體區域。JVM的堆被同一個JVM例項中所有的Java執行緒共享,它通常由某種自動管理機制所管理,這種機制通常叫做"垃圾回收"

在Java 1.8 中,堆的記憶體模型大致如下:

heap

堆大小 = 新生代 + 老年代。其中堆的大小可以通過引數 -Xms,-Xmx來指定。

預設的,新生代(Young) 與老年代(Old)的比例的值是 1:2 (該值可以通過引數 -XX: NewRatio來指定),即: 新生代(Young) = 1/3的堆空間大小,老年代(Old) = 2/3的堆空間大小。

其中,新生代(Young)被細分為 Eden 和 兩個 Survivor區域,這兩個 Survivor區域分別被命名為 from 和 to,以示區分。

預設的,Eden:from:to = 8:1:1 (可以通過引數 -XX: SurvivorRatio來設定),即: Eden = 8/10 的新生代空間大小,from = to = 1/10 的新生代空間大小。

JVM每次只會使用 Eden和其中的一塊 Survivor區域來為物件服務,所以無論什麼時候,總有一塊 Survivor區域是空閒著的,新生代實際可用的記憶體空間為 90% 的新生代空間。

標記 - 清除演算法

在GC演算法中,最簡單的就是 "標記-清除"(Mark-Sweep)演算法。它的原理比較簡單,首先根據可達性分析演算法對不可達物件進行標記,在標記完成後統一回收所有被標記的物件。標記-清除演算法的執行過程如下圖:

mark_sweep
標記-清除演算法有兩個缺點:

  • 效率問題,標記和清除兩個過程的效率都不高
  • 空間問題,標記清除之後產生大量不連續的記憶體碎片,如果這時候有大物件需要連續的記憶體空間進行分配時,很可能會因為沒有足夠的連續記憶體空間而又觸發一次 GC

基於Mark-Sweep的GC 多用於老年代

複製演算法

複製演算法的思路是它將可用記憶體按容量劃分為大小相等的兩塊,每次只用其中的一塊。當這塊記憶體用完了,就將還存活的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。

這樣每次都是對半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可。但是這種演算法是用空間換時間,代價是將記憶體縮小為原來的一半,代價很高。而新生代的物件一般是存活時間較短的物件,GC頻率較高,佔記憶體較少,因此新生代一般都採用基於複製的GC。複製演算法過程如下:

copy

HotSpot 虛擬機將新生代記憶體分為 一塊較大的 Eden空間和兩塊較小的 Survivor空間,Eden和Survivor的大小比例是8:1。每次新生代中可用記憶體空間為整個新生代容量的 90%。我們沒有辦法保證每次回收都只有不多於 10%的物件存活,當 Survvivor 空間不夠用時,需要依賴老年代進行分配擔保

標記 - 整理演算法

複製收集演算法在物件存活率較高時就要進行較多的複製操作,效率會變低,它比較適合收集新生代物件,至於老年代這種一般不選用複製演算法。根據老年代的特點,可以使用 "標記-整理"演算法或者"標記-清除"演算法

標記 - 整理演算法可以解決記憶體碎片的問題,而且思路也比較簡單,它的思想就是,讓所有存活的物件都向一端移動,然後直接清理掉邊界以外的記憶體,如下圖所示:

mark-compact

分代收集

當前商業虛擬機器的垃圾收集都採用"分代收集",將堆分為新生代和老年代,根據各個年代的特點採用最適當的收集演算法:

  • 新生代
    • 複製收集演算法
  • 老年代
    • 標記 - 清理演算法
    • 標記 - 整理演算法

簡單對比三種基本演算法

下面的分析參照 R大對於GC演算法的分析:hllvm.group.iteye.com/group/topic…

分代式 GC裡,老年代常用 mark-sweep(標記 - 清除演算法),或者是 mark-sweep /mark-compact 的混合方式,一般情況下用 mark-sweep,統計估算碎片量達到一定程度時用 mark-compact(標記 - 整理)。這是因為傳統上大家認為老年代的物件可能會長時間存活且存活率高,或者是比較大,這樣拷貝起來不划算,還不如採用就地收集的方式。 Mark-Sweep,Mark-compact,copying這三種基本演算法裡,只有mark-sweep是不移動物件的(也就是不拷貝的),所以老年代常選用 mark-sweep。當然針對不同的垃圾收集器,GC 演算法是有區別的

以下是三種演算法的比較:

mark-sweep mark-compact copying
速度 中等 最慢 最快
空間開銷 少(但會堆積碎片) 少(不堆積碎片) 通常需要活物件的2倍大小(不堆積碎片)
移動物件?

關於時間開銷

  • mark-sweep: mark階段與活物件的數量成正比,sweep階段與整堆大小成正比
  • mark-compact: mark階段與活物件的數量成正比,compact階段與活物件的大小成正比
  • copy:與活物件的大小成正比

如果把 mark,sweep,compact,copying這幾種動作的耗時放在一起看,大致有這樣的關係:

compaction >= copying > marking > sweeping marking + sweeping > copying

總結一下:

在分代式假設中,年輕代中物件在 minor GC 時的存活率應該很低,這樣用copying演算法就是最合算的,因為其時間開銷與活物件的大小成正比,如果沒多少活物件,它就非常快。而且 young GC 本身應該比較小,就算需要2倍空間也只會浪費不太多的空間

而老年代被 GC 時物件存活率可能會很高,而且假定可用剩餘空間不太多,這樣copying 演算法就不太合適,於是更可能選用另兩種演算法,特別是不用移動物件的 Mark-Sweep演算法

不過 HotSpot VM中除了CMS收集器之外的其他收集器都是會移動物件的,也就是要麼是 copying,要麼是mark-compact的變種

JVM堆記憶體設定引數

  • -XX:+<option> 啟用選項 例如:-XX:+PrintGCDetails啟動列印GC資訊的選項,其中+號表示true,開啟的意思
  • -XX:-<option>不啟用選項 ,例如:-XX:-PrintGCDetails關閉啟動列印GC資訊的選項,其中-號表示false,關閉的意思
  • -XX:<option>=<number>
  • -XX:<option>=<string>

常用堆引數

  • -Xms: 初始堆大小
  • -Xmx: 最大堆大小,預設為實體記憶體的1/4
  • -Xmn: 新生代大小,通常為 Xmx的 1/3或1/4。新生代 = Eden + 2個Survivor空間。實際可用空間為 = Eden + 1個 Survivor,即90%
  • -XX:NewSize = n:設定新生代大小
  • -XX:NewRatio = n: 設定新生代和老年代的比值,如 n = 3,表示新生代:老年代 = 1:3。
  • -XX:SurvivorRatio: 新生代中 Eden與Survivor的比值,預設值為 8。即Eden佔新生代空間的 8/10,另外兩個 Survivor各佔 1/10
  • -XX:PermSize: 永久代(方法區)的初始大小,(前提是永久代存在的情況下,在JDK 1.8及以後,永久代被移除了)
  • -XX:MaxPermSize:永久代(方法區)的最大值
  • -XX:+PrintGCDetails:列印 GC 資訊
  • -XX:+HeapDumpOnOutOfMemoryError:讓虛擬機器在發生記憶體溢位時 Dump 出當前的記憶體堆轉儲快照,以便分析用

更多JVM引數選項設定,請參考Oracle官方網站給出的相關資訊: www.oracle.com/technetwork…

小結

以上主要參考了《深入理解Java虛擬機器》這本書以及R大對於GC演算法的分析,本人對於JVM是渣渣級選手,如有問題之處,歡迎指出

另外關於垃圾演算法更加詳解的解釋,三種演算法的具體實現參考 中村成洋的《垃圾回收的演算法與實現》

參考資料 & 鳴謝

相關文章