細說jvm(四)、垃圾回收演算法

語言: CN / TW / HK

之前的文章

1、細說jvm(一)、jvm執行時的資料區域

2、細說jvm(二)、java物件建立過程

3、細說jvm(三)、物件建立的記憶體分配

從本篇開始說一說垃圾回收,這玩意是個大話題,也是我們應用效能優化中非常重要的一環,如果很擅長診斷jvm的GC問題,不僅能讓你在工作中出彩,也可以讓你在面試中更容易面試官的青睞。GC這部分我將會說常見的垃圾回收演算法,垃圾回收演算法有基礎的演算法,也有複雜一些的增量演算法和分代演算法等,然後還有常見的垃圾回收器的工作過程和優化細節,收集器我會重點說CMS,ParNew,G1,ZGC這幾個,還會手把手的帶你讀GC log,以及會針對應用型別以及常見的一些場景做一定的優化總結,還會介紹幾個排查問題的工具。這裡面有的東西比如垃圾回收器工作過程這裡是有一定的難度的,有什麼問題可以在文章下方留言,只要我看到,我都會回覆你。

GC涉及到的點會非常多,這裡我將會說的比較細,我的目標是讓你做個能實戰的人,所以在後邊的GC篇會經常展示各種排查問題的工具以及我排查的過程,因此不可避免文章也會變得長一些。

這章先來說說基礎的垃圾回收演算法,這是我們去理解後邊一個大的前提。

名詞解釋

GCRoot

指的是

一、判斷物件可以被回收的依據

判斷物件是否可以被回收,總共有兩種方式,我們分別說一下

1、引用計數法

這是一種很古老的演算法,具體是:在堆記憶體中分配物件時,會為物件分配一段額外的空間,這個空間用於維護一個計數器,如果有一個新的引用指向這個物件,則計數器的值加1;如果指向該物件的引用被置空或指向其它物件,則計數器的值減1。每次有一個新的引用指向這個物件時,計數器加1;反之,如果指向該物件的引用被置空或指向其它物件,則計數器減1;當計數器的值為0時,則自動刪除這個物件。

2、可達性分析法

可達性分析法是以根集合(GCRoot)作為起始點,從這些節點出發,根據引用關係開始搜尋,所經過的路徑稱為引用鏈,當一個物件沒有被任何引用鏈訪問到時,則證明此物件是不活躍的,可以被回收。jvm正是使用的這種方法。

從根本上來說,兩種演算法沒有孰優孰劣之分,只有用得多和用得少的區別,用的多的是可達性分析演算法,大概原因如下:1、引用計數法很難解決迴圈引用的問題,雖說有Recycler演算法可以解決(再去面試的時候別再說解決不了呦),但是這又帶來了難以預測的效能消耗問題。2、在多執行緒的環境下更改引用次數是一件效能消耗比較大事情。

名詞解釋:GCRoot 指的是執行可達性分析的起點,在java中,可以作為GCRoot的有:
1、虛擬機器棧中(棧幀中的本地變量表)引用的物件
2、方法區中的類靜態屬性引用的物件
3、方法區中常量引用的物件
4、本地方法棧中 JNI(Native 方法) 引用的物件
複製程式碼

上面說的兩種方法是用來判斷物件能否被回收的最基本的方法,很多的演算法如標記演算法都要基於此來做。下面我們來看看具體的垃圾回收算演算法。

二、基礎垃圾回收演算法

這裡要介紹的是三種基礎的垃圾回收演算法,這些是理解後邊垃圾回收器工作原理的基礎

(1)標記清除演算法

此演算法分為兩個階段:標記階段和清除階段。標記階段的任務是標記出所有需要被回收的物件,清除階段就是回收被標記的物件所佔用的空間。流程如下: 我們從圖中也可以看出來,標記清除演算法有著比較嚴重的記憶體碎片問題,這會導致建立物件時候記憶體分配效率變低,並且太多的記憶體碎片還會導致額外的垃圾回收動作。

(2)標記壓縮演算法

此演算法是為了解決上面的記憶體碎片問題,在標記之後,會將存活物件移動到記憶體的一端,過程如下: 我們先不說這種演算法的優缺點,先說說另外一種演算法

(3)標記複製演算法

這個演算法是將記憶體分為了兩塊,每次只使用其中一塊,標記動作也只是發生在使用的這塊記憶體中,然後將存活物件全部複製到另外一塊記憶體中,再將之前的記憶體塊全部清空,過程如下: 我們結合標記壓縮演算法和標記複製演算法一起來看下:這兩種演算法都解決了記憶體碎片的問題,但是,標記複製演算法浪費了一半的記憶體,也就是存在記憶體使用率低的問題,那是不是說標記壓縮演算法就是適用所有場景了呢?顯然不是的,如果這塊記憶體中存活物件很多的話,使用標記壓縮演算法就涉及到大量物件複製並且修改引用地址的問題,那樣會減少分配給使用者執行緒的執行時間(實際上標記複製演算法在存活物件多的時候也有這個問題)。

三、對垃圾回收演算法的改進

下面介紹的兩種垃圾回收演算法,會對基礎演算法中記憶體碎片化、暫停時間過長、空間利用率不高等不足進行改進。

