深入探究JVM之垃圾回收演算法實現細節

夜勿語發表於2020-07-27

@

前言

本篇緊接上文,主要講解垃圾回收演算法的實現細節以及對目前最前沿的低延遲GC(Shenandoah、ZGC)做個介紹。

垃圾回收演算法實現細節

根節點列舉

我們知道目前的JVM的垃圾回收器都是採用可達性分析演算法標記存活物件,該演算法首先需要找到GC Roots,然後通過這些根節點向下搜尋,能搜尋到的就標記為存活物件,未被標記的最後就會被垃圾回收器回收。那你是否想過垃圾回收器怎麼找到GC Roots呢?對於在方法區的根節點難道需要將方法區中的類、常量等資訊一個不漏的都掃描一遍麼?
虛擬機器當然不會這麼做,否則即使CMS和G1在初始標記這個環節都會停頓較長時間。實際上虛擬機器在類載入完成後就會將物件引用維護到一組成為OopMap的資料結構中,在GC進行初始標記這個環節時直接從該資料結構中獲取根節點即可。
另外在進行根節點列舉時,這些根節點必然是不能變化的(不可能為每條指令都生成對應的OopMap),即必須凍結在開始掃描之前的某個時間點,這也是為什麼初始標記時都會需要STW的原因。

安全點

在上一篇簡單提到過安全點的概念,虛擬機器開始GC時,不能隨時隨地立馬暫停使用者執行緒,必須跑到合適的位置才能暫停,這個位置就是安全點。那麼使用者執行緒應該在何時何點暫停呢?有以下兩個原則:

  • 安全點不能太多,太多的話使用者執行緒就會暫停比較頻繁,給系統增加負擔。
  • 安全點也不能太少,太少的會導致垃圾虛擬機器需要等待較長時間才能開始GC標記

從以上兩個原則我們可以總結出,安全點的選擇應以是否具有讓程式長時間執行的特徵為標準。什麼是長時間執行?這需要從指令角度考量,因為單條指令的執行時間都比較短,不可能以指令流的長度作為標準,只有指令序列的複用才能最明顯地體現出程式將要長時間執行,而方法呼叫(如遞迴呼叫)迴圈跳轉(迴圈次數可能比較多)異常跳轉這些指令就屬於指令序列複用。
安全點如何確定我們明白了,但是如何讓使用者執行緒跑到最近的安全點呢?有兩種方案:搶先式中斷主動式中斷。前者就是系統首先會暫停所有的使用者執行緒,然後挨個檢查是否已經在安全點,如果不在就恢復執行緒讓它跑到安全點。而後者則是由執行緒執行過程中自己去輪詢判斷是否是安全點,是就暫停,否則繼續執行直到跑到安全點。目前虛擬機器基本上採用的都是主動式中斷

安全區域

在執行過程中的使用者執行緒可以響應系統的中斷請求,但是還有些處於SleepBlock等非執行中的執行緒是無法響應中斷請求的,這個就沒法用安全點來保證了,因為虛擬機器不可能等待執行緒被系統分配時間片,為此引入了安全區域概念。
在這裡插入圖片描述
如圖所示,安全區域指的是一個範圍,當使用者執行緒進入該區域時,首先會標記自己進入了安全區域,當執行完該區域內的程式碼後,需要判斷垃圾收集器的STW階段已經完成(初始標記、重新標記等),如果已完成,執行緒繼續執行即可,否則則需要等待STW的結束。
通過上文我們很容易理解,安全區域需要該區域內不能有引用關係變化。

記憶集和卡表

記憶集的概念在上一篇的也提到過,它是用來解決跨代引用問題的,維護在垃圾收集區域,其中儲存了從非收集區域指向收集區域的指標集合(這部分引用需要作為GC Roots)。該指標的精度如果都是指向具體的跨代引用物件的話,維護成本非常高,另外和掃描整個非收集區域是一樣的。=指標的精度有以下幾種選擇:

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

上面最細粒度的就是字長精度,最粗粒度的是卡精度,目前最常用的實現就是第三種,也被稱為卡表。在HotSpot虛擬機器中使用的是位元組陣列來實現的卡表:

CARD_TABLE[this address >> 9] = 0;

