細説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,這樣就避免了掃描整個老年代的問題。