[深入理解Java虛擬機器]Hotspot垃圾回收演算法

Duancf發表於2024-07-18

HotSpot的演算法細節實現

3.2、3.3節從理論原理上介紹了常見的物件存活判定演算法和垃圾收集演算法,Java虛擬機器實現這些演算法時,必須對演算法的執行效率有嚴格的考量,才能保證虛擬機器高效執行。本章設定這部分內容主要是為了稍後介紹各款垃圾收集器時做前置知識鋪墊,如果讀者對這部分內容感到枯燥或者疑惑,不妨先跳過去,等後續遇到要使用它們的實際場景、實際問題時再結合問題,重新翻閱和理解。

根節點列舉

我們以可達性分析演算法中從GC Roots集合找引用鏈這個操作作為介紹虛擬機器高效實現的第一個例子。

固定可作為GC Roots的節點主要在全域性性的引用(例如常量或類靜態屬性)與執行上下文(例如棧幀中的本地變數表)中,儘管目標明確,但查詢過程要做到高效並非一件容易的事情,現在Java應用越做越龐大,光是方法區的大小就常有數百上千兆,裡面的類、常量等更是恆河沙數,若要逐個檢查以這裡為起源的引用肯定得消耗不少時間。

迄今為止,所有收集器在根節點列舉這一步驟時都是必須暫停使用者執行緒的,因此毫無疑問根節點列舉與之前提及的整理記憶體碎片一樣會面臨相似的“Stop The World”的困擾。現在可達性分析演算法耗時最長的查詢引用鏈的過程已經可以做到與使用者執行緒一起併發(具體見3.4.6節),但根節點列舉始終還是必須在一個能保障一致性的快照中才得以進行——這裡“一致性”的意思是整個列舉期間執行子系統看起來就像被凍結在某個時間點上,不會出現分析過程中,根節點集合的物件引用關係還在不斷變化的情況,若這點不能滿足的話,分析結果準確性也就無法保證。這是導致垃圾收集過程必須停頓所有使用者執行緒的其中一個重要原因,即使是號稱停頓時間可控,或者(幾乎)不會發生停頓的CMS、G1、ZGC等收集器,列舉根節點時也是必須要停頓的。由於目前主流Java虛擬機器使用的都是準確式垃圾收集(這個概念在第1章介紹Exact VM相對於Classic VM的改進時介紹過),所以當使用者執行緒停頓下來之後,其實並不需要一個不漏地檢查完所有執行上下文和全域性的引用位置,虛擬機器應當是有辦法直接得到哪些地方存放著物件引用的。

在HotSpot的解決方案裡,是使用一組稱為OopMap的資料結構來達到這個目的。一旦類載入動作完成的時候,HotSpot就會把物件內什麼偏移量上是什麼型別的資料計算出來,在即時編譯(見第11章)過程中,也會在特定的位置記錄下棧裡和暫存器裡哪些位置是引用。這樣收集器在掃描時就可以直接得知這些資訊了,並不需要真正一個不漏地從方法區等GC Roots開始查詢。

下面程式碼清單3-3是HotSpot虛擬機器客戶端模式下生成的一段String::hashCode()方法的原生代碼,可

以看到在0x026eb7a9處的call指令有OopMap記錄,它指明瞭EBX暫存器和棧中偏移量為16的記憶體區域

中各有一個普通物件指標(Ordinary Object Pointer,OOP)的引用,有效範圍為從call指令開始直到

0x026eb730(指令流的起始位置)+142(OopMap記錄的偏移量)=0x026eb7be,即hlt指令為止。

程式碼清單3-3 String.hashCode()方法編譯後的原生代碼

[Verified Entry Point]

0x026eb730: mov %eax,-0x8000(%esp)

…………

;; ImplicitNullCheckStub slow case

0x026eb7a9: call 0x026e83e0 ; OopMap{ebx=Oop [16]=Oop off=142}

; *caload

; - java.lang.String::hashCode@48 (line 1489)

; {runtime_call}

0x026eb7ae: push $0x83c5c18 ; {external_word}

0x026eb7b3: call 0x026eb7b8

0x026eb7b8: pusha

0x026eb7b9: call 0x0822bec0 ; {runtime_call}

0x026eb7be: hlt

記憶集與卡表