上面這段程式碼的意思是每個陣列元素儲存的是每個卡頁(記憶體塊)的記憶體起始地址,右移9位即代表除以512,及每個卡表大小為512位元組(在HotSpot中是2的9次冪,其它虛擬機器中也需要保證是2的N次冪)。為幫助理解,我畫了一張對應關係圖,圖中數字都已轉化為十進位制數。
在這裡插入圖片描述
當卡頁中只要存在至少一個跨代引用物件,對應卡表中的元素就會被標識為1,標識該卡頁變“髒”,在進行可達性分析時,就會將變“髒”的記憶體頁加入GC Roots中一併掃描。
在CMS和G1中都使用了卡表,在使用CMS時,只在新生代中維護了一個卡表(老年代中也有可能存在新生代對其的跨代引用,但新生代的物件大都朝生夕死,所以沒有必要),而G1是每個Region都需要維護一個卡表,因此G1比CMS更浪費空間,換言之這也是為什麼G1更適合堆空間較大的情況。

寫屏障

有了卡表,就能很輕鬆地解決跨代引用的問題,但是卡表在什麼時候去維護呢?考慮到併發問題,肯定需要在跨代引用欄位賦值完成的那一刻將對應的記憶體頁變“髒”,即欄位賦值和卡表的維護應該保證原子性(多個操作是不可分割的一個操作)。那麼要如何實現呢?在HotSpot虛擬機器中是使用的寫屏障技術實現的,可以理解為對欄位賦值的AOP環形通知。既然是環形通知,那麼就存在寫前屏障寫後屏障,在維護卡表這一操作上所有的垃圾回收器都使用的是寫後屏障,而G1還使用寫前屏障實現原始快照(稍後分析)。
寫屏障雖好,但也有其缺陷,一是會增加額外的開銷,所以的賦值操作都會增加維護卡表的邏輯;二是在高併發場景下卡表會存在偽共享(現代的處理器是以快取行為單位儲存的,如果儲存的兩個或多個獨立的變數位於同一快取行,就會彼此影響,導致快取失效,效能大大降低。)問題。

假設處理器的快取行大小為64位元組,由於一個卡表元素佔1個位元組,64個卡表元素將共享同一個快取行。這64個卡表元素對應的卡頁總的記憶體為32KB(64×512位元組),也就是說如果不同執行緒更新的物件正好處於這32KB的記憶體區域內,就會導致更新卡表時正好寫入同一個快取行而影響效能。為了避免偽共享問題,一種簡單的解決方案是不採用無條件的寫屏障,而是先檢查卡表標記,只有當該卡表元素未被標記過時才將其標記為變髒,即將卡表更新的邏輯變為以下程式碼所示:
if (CARD_TABLE [this address >> 9] != 0) CARD_TABLE [this address >> 9] = 0;
在JDK 7之後,HotSpot虛擬機器增加了一個新的引數-XX:+UseCondCardMark,用來決定是否開啟卡表更新的條件判斷。開啟會增加一次額外判斷的開銷,但能夠避免偽共享問題,兩者各有效能損耗,是否開啟要根據應用實際執行情況來進行測試權衡。

上面內容引用自《深入理解Java虛擬機器》,這裡博主仍存在一個疑問:CARD_TABLE[0]~CARD_TABLE[64]處於同一快取行,任意一個元素的值改變都會導致該快取行失效,那這裡是不是隻是解決了同一快取行所有元素第一次維護完之後的偽共享問題呢?

併發的可達性分析

通過前面的學習我們知道GC停頓最耗時的階段是在深入遍歷物件圖的時候,所以CMS和G1都是將該階段實現為與使用者執行緒併發執行,降低STW的時間,而要降低使用者執行緒的停頓的前提是必須要保證整個可達性分析過程處於一個一致性的快照中。那要如何保證處於一致性快照呢?在非併發垃圾回收器中都是採用讓整個回收過程STW實現的,而現在為了降低這個延遲,需要將其中一些過程改為與使用者執行緒併發執行,為此JVM使用了一個三色標記的演算法來實現一致性快照三色標記就是將掃描過程中的標記狀態分為了三種顏色(以前只有1和0,可以理解為黑色和白色):

  • 黑色:物件和該物件中的所有引用都已經被掃描過,表示存活物件,一開始只有GC Roots是黑色的。
  • 白色:還沒有被掃描過的物件,直到整個掃描完成後還是白色的物件就會被回收。
  • 灰色:當前物件已被訪問過,但其內至少還有一個引用沒被垃圾回收器掃描過的物件就回標記為灰色。黑色和白色不能直接相連,中間必須要有灰色物件。

