可達性分析深度剖析:安全點和安全區域

語言: CN / TW / HK

可達性分析可以分成兩個階段

  • 根節點列舉
  • 從根節點開始遍歷物件圖

前文我們在介紹垃圾收集演算法的時候,簡單提到過:標記-整理演算法(Mark-Compact)中的移動存活物件操作是一種極為負重的操作,必須全程暫停使用者應用程式才能進行,像這樣的停頓被最初的虛擬機器設計者形象地描述為 “Stop The World (STW)”。

顯然 STW 並不是一件好事,能夠避免那就需要儘可能避免。

在可達性分析中,第一階段 ”可達性分析“ 是必須 STW 的,而第二階段 ”從根節點開始遍歷物件圖“,如果不進行 STW 的話,會導致一些問題,由於第二階段時間比較長,長時間的 STW 很影響效能,所以大佬們設計了一些解決方案,從而使得這個第二階段可以不用 STW,大幅減少時間

先這樣籠統的介紹下,大夥兒對可達性分析的整體脈絡有個認識就行,下面會詳細解釋,我會分兩篇文章來寫,本篇就先來分析第一階段 ”可達性分析“!

根節點列舉

迄今為止,所有收集器在根節點列舉這一步驟時都是必須暫停使用者執行緒的,列舉過程必須在一個能保障 ”一致性“ 的快照中才得以進行。

通俗來說,整個列舉期間整個系統看起來就像被凍結在某個時間點上,不會出現在分析過程中,使用者程序還在執行,導致根節點集合的物件引用關係還在不斷變化的情況,若這點都不能滿足的話,可達性分析結果的準確性顯然也就無法保證。

也就是說,根節點列舉與我們之前提到的標記-整理演算法(Mark-Compact)中的移動存活物件操作一樣會面臨相似的 “Stop The World” 的困擾。

另外,眾所周知,可作為 GC Roots 的物件引用就那麼幾個,主要在全域性性的引用(例如常量或類靜態屬性)與執行上下文(例如虛擬機器棧中引用的物件)中,儘管目標很明確,但查詢過程要做到快速高效其實並不是一件容易的事情。

現在 Java 應用越做越龐大,光是方法區的大小就常有數百上千兆,裡面的類、常量等更是一大堆,要是把這些區域全都掃描檢查一遍顯然太過於費事。

那有沒有辦法減少耗時呢?

一個很自然的想法,空間換時間!

把引用型別和它對應的位置資訊用雜湊表記錄下來,這樣到 GC 的時候就可以直接讀取這個雜湊表,而不用一個區域一個區域地進行掃描了。Hotspot 就是這麼實現的,這個用於儲存引用型別的資料結構叫 OopMap。

下圖是 HotSpot 虛擬機器客戶端模式下生成的一段 String::hashCode() 方法的原生代碼,可以看到在 0x026eb7a9 處的 call 指令有 OopMap 記錄,它指明瞭 EBX 暫存器和棧中偏移量為 16 的記憶體區域中各有一個 OopMap 的引用,有效範圍為從 call 指令開始直到0x026eb730(指令流的起始位置)+ 142(OopMap 記錄的偏移量)= 0x026eb7be,即 hlt 指令為止。

實話實說,這段不理解也就算了,知道 OopMap 是這麼一個東西就行了。

安全點 Safe Point

在 OopMap 的協助下,HotSpot 可以快速完成根節點枚舉了,但一個很現實的問題隨之而來:由於引用關係可能會發生變化,這就會導致 OopMap 內容變化的指令非常多,如果為每一條指令都生成對應的 OopMap,那將會需要大量的額外儲存空間,這樣垃圾收集伴隨而來的空間成本就會變得無法忍受的高昂。

所以實際上 HotSpot 也確實沒有為每條指令都生成 OopMap,只是在 “特定的位置” 生成 OopMap,換句話說,只有在某些 ”特定的位置“ 上才會把物件引用的相關資訊給記錄下來,這些位置也被稱為安全點(Safepoint)。

