深入理解Java虛擬機器 --- 垃圾標記/收集演算法

ayu0v0發表於2024-11-08

在開始本章之前,我們得了解一個概念,那就是我們怎麼知道這個物件是"垃圾"?所以如何定義垃圾就成為我們第一個需要探討的重要的點之一。

垃圾標記演算法

常見的垃圾標記演算法有:引用計數演算法可達性分析演算法

引用計數演算法

實現思路

每個物件去額外儲存一個引用計數器,這個計數器統計了物件被引用的次數,當被引用的次數為0時,就可以認為它是垃圾了。

優點

實現簡單,垃圾物件便於識別;判定效率高,回收沒有延遲性。

缺點

它有一個致命的缺陷,導致了它這個演算法沒有被採用。那就是它解決不了迴圈依賴(或者說解決的成本太高了)。

這個問題導致的直接問題就是--記憶體洩漏

image.png

可達性分析演算法

GC Roots

GC Roots的物件包含以下幾種:

  • 虛擬機器棧(棧幀中的本地變數表)中引用的物件。(可以理解為:引用棧幀中的本地變數表的所有物件)

  • 方法區中靜態屬性引用的物件(可以理解為:引用方法區該靜態屬性的所有物件)

  • 方法區中常量引用的物件(可以理解為:引用方法區中常量的所有物件)

  • 本地方法棧中(Native方法)引用的物件(可以理解為:引用Native方法的所有物件)

當然隨著使用者選用的垃圾回收器以及當前區域的不同,也可能有其他物件"臨時性"地加入GC Roots。

談談引用

  • 強引用:我們平常用new建立的物件所獲得的引用就是強引用。被強引用引用的物件,無論發生什麼都不會垃圾回收

  • 軟引用:軟引用主要用來描述一些還有用,但非必須的物件。只被軟引用關聯的物件,在系統即將要發生OOM時,會被垃圾回收

  • 弱引用:弱引用主要用來描述一些非必須物件。所有被弱引用關聯的物件,只能生存到下一次垃圾回收發生為止。

  • 虛引用:虛引用不會影響物件的存活時間,也無法透過虛引用來獲取物件例項。唯一作用就是能在這個物件被收集器回收時收到一個系統通知。

生與死 & finalize()

如果在可達性分析演算法中被認定為不可達物件,也不是"非死不可",他們處於緩刑狀態,這時候會呼叫finalize()方法(如果物件重寫了該方法那麼執行重寫方法),這個是他活路的唯一時機。如果在finalize()還沒有重新建立引用,那麼下一次標記為不可達時,那它必定會死。

要真正宣告一個物件的死亡,至少要經歷兩次標記。

實現思路

  • 可達性分析演算法是以根物件集合(GC Roots)為起始點,按照從上到下的方式搜尋被根物件集合所連線的目標物件是否可達

  • 使用可達性分析演算法後,記憶體中存活的物件都會直接或間接跟根物件集合連線,搜尋所走過的路徑稱為引用鏈

  • 如果目標物件沒有任何引用鏈相連,則是不可達的,意味著目標物件已死亡,可以標記為垃圾物件。

  • 只有被根物件集合直接或間接連線的物件才是存活物件。

image.png

併發的可達性分析--三色標記法

思想

將物件根據狀態標記為黑、灰、白三種顏色。

:該物件沒有被標記過,為垃圾。(當然最開始都是白色的)

:該物件已經標記過了,但是該物件下的引用還沒有標記完。

:該物件已經標記過了,且該物件下的引用也被標記過了。

演算法流程

1、初始狀態:先把所有物件都標記為白色。

2、遍歷根物件:從根物件(GC Root)開始遍歷,遍歷物件時,將其標為灰色並放在專門的灰色集合中。

3、遍歷灰色集合中的物件:從灰色集合中取出物件,並遍歷該物件的引用物件,如果引用物件是白色的,把其標記為灰色,並放入灰色集合中;反之,則不做處理。

4、上述3操作遍歷完引用物件後的灰色物件會被標記為黑色,並放在專門的黑色集合中。

5、反覆進行3的操作直到灰色集合為空,最後仍然為白色的物件就表明其為垃圾。

問題

主要會出現兩種問題:漏標和浮動垃圾。

