☕[JVM技術指南](2)垃圾回收子系統(Garbage Collection System)之常見的垃圾回收演算法

liboware發表於2021-06-12

GC Roots

在Java語言中,GC Roots包括以下幾類元素:

  1. 虛擬機器棧中引用的物件,比如:各個執行緒被呼叫的方法中使用到的引數,區域性變數等

  2. 本地方法棧內JNI(通常說的本地方法)引用的物件

  3. 方法區中類靜態屬性引用的物件,比如:Java類的引用型別靜態變數

  4. 方法區中常量引用的物件,比如:字串常量池(String Table)裡的引用

  5. 所有被同步鎖synchronized持有的物件

  6. Java虛擬機器內部的引用,基本資料型別對應的Class物件,一些常駐的異常物件(如:NullPointerException,OutOfMemoryError),系統類載入器。

  7. 反映java虛擬機器內部情況的JMXBean,JVMTI中註冊的回撥,原生程式碼快取等

分代收集理論

當代商業虛擬機器的垃圾收集器,大多數都遵循“分代收集”的理論進行設計,它建立在兩個假說上之上:

  • 弱分代假說:絕大多數物件都是朝生夕死。

  • 強分代假說:熬過多次垃圾收集過程的物件就越難以消亡。

  • (較少)跨代引用假說:跨代引用相對於同代引用來說僅佔極少數

這兩個分代假說共同奠定了常用垃圾收集器的一致的設計原則:收集器應該將Java堆劃分為不同的區域,然後將回收的物件依據年齡(物件熬過垃圾收集過程的次數)分配到不同的區域之中儲存。

  • 如果一個區域中大多數物件都是朝生夕死,難以熬過垃圾收集器收集的過程,把它們放到一起,每次回收只關注如何保留少量存活而不是去標記那些大量將被回收的物件,就能以最小的代價回收到大量的空間;

  • 如果剩下的是難以消亡的物件,把它們放到一起,就使用較低的頻率來回收這個區域;這樣就兼顧了垃圾收集的時間開銷和記憶體的空間有效利用。

在Java堆劃分不同區域之後,垃圾收集器就可以每次回收其中某些部分的區域——因此,就有了“Minor GC”、“Major GC”、“Full GC”這樣的回收型別的劃分——才能針對這些不同區域與裡面儲存物件存亡的特徵相匹配的垃圾收集演算法——因此,發展出“標記——複製演算法”“標記——清除演算法”、“標記——整理演算法”等針對性的垃圾收集演算法。

始於分代收集理論。

分代收集並非只是簡單的劃分一下記憶體區域那麼容易,它至少存在一個明顯的困境:物件不是孤立的,物件之間會存在跨代引用,存在相互引用關係的兩個物件,是應該傾向於同時生存或者同時消亡的。

依據這條假說,我們不應該再為少量的跨代引用去掃描整個老年代,也不必浪費空間專門記錄每個物件是否存在跨代引用,只需在新生代建立一個全集的資料結構(該結構為“記憶集”,Remembered Set)。

  • 標識出老年代哪塊記憶體會存在跨代引用,當發生Minor GC時,只有包含跨代引用的小塊記憶體裡的物件才會被加入到GC Roots進行掃描。

分代收集名稱定義:

  • 部分收集(Partial GC):指目標是部分收集整個Java堆的垃圾收集,其中又分為:
    • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集。
    • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集。目前只有CMS收集器會有單獨收集老年代的行為(可以設定CMSscavenge選項進行開啟MinorGC機制)。
    • 混合收集(Mixed GC):收集整個新生代以及部分老年代的垃圾收集。目前只有G1收集器會有這種行為。
  • 整堆收集(Full GC):收集整個Java堆和方法區的垃圾收集。

垃圾回收演算法

JVM中比較常見的三種垃圾收集演算法是標記-清除演算法(Sweep),複製演算法(Copying),標記-壓縮演算法(Mark-Compact)

標記——清除演算法

演算法背景

標記-清除演算法(Mark-Sweep)是一種最基礎和常見的垃圾收集演算法,該演算法被J.McCarthy等人在1960年提出並應用於Lisp語言,後續的收集演算法大多以標記——清除演算法為基礎,對其缺點進行改進而得到的。