講解分代收集理論的時候,提到了為解決物件跨代引用所帶來的問題,垃圾收集器在新生代中建立了名為記憶集(Remembered Set)的資料結構,用以避免把整個老年代加進GC Roots掃描範圍。事實上並不只是新生代、老年代之間才有跨代引用的問題,所有涉及部分割槽域收集(Partial GC)行為的垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都會面臨相同的問題,因此我們有必要進一步理清記憶集的原理和實現方式,以便在後續章節裡介紹幾款最新的收集器相關知識時能更好地理解。記憶集是一種用於記錄從非收集區域指向收集區域的指標集合的抽象資料結構。如果我們不考慮效率和成本的話,最簡單的實現可以用非收集區域中所有含跨代引用的物件陣列來實現這個資料結構,如程式碼清單3-5所示:

程式碼清單3-5 以物件指標來實現記憶集的虛擬碼

class RememberedSet {
	Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}

這種記錄全部含跨代引用物件的實現方案,無論是空間佔用還是維護成本都相當高昂。而在垃圾收集的場景中,收集器只需要透過記憶集判斷出某一塊非收集區域是否存在有指向了收集區域的指標就可以了,並不需要了解這些跨代指標的全部細節。那設計者在實現記憶集的時候,便可以選擇更為粗獷的記錄粒度來節省記憶集的儲存和維護成本,下面列舉了一些可供選擇(當然也可以選擇這個範圍以外的)的記錄精度:

  • 字長精度:每個記錄精確到一個機器字長(就是處理器的定址位數,如常見的32位或64位,這個精度決定了機器訪問實體記憶體地址的指標長度),該字包含跨代指標。
  • 物件精度:每個記錄精確到一個物件,該物件裡有欄位含有跨代指標。
  • 卡精度:每個記錄精確到一塊記憶體區域,該區域內有物件含有跨代指標。

其中,第三種“卡精度”所指的是用一種稱為“卡表”(Card Table)的方式去實現記憶集[1],這也是目前最常用的一種記憶集實現形式,一些資料中甚至直接把它和記憶集混為一談。前面定義中提到記憶集其實是一種抽象的資料結構,抽象的意思是隻定義了記憶集的行為意圖,並沒有定義其行為的具體實現。卡表就是記憶集的一種具體實現,它定義了記憶集的記錄精度、與堆記憶體的對映關係等。

關於卡表與記憶集的關係,讀者不妨按照Java語言中HashMap與Map的關係來類比理解。卡表最簡單的形式可以只是一個位元組陣列[2],而HotSpot虛擬機器確實也是這樣做的。以下這行程式碼是HotSpot預設的卡表標記邏輯[3]:

CARD_TABLE [this address >> 9] = 0;

位元組陣列CARD_TABLE的每一個元素都對應著其標識的記憶體區域中一塊特定大小的記憶體塊,這個記憶體塊被稱作卡頁(Card Page)。一般來說,卡頁大小都是以2的N次冪的位元組數,透過上面程式碼可以看出HotSpot中使用的卡頁是2的9次冪,即512位元組(地址右移9位,相當於用地址除以512)。那如果卡表標識記憶體區域的起始地址是0x0000的話,陣列CARD_TABLE的第0、1、2號元素,分別對應了地址範圍為0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡頁記憶體塊[4],如圖3-5所示。

圖3-5 卡表與卡頁對應示意圖
image

一個卡頁的記憶體中通常包含不止一個物件,只要卡頁內有一個(或更多)物件的欄位存在著跨代指標,那就將對應卡表的陣列元素的值標識為1,稱為這個元素變髒(Dirty),沒有則標識為0。在垃圾收集發生時,只要篩選出卡表中變髒的元素,就能輕易得出哪些卡頁記憶體塊中包含跨代指標,把它們加入GC Roots中一併掃描。

[1] 由Antony Hosking在1993年發表的論文《Remembered sets can also play cards》中提出。
[2] 之所以使用byte陣列而不是bit陣列主要是速度上的考量,現代計算機硬體都是最小按位元組定址的,沒有直接儲存一個bit的指令,所以要用bit的話就不得不多消耗幾條shift+mask指令。具體可見HotSpot應用寫屏障實現記憶集的原始論文《A Fast Write Barrier for Generational Garbage Collectors》
http://www.hoelzle.org/publications/write-barrier.pdf)。
[3] 引用來源為http://psy-lob-saw.blogspot.com/2014/10/the-jvm-write-barrier-card-marking.html。
[4] 十六進位制數200、400分別為十進位制的512、1024,這3個記憶體塊為從0開始、512位元組容量的相鄰區
域。

相關文章