浮動垃圾的問題還能容忍,因為在下一次GC就能夠把浮動垃圾給收集了,主要影響的是下一次GC的時間。

但是漏標的問題就很大了,它會導致"物件消失"

  • 發生的條件

    • 1、賦值器插入了一條或多條從黑色物件到白色物件的引用。

    • 2、賦值器刪除了全部從灰色物件到該白色物件的直接或間接引用。

漏標的手繪圖

image.png

image.png

image.png

image.png

這樣白色物件就會成為消失的物件。

解決漏標問題

有兩種解決方案:增量更新和原始快照。

增量更新

主要破壞的是:賦值器插入了一條或多條從黑色物件到白色物件的引用。

實現思路:當黑色物件引用一個白色物件時,需要記錄該黑色物件,等併發掃描結束後,再以他為根去重新掃描一次。

原始快照

主要破壞的是:賦值器刪除了全部從灰色物件到該白色物件的直接或間接引用。

實現思路:當灰色都想要刪除白色物件的引用關係時,就要將這個白色物件記錄下來。併發掃描結束後,會將記錄下來的白色物件標記為灰色,然後以他們為根,重新掃描一次。

兩者比較

兩者相比:原始快照用產生浮動垃圾的可能性,減少了需要重新掃描的時間。(空間換時間)

垃圾收集演算法

上述我們已經成功把垃圾給標出來了,那麼我們應該重點去思考我們應該怎樣優雅的收集垃圾。

分代收集理論

這個理論是基於三個重要的假說上的:

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

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

3、跨代引用假說:跨代引用的物件相對於同代引用來說僅佔少數。

基於上述三個假說,我們把堆分成兩個區域:年輕代(Eden、Survivor1區、Survivor2區)、老年代。

記憶集(卡表)

為了解決物件跨代引用所帶來的問題,垃圾回收器在新生代中建立了名為記憶集的資料結構,用以避免把整個老年代加進GC Root掃描範圍。

記憶集(卡表):只需要記錄非收集區域是否存在有指向收集區域的指標即可

卡表

卡表是記憶集的一種實現形式。

標記-清除演算法

第一個演算法閃亮登場,不過這哥們被用得比較少。

思想

它的做法是當堆中的有效記憶體空間(available memory)被耗盡的時候,就會停止整個程式(也被成為stop the world),然後進行兩項工作,第一項則是標記,第二項則是清除

標記:標記的過程其實就是,遍歷所有的GC Roots,然後將所有GC Roots可達的物件標記為存活的物件。

清除:清除的過程將遍歷堆中所有的物件,將沒有標記的物件全部清除掉。

標記/清除演算法,就是當程式執行期間,若可以使用的記憶體被耗盡的時候,GC執行緒就會被觸發並將程式暫停,隨後將依舊存活的物件標記一遍,最終再將堆中所有沒被標記的物件全部清除掉,接下來便讓程式恢復執行

例項流程

image.png

這張圖代表的是程式執行期間所有物件的狀態,它們的標誌位全部是0(也就是未標記,以下預設0就是未標記,1為已標記),假設這會兒有效記憶體空間耗盡了,JVM將會停止應用程式的執行並開啟GC執行緒,然後開始進行標記工作,按照根搜尋演算法,標記完以後,物件的狀態如下圖。

https://i.iter01.com/images/37a5abfd76bb28f3d10c9e24db03b1380e81be7153033649d78faa9ffeafcdfe.jpg

可以看到,按照根搜尋演算法,所有從root物件可達的物件就被標記為了存活的物件,此時已經完成了第一階段標記。接下來,就要執行第二階段清除了,那麼清除完以後,剩下的物件以及物件的狀態如下圖所示。

https://i.iter01.com/images/8b33571535ecb034473dddaa81290262c7d384c2d122e53fae43989f55a43e88.jpg

可以看到,沒有被標記的物件將會回收清除掉,而被標記的物件將會留下,並且會將標記位重新歸0。接下來就不用說了,喚醒停止的程式執行緒,讓程式繼續執行即可。

缺點

1、執行效率不穩定:當Java堆中有大量的物件,且其中大部分是需要被回收的;導致標記和清除兩個過程的執行效率都隨著物件數量增長而降低。

2、會產生大量不連續的記憶體碎片。

