Java虛擬機器03——垃圾收集演算法

llldddbbb發表於2019-04-03

這裡簡要介紹幾種垃圾收集演算法的思想

標記 - 清除演算法

該演算法如同它的名字一樣,分為“標記”和“清除”兩個階段:

  • 首先標記出所有需要回收的物件
  • 在標記完後統一回收所有被標記的物件

image.png

這個演算法其實已經過時了,但是後續的演算法都是基於這種思路來的。它主要的不足點有兩個:

  1. 效率問題。標記和清理兩個過程的效率都不高
  2. 空間問題。標記清除後會產生大量不連續的記憶體碎片,空間碎片太對會導致程式執行過程中需要分配大物件時,無法找到連續的記憶體而不得不提前觸發另一次垃圾收集動作

複製演算法

複製演算法的流程如下:

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

image.png

可以看到每次只對一半區域進行收集,這樣就不用考慮記憶體碎片等複雜情況了,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。但是這種演算法的代價是將記憶體縮小為原來的一半,記憶體成本高

複製演算法一般用於收集新生代,因為新生代大部分的物件的存活時間很短,因此新生代中存活的物件遠遠少於垃圾物件。

新生代:存放年輕物件的堆空間。年輕物件是指剛剛建立,或者經歷垃圾回收次數不多的物件。
老年代:存放老年物件的堆空間。老年物件指經歷過多次垃圾回收依然存活的物件。

在商業虛擬機器中,例如我們常見的HotSpot虛擬機器,將新生代分為一個Eden區和兩個Survivor區,Eden區與Survivor區的大小比例是8:1,也即是說Eden區佔新生代的80%,兩個Survivor分別佔10%。新生代的複製演算法執行規則如下:

  • 每次使用複製演算法進行垃圾回收時,會將Eden區和其中一塊Survivor區的所有存活物件複製到另一塊空閒Survivor區中,在複製操作中,大物件和老年物件將直接複製到老年代;
  • 然後將原來的Eden區和Survivor區的物件一次性清理掉;
  • 如果在執行復制演算法時一塊空閒Survivor區域不能夠容納原來的Eden區和Survivor區的物件,就需要依賴老年代,將多餘的物件直接複製到老年代。

可以發現,這種複製機制保證只有一塊Survivor區的記憶體(僅佔新生代記憶體的10%)是被浪費的。新生代的複製演算法示意圖如下:

image.png

標記 - 整理演算法

在物件存活率較低的新生代使用複製演算法效率高。那麼在物件存活率高的老年代,使用複製演算法效率將會變得很低。根據老年代的特點,有人提出了“標記 - 整理”演算法。演算法流程如下:

  • 首先標記出所有需要回收的物件
  • 讓所有存活的物件都向一端移動
  • 然後清理掉端邊界以外的記憶體

image.png

分代收集演算法

當前商業虛擬機器的垃圾收集演算法都採用“分代收集演算法”。主要思想是根據物件存活週期的不同將記憶體劃分為幾塊,並採用最適合的收集演算法。

  • 在大批物件死去,少量存活的新生代中,採用複製演算法
  • 在物件存活率高、沒有額外空間對它進行分配擔保,採用“標記 - 清理”或“標記 - 整理”演算法。

OopMap、Safe Point和Safe Region

上面介紹了幾種垃圾收集演算法,但是虛擬機器(這裡以HotSpot為例子)在發起記憶體回收的時候會遇到很多問題。因此誕生了OopMap、Safe Point和Safe Region來解決

OopMap

問題:

  • GC Roots的節點主要在全域性性的引用(例如常量或類靜態屬性)與執行上下文(例如棧幀中的本地變數表)中,但是現在引用眾多,如果要逐個檢查這裡面的引用,那麼必然會消耗很多時間。
  • 另外,可達性分析對執行時間的敏感還體現在GC停頓上,因為這項分析工作必須在一個能確保一致性的快照中進行————這裡“一致性”是指分析過程中不可以出現引用關係還在不斷變化的情況,因此GC進行時必須停頓所有的Java執行執行緒

解決: 在HotSpot的實現中,使用一組成為OopMap的資料結構。

  • 在類載入完成的時候,就把物件內什麼偏移量上是什麼型別的資料計算出來
  • 在JIT編譯過程中,也會在特定位置記錄下棧和暫存器中哪些位置是引用

這樣,GC在掃描時就可以直接得知這些資訊了。

Safe Point

在OopMap的協助下,HotSpot可以快速且準確地完成GC Roots列舉,但一個很現實的問題隨之而來:

  • OopMap內容變化的指令過多導致需要大量額外空間的問題

解決:

  • HotSpot沒有為每條指令都生成OopMap,只是在“特定的位置”記錄了這些資訊,這些位置稱為安全點(Safe Point),即程式執行時只有在到達Safe Point時才能更新自己的OopMap。

對於Safe Point,另一個需要考慮的問題是如何在GC發生時讓所有執行緒(這裡不包括執行JNI呼叫的執行緒)都執行到最近的安全點上再停頓下來。這裡有兩種方案可供選擇:搶先式中斷(Preemptive Suspension)和主動式中斷(Voluntary Suspension):

  • 搶先式中斷。不需要執行緒的執行程式碼主動去配合,在GC發生時,首先把所有執行緒全部中斷,如果發現有執行緒中斷的地方不在安全點上,就恢復執行緒,讓它繼續執行到安全點上。現在幾乎沒有虛擬機器實現採用搶先式中斷來暫停執行緒從而響應GC事件。
  • 主動式中斷。當GC需要中斷執行緒的時候,不直接對執行緒操作,僅僅簡單地設定一個標誌,各個執行緒執行時主動去輪詢這個標誌,發現中斷標誌為真時就自己中斷掛起。輪詢標誌的地方和安全點是重合的,另外再加上建立物件需要分配記憶體的地方。

Safe Region

Safe Point機制保證了程式執行時,在不太長的時間內就會遇到可進入GC的Safe Point。 問題:

  • 但是當執行緒沒有分配CPU時間(如執行緒處於Sleep狀態或者Blocked狀態),這時候執行緒無法響應JVM的中斷請求以繼續到安全的地方去中斷掛起,JVM也顯然不太可能等待執行緒重新被分配CPU時間。對於這種情況,就需要安全區域(Safe Region)來解決。

安全區域是指在一段程式碼片段之中,引用關係不會發生變化。在這個區域中的任意地方開始GC都是安全的。我們也可以把Safe Region看做是被擴充套件了的Safepoint。

線上程執行到Safe Region中的程式碼時,首先標識自己已經進入了Safe Region,當在這段時間裡JVM要發起GC時,就不用管標識自己為Safe Region狀態的執行緒了。線上程要離開Safe Region時,它要檢查系統是否已經完成了根節點列舉(或者是整個GC過程),如果完成了,那執行緒就繼續執行,否則它就必須等待直到收到可以安全離開Safe Region的訊號為止。

相關文章