基本思路

演算法的基本思路:標記出所有需要回收的物件,標記完成後,統一回收掉所有被標記的物件。

演算法結構

該演算法分為“ 標記” 和“ 清除” 兩個階段

  • 標記階段:就是會從根節點掃描所有的物件,如果發現某個物件有被引用,就在物件的Header(物件標識為11)中記錄為可達物件;

  • 清除階段:對堆記憶體從頭到尾線性遍歷,如果發現物件的Header中沒有被標記為可達物件,則將其回收。不過需要注意的時,【當執行這兩個階段的工作的時候,需要先把整個程式停止也被稱為stop the world,然後再進行這兩項工作】。

總體概述

當堆中的有效記憶體空間(available memory)被耗盡的時候,就會停止整個程式(也被稱為stop the world),然後進行兩項工作,第一項則是標記(標記的是非垃圾物件也叫做可達物件),第二項則是清除,當成功區分記憶體中存活物件和死亡物件後,GC接下來的任務就是執行垃圾回收,釋放掉無用物件所佔用的記憶體空間,以便有足夠的可用記憶體空間為新物件分配記憶體。

☕[JVM技術指南](2)垃圾回收子系統(Garbage Collection System)之常見的垃圾回收演算法

主要缺點

  1. 執行效率不穩定,當大部分資料需要被回收時,就需要進行大量標記和清除的動作。

  2. 記憶體空間碎片化問題,標記、清除之後會產生大量不連續的記憶體碎片

  3. 這種方式清理出來的空閒記憶體是不連續的,產生記憶體碎片。需要維護一個空閒列表

注意:何為清除?

標記——複製演算法【新生代】

演算法背景

為了解決標記-清除演算法在垃圾收集效率方面的缺陷,M.L.Minsky於1963年發表了著名的論文,“使用雙儲存區的Lisp語言垃圾收集器CA LISP Garbage Collector Algorithm Using Serial Secondary Storage”。M.L.Minsky在該論文中描述的演算法被人們稱為複製(Copying)演算法,它也被M.L.Minsky本人成功地引入到了Lisp語言的一個實現版本中。

核心思想

將活著的記憶體空間分為兩塊,每次只使用其中一塊,在垃圾回收時將正在使用的記憶體中的存活物件複製到未被使用的記憶體塊中,之後清除正在使用的記憶體塊中的所有物件,交換兩個記憶體的角色,最後完成垃圾回收,

☕[JVM技術指南](2)垃圾回收子系統(Garbage Collection System)之常見的垃圾回收演算法

把新生代分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次分配記憶體只使用Eden和其中一塊Survivor。當發生垃圾搜尋時,將Eden和Survivor中仍存活的物件一次性複製到另一個Survivor上,然後直接清除掉Eden和已用過的那塊Survivor空間。

HotSpot虛擬機器預設Eden和Survivor的大小比例是8:1,即10%的新生代是會被浪費掉的。當Survivor空間不足以容納一次Minor GC之後存活的物件時,就需要以來其他記憶體區域進行分配擔保。

原理總結

因為標記-清除演算法的效率比較低,所以為了解決這個問題,就出現了複製演算法。就是使用雙倍的記憶體空間,然後其中一個記憶體空間是空的,裡面沒有存放物件,每次垃圾回收的時候就掃描非空記憶體空間內的全部物件,如果掃描到的物件有被引用,就複製到那個空的記憶體空間內,最後把最初的那個非空的記憶體空間整體回收掉,這樣時間效率雖然高,但是空間效率比較低,因為使用了雙倍的空間,這是典型的用空間換時間的思想。

這裡所謂的清除並不是真的置空,而是把需要清除的物件地址儲存在空閒的地址列表裡。下次有新物件需要載入時,判斷垃圾的位置空間是否夠,如果夠,就存放。

優點:

  1. 實現簡單,執行高效。

  2. 複製過去以後保證空間的連續性,不會出現”碎片”問題。

缺點:

  1. 此演算法的缺點也是明顯的,就是需要兩倍的記憶體空間,浪費相關的記憶體

  2. 對於G1這種分拆成為大量region的GC,複製而不是移動,意味著GC需要維護region之間物件引用關係,不管是記憶體佔用或者時間開銷也不小。