標記-複製演算法(複製演算法)

思想

複製演算法將記憶體劃分為兩個區間,在任意時間點,所有動態分配的物件都只能分配在其中一個區間(稱為活動區間),而另外一個區間(稱為空閒區間)則是空閒的

當有效記憶體空間耗盡時,JVM將暫停程式執行,開啟複製演算法GC執行緒。接下來GC執行緒會將活動區間內的存活物件,全部複製到空閒區間,且嚴格按照記憶體地址依次排列,與此同時,GC執行緒將更新存活物件的記憶體引用地址指向新的記憶體地址

此時,空閒區間已經與活動區間交換,而垃圾物件現在已經全部留在了原來的活動區間,也就是現在的空閒區間。事實上,在活動區間轉換為空間區間的同時,垃圾物件已經被一次性全部回收。

例項流程

image.png

當複製演算法的GC執行緒處理之後,兩個區域會變成什麼樣子,如下所示。

https://i.iter01.com/images/6e138668fcaeb0b61a7b3cd73c8ea7cc4f7258c79b8d945f3a29b5c73e5a71d8.jpg

可以看到,1和4號物件被清除了,而2、3、5、6號物件則是規則的排列在剛才的空閒區間,也就是現在的活動區間之內。此時左半部分已經變成了空閒區間,不難想象,在下一次GC之後,左邊將會再次變成活動區間。

優點

不會產生記憶體碎片

缺點

  • 浪費了一半的記憶體

  • 複製這一工作所花費的時間,在物件存活率比較高時,將會變的不可忽視

標記-整理演算法

標記-清除演算法與標記-整理演算法的本質差異在於前者是一種非移動式的回收演算法,後者是移動式的。

標記-整理演算法通常使用在老年代:因為標記-清除演算法會產生記憶體碎片(只有CMS用了標記-清除),複製演算法需要損耗一般的空間且老年代的存活物件一般比較多,需要頻繁進行復制,效率不高。而比較合適的就是標記整理演算法了。

思想

標記:它的第一個階段與標記/清除演算法是一模一樣的,均是遍歷GC Roots,然後將存活的物件標記。

整理:移動所有存活的物件,且按照記憶體地址次序依次排列,然後將末端記憶體地址以後的記憶體全部回收。因此,第二階段才稱為整理階段。

它GC前後的圖示與複製演算法的圖非常相似,只不過沒有了活動區間和空閒區間的區別,而過程又與標記/清除演算法非常相似

例項流程

image.png

這張圖其實與標記/清楚演算法一模一樣,只是LZ為了方便表示記憶體規則的連續排列,加了一個矩形表示記憶體區域。倘若此時GC執行緒開始工作,那麼緊接著開始的就是標記階段了。此階段與標記/清除演算法的標記階段是一樣一樣的,我們看標記階段過後物件的狀態,如下圖。

https://i.iter01.com/images/abe3e1f9a71779266d1d7b51ac0797423b17a8cb1dc137b2ef358a793e7138dd.jpg

沒什麼可解釋的,接下來,便應該是整理階段了。我們來看當整理階段處理完以後,記憶體的佈局是如何的,如下圖。

https://i.iter01.com/images/37fc79fc6a3236670106f0d12171d27d02e5807eb3fbcf1c916c311265e317f1.jpg

可以看到,標記的存活物件將會被整理,按照記憶體地址依次排列,而未被標記的記憶體會被清理掉。如此一來,當我們需要給新物件分配記憶體時,JVM只需要持有一個記憶體的起始地址即可,這比維護一個空閒列表顯然少了許多開銷。

不難看出,標記/整理演算法不僅可以彌補標記/清除演算法當中,記憶體區域分散的缺點,也消除了複製演算法當中,記憶體減半的高額代價。

不過任何演算法都會有其缺點,標記/整理演算法唯一的缺點就是效率也不高,不僅要標記所有存活物件,還要整理所有存活物件的引用地址。從效率上來說,標記/整理演算法要低於複製演算法。

優點

  • 消除了標記-清除演算法中記憶體區域分散的缺點

  • 消除了複製演算法中記憶體減半的代價

缺點

  • 執行效率低於複製演算法

  • 需要整理所有物件的引用地址

  • 物件移動操作必須STW。

相關文章