有了安全點的設定,也就決定了使用者程式執行時並不是隨便哪個時候都能夠停頓下來開始 GC 的,而是強制要求程式必須執行到達安全點後才能夠進行 GC(因為不到達安全點話,沒有 OopMap,虛擬機器就沒法快速知道物件引用的位置呀,沒法進行根節點列舉)。

如下圖所示:

因此,安全點的設定既不能太少以至於讓垃圾收集器等待時間過長,也不能太多以至於頻繁進行垃圾收集從而導致執行時的記憶體負荷大幅增大。所以,安全點的選定基本上是以 “是否具有讓程式長時間執行的特徵” 為標準進行選定的,最典型的就是指令序列的複用:例如方法呼叫、迴圈跳轉、異常跳轉等,所以只有具有這些功能的指令才會產生安全點。

對於安全點,另外一個需要考慮的問題是,如何在 GC 發生時讓所有使用者執行緒都執行到最近的安全點,然後停頓下來呢?。這裡有兩種方案可供選擇:

  • 搶先式中斷(Preemptive Suspension):這種思路很簡單,就是在 GC 發生時,系統先把所有使用者執行緒全部中斷掉。然後如果發現有使用者執行緒中斷的位置不在安全點上,就恢復這條執行緒執行,直到跑到安全點上再重新中斷。

搶先式中斷的最大問題是時間成本的不可控,進而導致效能不穩定和吞吐量的波動,特別是在高併發場景下這是非常致命的,所以現在幾乎沒有虛擬機器實現採用搶先式中斷來暫停執行緒響應 GC 事件。

  • 主動式中斷(Voluntary Suspension):主動式中斷不會直接中斷執行緒,而是全域性設定一個標誌位,使用者執行緒會不斷的輪詢這個標誌位,當發現標誌位為真時,執行緒會在最近的一個安全點主動中斷掛起。現在的虛擬機器基本都是用這種方式。

安全區域 Safe Region

安全點機制保證了程式執行時,在不太長的時間內就會遇到可進入垃圾收集過程的安全點。

對於主動式中斷來說,使用者執行緒需要不斷地去輪詢標誌位,那對於那些處於 sleep 或者 blocked 狀態的執行緒(不在活躍狀態的執行緒)來說怎麼辦?

這些不在活躍狀態的執行緒沒有獲得 CPU 時間,沒法去輪詢標誌位,自然也就沒法找到最近的安全點主動中斷掛起了。

換句話說,對於這些不活躍的執行緒,我們沒法掌控它們醒過來的時間。很可能其他執行緒都已經通過輪詢標誌位到達安全點被中斷了,然後虛擬機器開始根節點枚舉了(根節點列舉需要暫停所有使用者執行緒),但是這時候那些本不活躍的使用者執行緒又醒過來了開始執行,破壞了物件之間的引用關係,那顯然是不行的。

對於這種情況,就必須引入安全區域(Safe Region)來解決。

安全區域的定義是這樣的:確保在某一段程式碼片段之中,引用關係不會發生變化,因此,在這個區域中的任意地方開始 GC 都是安全的。

可以簡單地把安全區域看作被拉長了的安全點。

當用戶執行緒執行到安全區域裡面的程式碼時,首先會標識自己已經進入了安全區域。那樣當這段時間裡虛擬機器要發起 GC 時,就不必去管這些在安全區域內的執行緒了。當安全區域中的執行緒被喚醒並離開安全區域時,它需要檢查下主動式中斷策略的標誌位是否為真(虛擬機器是否處於 STW 狀態),如果為真則繼續掛起等待(防止根節點列舉過程中這些被喚醒執行緒的執行破壞了物件之間的引用關係),如果為假則標識還沒開始 STW 或者 STW 剛剛結束,那麼執行緒就可以被喚醒然後繼續執行。