標記——整理演算法【老年代】

☕[JVM技術指南](2)垃圾回收子系統(Garbage Collection System)之常見的垃圾回收演算法

背景

  • 標記-複製演算法的高效性是建立在存活物件少,垃圾物件多的前提下的。這種情況在新生代經常發生,但是在老年代,更常見的情況是大部分物件都是存活物件。如果依然使用複製演算法,由於存活物件較多,複製的成本也將很高。因此,基於老年代垃圾回收的特性,需要使用其它的演算法。

  • 標記-清除演算法的確可以應用在老年代中,但是該演算法不僅執行效率低下,而且在執行完記憶體回收後還會產生記憶體碎片,所以JVM的設計者需要在此基礎之上進行改進。標記-壓縮(Mark-Compact)演算法由此誕生。

標記——整理和標記—清除演算法一樣,從根節點遞迴標記所有可達的物件,讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。

執行過程:

  1. 第一階段和標記-清除演算法一樣,從根節點開始標記所有被引用物件。

  2. 第二階段將所有的存活物件壓縮到記憶體的一端(通過指標遊標進行劃分),按順序排放。

  3. 之後,清理便捷外所有的空間。

優點:

  1. 清楚了標記-清除演算法當中,記憶體區域分散的缺點,我們需要給新物件分配記憶體時,JVM只需要持有一個記憶體的起始地址即可。

    • 可以看到,標記的存活物件將會被整理,按照記憶體地址一次排列,而未被標記的記憶體會被清理掉。如此一來,當我們需要給新物件分配記憶體時,JVM只需要持有一個記憶體的其實地址即可,這比維護一個空閒列表顯然少了許多開銷。
  2. 消除了複製演算法當中,記憶體減半的高額代價

  3. 沒有記憶體碎片

缺點:

  1. 從效率上來說,標記-整理演算法要低於複製演算法

  2. 移動物件的同時,如果物件被其它物件引用,則還需要調整引用的地址

  3. 移動過程中,需要全程暫停使用者應用程式。即:STW(stop the world)執行演算法的時候需要先停止整個程式執行

核心原理

當成功區分出記憶體中存活物件和死亡物件後,GC接下來的任務就是執行垃圾回收,釋放掉吳用物件所佔用的記憶體空間,以便有足夠的可用記憶體空間為新物件分配記憶體。
目前在JVM中比較常見的三種垃圾收集演算法是標記-清除演算法(Mark-Sweep)、複製演算法(Copying)、標記-壓縮演算法(Mark-Compact)。

就做標記-壓縮演算法,這個演算法的執行過程分為三個階段,第一階段的標記階段和複製演算法一樣,先從根節點開始標記所有被引用的物件;然後第二階段是把所有被引用的物件移到記憶體空間的一端,按順序排放;第三階段是清理記憶體空間的另一端中所有的沒有被引用的垃圾物件。

與標記清除的區別

二者的本質差異在於標記-清除演算法是一種非移動式的回收演算法,標記-壓縮是移動式的。是否移動回收後的存活物件是一項優缺點並存的風險決策。

分代演算法

根據物件存活週期的不同將記憶體劃分為幾塊。一般是把Java堆分為新生代和老年代,根據各個年代的特點採用最適當的收集演算法。

  • 新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活, 那就選用複製演算法, 只需要付出少量存活物件的複製成本就可以完成收集。

  • 老年代中因為物件存活率高、沒有額外空間對它進行分配擔保, 就必須使用“ 標記—清理” 或者“ 標記—整理” 演算法來進行回收

分割槽演算法

分代演算法將按照物件生命週期長短劃分成兩部分,而分割槽演算法則是將堆劃分成不同小區間,每個區間都獨立使用,獨立回收,這種演算法的好處控制一次回收小區間的數量。

隨著計算機算力越來越強,記憶體也越來越便宜,生產上堆空間可供應用程式支配也越來越多,一般來講,堆空越大GC回收的時間越長,為了更好的控制GC的時間,將大區域劃分成獨立小房間,獨立管理獨立回收,每次垃圾回收合理地回收若干小區間,可以減少GC所產生停頓的時間。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章