既然是與使用者執行緒併發執行,那麼就必然存在引用變化的問題,所以需要思考怎麼正確地標記物件的顏色。這有兩種情況,一是多標,將本來應該回收的物件標記為黑色(在掃描過程中有其它執行緒修改了刪除了對黑色物件的引用),這種情況是可以容忍的,只需要在下一次GC時一起回收就可以了;另外還有一個主要要解決的問題——漏標,即本來應該存活的物件沒有標記為黑色,導致應存活物件最後被回收,這種情況是非常危險的。
在這裡插入圖片描述
如圖所示,當垃圾回收執行緒掃描到灰色物件的那一刻,突然有其它的使用者執行緒將指向下面白色物件的引用刪除掉,並賦值給已經掃描過的黑色物件,那麼最終掃描完成後就會漏標一個或多個(此處只列出的最簡單的情況)物件,導致被回收。“物件消失”的問題於1994年在Wilson中被證明需要同時滿足下面兩個條件才會出現:

  • 賦值器插入了一條或多條從黑色物件到白色物件的新引用;
  • 賦值器刪除了全部從灰色物件到該白色物件的直接或間接引用。

因此只需要破壞這兩個條件中的任意一個,就能解決漏標問題。由此產生了下面兩種解決方案:

  • 增量更新(Incremental Update):破壞的是第一個條件,每當相黑色的物件插入一個白色物件時,就記錄下這個引用,等待併發掃描完成後,再重新掃描一下這些引用,CMS採用的是這種方式。
  • 原始快照(Snapshot At The Beginning,SATB):破壞的是第二個物件,當灰色物件要刪除指向白色物件的引用的時候,就會記錄下這個引用,在掃描結束後,再逐個掃描這些引用,好比刪除時留下了一個快照資訊,G1、Shenandoah則是用原始快照來實現。

通過上文就能理解為什麼目前併發收集器中都會有一個最終或重新標記的過程,並且這個階段也是STW的。

低延遲GC

前面所講的GC在回收階段都還需要顯著的停頓時間,主要問題在於整理階段還不支援和使用者執行緒併發執行,所以虛擬機器的開發者們一直在想方法設法如何讓GC的停頓只與根節點數量有關,而不是堆中所有物件的數量,由此產生了幾款非常優秀的垃圾回收器,這裡主要討論Shenandoah收集器ZGC,由於它們目前都還不夠成熟,實現也非常複雜,所以不會過多的討論實現細節。

Shenandoah

Shenandoah並非Oracle開發的垃圾收集器,所以受到官方的打壓,只能在OpenJDK中使用。相比於G1它有以下區別:

  • 也是採用Region佈局
  • Shenandoah目前預設不使用分代回收(以後可能會支援)
  • 整理回收階段支援與使用者執行緒併發執行
  • 每個Region不再單獨維護記憶集,而是維護了一個全域性的連線矩陣資料結構。可以看作是一個二維表格,橫豎都表示Region的編號,當Region 2引用了Region 3,Region 5引用了Region 1中的物件時,對應表格的2行3列和5行1列就會打上標記。

Shenandoah的執行原理比較複雜,包含了以下9個階段:

  • 初始標記:標記與GC Roots直接關聯的物件,會有極短的STW時間
  • 併發標記:併發的可達性分析
  • 最終標記:處理剩餘的SATB記錄,並統計出回收價值最高的Region,這個階段也會有一小段暫停時間。
  • 併發清理:清理整個堆中一個存活物件都沒有的Region。
  • 併發回收:這個階段會把複製存活物件到其它Region。因為與使用者執行緒併發執行,所以需要解決物件引用變動問題,Shenandoah採用的是Brooks Pointers轉發指標來解決的(稍後分析)。
  • 初始引用更新:更新回收階段變動的引用的指標,不過在初始階段只是設定了一個類似安全點的執行緒集合點,確保所有併發回收階段中進行的收集器執行緒都已完成分配給它們的物件移動任務,這個階段也會有短暫的STW時間。
  • 併發引用更新:真正執行引用更新操作,時間長短與引用數量有關。
  • 最終引用更新:修正存在於GC Roots中的引用,需要停頓,與GC Roots數量有關。
  • 併發清理:清理掉之前的Region。

