1. 程式人生 > 程式設計 >JVM預設垃圾回收器工作原理

JVM預設垃圾回收器工作原理

本文翻譯自Oracle的一篇文章

垃圾回收(GC)是一種對程式中不再使用的記憶體空間進行自動回收並複用的方式。有別於其它需要手動建立和銷燬物件的程式語言,因為GC機制的存在,Java開發人員不需要檢查每個物件是否必需的。相反,強大的GC程式會在背後默默丟棄無用的物件,並對剩餘的物件進行整理。這種機制使得程式執行時效率更高。

什麼是垃圾回收?

JVM使用物件形式來組織程式資料。物件會包含若干域(資料),這些資料存在名為heap的託管地址空間中。

考慮一個二叉樹節點類定義:

class TreeNode {
    public TreeNode left, right;
    public int
 data;
    TreeNode(TreeNode l,  TreeNode r, int d) {
        left = l; right = r; data = d;
    }
    void setLeft(TreeNode l) { left = l;}
    void setRight(TreeNode r) {right = r;}
}
複製程式碼

假設對該類進行以下操作:

TreeNode left = new TreeNode(null13);
TreeNode right = 19);
TreeNode root = new TreeNode(left, right,186); word-wrap: inherit !important; word-break: inherit !important;" class="hljs-number">
17);
複製程式碼

最終,我們建立了一個二叉樹,根節點為17,左子節點為13,右子節點為19,入下圖

二叉樹
二叉樹

假如我們將右子節點替換,將子節點19變成一個孤立的垃圾物件:

root.setRight(21));
複製程式碼

結果如下圖

節點替換
節點替換

可以想見,在對資料結構進行構建和操作的過程中,堆應該類似於這樣的狀態:

堆記憶體
堆記憶體

整理資料,意味著需要改變其在記憶體中的地址。Java程式期望能根據特定的地址找到相應物件,如果垃圾回收器移動了物件,那麼Java程式也需要知道該物件新的位置。要實現這一要求,最簡單的方式就是停止所有的Java執行緒,整理所有物件,更新所有指向舊地址的應用,將其指向新的地址,之後再恢復Java程式。但是,這種方式會導致很長的GC週期(GC停頓時間

),即Java執行緒不再執行的時間。

程式無法執行,這是每一個研發人員都無法接受的。對此,有兩種方式來降低GC停頓時間,通常Java文獻中將其稱為併發演演算法(在程式執行時工作)和並行演演算法(在Java執行緒停止時,啟用更多執行緒以期更快結束工作)。JDK 8中的預設垃圾收集器(可以在命令列中通過-XX:+UseParallelGC手動啟用)就是使用了並行策略,使用了大量GC執行緒來獲取出色的吞吐量。

並行垃圾收集器

並行垃圾收集器根據物件存活的GC週期數,將物件分置於兩個區域——年輕代和老年代。新生成的物件初始時會分配在年輕代,在整理階段,如果物件存活週期數未達到特定值的話,就繼續留在年輕代。如果存活時間足夠長,則會升入老年代。這種方式不會暫停程式之後清理整個堆空間——這樣會花費很長時間,而且只清理可能包含短期存活物件的堆空間。隨著程式執行,也有必要對存活更長的物件進行清理。

如果要只對年輕物件進行整理,垃圾回收器就需要了解老年代的哪些物件引用了年輕代中的物件。這些老年物件需要更新引用,指向年輕物件的新位置。JVM通過獲取名為卡表的資料結構來完成,當老年代物件中寫入引用時,會在卡表中進行標記。因為在下一個young GC週期中,JVM可以通過掃描該卡表來查詢老年代執行年輕代的引用。因為這些引用已知,並行垃圾回收器也就可以識別,哪些物件可以清除,哪些引用需要更新。當垃圾回收器暫停程式之後,會使用多個GC執行緒來保證整理工作能儘快完成。

G1垃圾收集器

JDK中的G1垃圾收集器同時使用了併發執行緒和並行執行緒。程式執行時,使用併發執行緒掃描存活物件;使用並行執行緒來快速拷貝物件,降低程式暫停時間。

G1將堆空間劃分為很多分割槽,在程式執行過程中,一個分割槽既可以是老年代,也可以是年輕代。年輕代分割槽在每個GC週期都必須進行回收,但是對於老年代分割槽,G1會根據使用者指定的GC停頓時間要求,靈活選擇可以回收的分割槽數量。這種靈活性也保證了G1可以將老年代GC工作集中在垃圾物件最多的分割槽進行,同時也使得G1可以根據使用者指定的GC停頓時間來調整垃圾收集的停頓時間。