(1)分代演算法

分代演算法是基於這樣一個假說:絕大多數物件都是朝生夕滅的。這個假說其實已經被證實了,這一點也不難以想象,絕大多數OLTP應用中一個物件創建出來很快就會被回收掉。分代演算法把物件分類成幾代,針對不同的代使用不同的 GC 演算法:剛生成的物件稱為新生代物件,對新物件執行的 GC 稱為新生代 GC(minor GC),到達一定年齡的物件則稱為老年代物件,面向老年代物件的 GC 稱為老年代 GC(major GC),新生代物件轉為為老年代物件的情況稱為晉升。但是代數也不是越多越好,綜合來看,代數是兩代或者三代是最好的。

這裡引進了一個新的概念,OLTP和OLAP,這是兩種不同的型別的應用,OLAP一般是資料分析型應用,OLTP應用是普通的實時型別
業務的應用。這兩種應用的jvm優化是不一樣的,這裡你先記住這個概念,後邊我們再具體講優化。
複製程式碼

在經過新生代 GC 而晉升的物件把老年代空間填滿之前,老年代 GC 都不會被執行。因此,老年代 GC 的執行頻率要比新生代 GC 低。通過使用分代垃圾回收,可以減少 GC 所花費的時間。

(2)增量演算法

增量演算法主要是通過併發的方式來控制STW(stop the world即應用停頓)時間。具體體現在垃圾回收執行緒工作和使用者執行緒工作是以併發的方式來執行的。這點是因為jvm採用的是可達性分析演算法,基於可達性分析的標記演算法的工作過程一般都會分為數個階段,並不是每個階段都需要停止使用者執行緒,所以這些不需要停止使用者執行緒的階段只需要和使用者執行緒併發執行即可(增量如果不明白不著急,到後邊講垃圾回收器的時候會讓你更好的理解)

四、使用改進的垃圾回收演算法帶來的問題及解決方式

上面的改進也是有代價的,因為這類演算法的本質其實就是在時間和空間之間做權衡,如果速度變快了,那就得犧牲一定的記憶體,如果佔的記憶體小了,那速度肯定就會相對慢一些。

(1)標記過程中物件引用關係發生變化

我們先來看看標記演算法中的標記過程,這個過程可以用三色演算法來抽象概括,即根據標記的不同程度將物件分成三類,白色:未被垃圾回收器標記的物件,灰色:自身已經被標記,但其擁有的成員變數還未被標記,黑色:自身已經被標記,且物件本身所有的成員變數也已經被標記。

在GC開始的時候所有物件都是白色如狀態1,首先從GCRoot掃描的時候,將圖中A標為灰色,E標為黑色如狀態2,然後從灰色物件出發(不再掃描黑色),將灰色引用標記為黑色(A物件),再將B標記為灰色,如狀態3,再遞迴迴圈從灰色出發掃描的過程,到了最後,只有D不會被掃描到,所以D就是垃圾,垃圾將在清理過程中被清理掉。

問題1:假如在狀態3, jvm準備進行下一步標記的時候,A和B的引用關係被解除了,那麼在下次標記的時候依然會從B出發把B標為黑色,把C標為灰色,最後B和C都會被標記上,但是其實我們根據圖很容易推斷出A和B解除了引用關係之後B和C從GCRoot是不可達的,因此B和C在這輪迴收中是無法被回收的,於是B和C就變成了浮動垃圾。

問題2:另外一種情況發生在狀態2結束,jvm準備進行下一步之前,E引用了B,而A和B的引用關係被解除,然後B和C也就無法在下一輪掃描中被掃描到了(因為下次掃描只會從灰色物件出發),接下來B和C就變成了垃圾,這就很嚴重了,因為影響到了程式的正確性。jvm為了解決這兩個問題引入了讀寫屏障,讀寫屏障發生的條件如下

問題2這個場景的程式碼如下:
void test(A a,E e) {    
    B b = a.b;                  		// 觸發讀屏障    
    a.b = null;
    e.b = b;   					// 觸發寫屏障
}
複製程式碼

具體是:(1)寫屏障:在e.b=b的時候,把e物件或者b物件標為灰色,這樣下次掃描就可以掃描到了,(2)讀屏障:在給B b賦值a.b的時候,立刻將a.b標記為了灰色,保證掃描的正確性,這種也叫做增量更新。

(2)跨代引用

使用分代也是有代價的,新生代中的物件不一定僅僅只是被GCRoot引用,還有可能被老年代物件引用,如圖,想要知道B能否被回收,就必須掃描一下老年代,但這樣就失去了分代的意義,jvm必須做到在安全的回收新生代的同時不掃描老年代,不然的話還不如不分代。

上圖中,我們可以把C也作為一個GCRoot,從而每次掃描的時候可以從C出發,或者是將C標為灰色(寫屏障)。將C作為GCRoot的這個方法具體是每當發生跨代引用的時候,就將老年代物件記錄進一個名為Remember Set的集合中,然後掃描的時候也會以Remember Set中物件作為GCRoot,這樣就避免了掃描整個老年代的問題。