瞭解了Shenandoah的執行原理,再來看轉發指標是如何支援併發整理的。轉發指標是在物件頭中新增了一個引用欄位,該欄位指向當前物件最新的記憶體地址,預設情況就是指向自己,一旦物件地址發生改變,即被複制到新的Region中,則需要同時修改頭部中的引用指向,注意這兩部操作必須保證連續,即中間不能有其它操作,避免併發競爭。
從上面我們可以看到Shenandoah雖然解決了併發清理,但實際執行過程中也有4個需要停頓的地方,另外由於使用轉發指標,對於記憶體地址改變的物件在引用更新完成之前對其訪問都會產生額外的開銷,所以經測試Shenandoah在總的垃圾回收執行時間上相較以前的垃圾回收器是最長的,但是停頓時間的降低確實有很大的提高。

ZGC

ZGC相較於Shenandoah又是一革命性的垃圾回收器,它的垃圾回收停頓時間只和根節點數量有關,目前任意大小的堆空間回收停頓時間都能控制在10ms內,但是由於它使用染色指標標記物件是否重分配(重分配是ZGC的一種處理動作,用於複製物件的收集器階段,稍後會介紹到)導致目前ZGC在64位系統最大可管理4TB的堆空間。ZGC同樣採用Region佈局,不過Region大小分為三種型別:

  • 小型:容量固定為2MB,用於放置小於256KB的小物件。
  • 中型:容量固定為32MB,用於放置大於等於256KB但小於4MB的物件。
  • 大型:容量不固定,可以動態變化,但必須為2MB的整數倍,用於放置4MB或以上的大物件。每個大型Region中只會存放一個大物件,最小容量可低至4MB。大型Region在ZGC的實現中是不會被重分配的,因為複製一個大物件的代價非常高昂。

什麼是染色指標?ZGC的標記區別於其它的垃圾回收器,既不是單獨維護在記憶集中,也不是維護在物件頭中,而是直接標記在引用指標上。受限於硬體和作業系統的限制,目前ZGC只能用於64位系統,而64位系統高18位是不能使用的,剩餘的46位中ZGC使用了4位來儲存三色標記、是否進入了重分配集(即被移動過)、是否只能通過finalize()方法才能被訪問等狀態資訊,這也是為什麼目前ZGC最多隻能管理4TB的堆空間(2的42次冪)的原因。
ZGC的執行過程包含了4個階段:

  • 併發標記:物件圖的遍歷,也存在初始標記和最終標記兩個需要短暫停頓的階段。
  • 併發預備重分配:掃描所有的Region(不再維護記憶集),統計得出需要清理的Region,將這些Region組成重分配集。
  • 併發重分配:將重分配集中存活物件複製到新的Region中,並且會為重分配集中每個Region維護一個轉發表,指向物件的新地址,得益於染色指標的支援,ZGC只需要從引用指標上就能得知物件最新的記憶體地址。如果使用者執行緒此時訪問一個移動了的物件,只有第一次會根據轉發表找到新地址,並同時修正引用指向,這稱為指標的自愈性。另外由於染色指標的存在,任何一個Region中存活物件複製完畢,該Region就可以直接釋放並分配新物件,因此在ZGC中至多隻會浪費一個空間(需要一個空的Region完成複製物件的存放)。
  • 併發重對映:重對映所做的就是修正整個堆中指向重分配集中舊物件的所有引用,但這個過程由於染色指標的存在,引用是可以“自愈”的,所以ZGC將這個過程放到下一次GC的併發標記過程中。當所有引用修正後,原先得轉發表記錄就可以釋放掉了。

以上就是ZGC得執行原理,從上面我們可以發現ZGC也是沒有分代的,所以它不需要維護記憶集,即少了寫屏障帶來的執行負擔以及沒有了記憶集佔用大量的記憶體空間,但同時不分代也帶來新的問題,ZGC不適用於高速分配物件的系統中,因為ZGC當對一個較大堆執行一次完整的收集時,會執行較長時間(非停頓時間),這時系統如果建立物件的速度較快,就會產生大量浮動垃圾,堆中可用的空間就會越來越少,目前只能通過增大堆空間來緩解,但終究是治標不治本的方法,這也是為什麼ZGC適合較大堆空間的垃圾回收的原因。

總結

垃圾回收演算法實現的細節是面試的重點,重要性自然不言而喻,但主要是要理解每個演算法要解決的問題以及它的思想,明白我們平時使用的虛擬機器在哪些情況下會發生停頓,深刻理解,在進行調優時也才有更好的思路。最後介紹的兩款低延遲的垃圾回收器可根據自身情況進行了解,最好是能理解其設計思想,本文也只是簡單的介紹了一下,詳細細節可翻閱《深入理解JVM虛擬機器第三版》。

相關文章