如下圖所示,G1會將物件整理到新的分割槽中。Region1Redion2內的物件被整理到Region4,新物件也會分配在Region4Region3因為過多的複製操作(70%)和較低的空間回收率(30%),沒有被垃圾回收器處理。

Before and after a G1 run
Before and after a G1 run

G1回收器清楚每個分割槽中有多少資料,以及拷貝其中的存活物件所消耗的大致時間。如果使用者期望GC停頓時間短,G1會選擇回收一些分割槽,如果使用者不關心GC停頓時間,或者期望的停頓時間比較長,G1會選擇回收更多的分割槽。

G1回收器如果要隻手機年輕代分割槽,必須維護一個卡表資料結構,同時也需要記錄每個老年代分割槽被其它老年代分割槽的引用,這個資料結構叫做into remembered set

停頓時間設定較短的一個缺點在於,G1可能會跟不上程式的記憶體分配速率,這種情況下回收器會放棄並回退為STW GC模式。也就是說,掃描和拷貝都是在Java執行緒暫停的時候完成的。注意,如果垃圾回收器在進行部分收集時無法滿足對停頓時間的要求,那麼full GC一定會超過指定的停頓時間。

綜合來說,G1是一個平衡和吞吐量和停頓時間的優秀垃圾回收器。

Shenandoah垃圾收集器

Shenandoah垃圾回收器是一個OpenJDK專案,是OpenJDK 12發行版中的一部分,也被移植到了JDK 8和IDK 11。與G1回收器一樣,Shenandoah使用了相同的基於分割槽的堆空間佈局方式,並且同樣使用併發掃描執行緒計算每個分割槽中的存活資料。二者區別之處在於整理階段的處理方法不同。

Shenandoah使用併發方式對資料進行整理。(明眼人應該注意到一個問題,GC可能會在程式對物件資料進行讀寫操作時遷移資料,不用擔心,這個問題馬上就會講到)因此,Shenandoah不需要為了最小化程式停頓時間而限制回收的分割槽數量。相對地,它會選擇最有效的分割槽——也就是包含很少存活物件的分割槽,或者說含有大量無效空間的分割槽。整個過程中,只有在掃描的初始和結束階段一些相關步驟中需要停止程式。

Shenandoah併發複製物件的主要難點在於,進行復制工作的GC執行緒與訪問堆儲存的Java執行緒需要就物件的記憶體地址達成一致。地址可能會儲存在多個地方,並且對地址的更新操作必須同時進行。跟電腦科學中的大多數問題一樣,解決方法就是增加一層轉換。

(這種回收器中)物件結果中分配了額外的空間儲存間接指標。當Java執行緒訪問物件時,首先讀取間接指標檢視物件是否被移動。如果垃圾回收器移動了某個物件,就會更新其間接指標指向新的位置。新分配的物件中的間接指標會指向其自身,而且物件中的間接指標只有在GC過程中被複制時才會指向其它位置。

間接指標的使用並不是無代價的。讀取指標已經查詢物件的當前位置都需要消耗時間空間,但是這些程式碼要比你想象的小。空間方面,Shenandoah不需要通過類似卡表和into remembered sets等堆外資料結構來支援部分回收。時間方面,目前也有一些策略來消除閱讀障礙。優化的JIT編譯器也可以識別到程式在訪問一個不可變屬性,比如陣列的大小,這種情況下無論是讀原物件還是副本中的資料都是一樣的,因此也就不需要間接閱讀。此外,如果Java程式要讀取同一個物件中的多個屬性,JIT會識別並刪除後面對於間接指標的讀取。

如果Java程式要寫的物件也是Shenandoah回收器正在拷貝的物件,就會出現競爭條件。這個問題可以通過Java執行緒與GC執行緒的協作來解決。如果Java執行緒要對一個需要拷貝的物件進行寫操作,Java程式首先會將該物件拷貝到自己的分配區域中,並檢查這是否是該物件的第一次拷貝,然後再進行寫操作。如果GC執行緒首先拷貝了物件,那麼Java執行緒可以釋放其記憶體分配,使用GC執行緒的副本。

Shenandoah消除了拷貝存活物件時對執行緒的暫停,因此也就提供了更短的程式停頓時間。