JVM筆記-HotSpot的算法細節實現

1. 根節點枚舉

1.1 暫停用戶線程

迄今爲止,全部收集器在根節點枚舉這一步驟都是必須暫停用戶線程的。即使是號稱停頓時間可控、或者(幾乎)不會發生停頓的 CMS、G一、ZGC 等收集器,枚舉根節點時也必需要停頓。算法

這也是致使垃圾收集過程必須停頓全部用戶線程的一個重要緣由。數組

1.2 如何高效查找引用鏈

目前主流 JVM 使用的都是準確式垃圾收集,所以虛擬機有辦法直接知道哪些地方存放着對象的引用。而 HotSpot 是使用一組稱爲 OopMap 的數據結構來實現的。緩存

一旦類加載動做完成,HotSpot 就會把對象內什麼偏移量上是什麼類型的數據計算出來(在即時編譯過程當中,也會在「特定的位置」記錄下棧和寄存器中哪些位置是引用),這樣收集器在掃描時就能直接得知這些信息,沒必要挨個從方法區等 GC Roots 開始查找了。從而能夠提升查找效率。安全

普通對象指針:Ordinary Object Pointer, OOPbash

2. 安全點

2.1 概述

前面「特定的位置」記錄了引用信息,這些位置被稱爲安全點(Safepoint)。數據結構

用戶程序執行時,並不是在代碼指令流的任意位置都能停下來開始垃圾收集,而是強制要求必須執行到安全點後才能暫停。多線程

能夠用高速公路上行駛的汽車作類比:高速公路上行駛的汽車(用戶線程)不是在任何地方都能停下來的,只有到了服務區(安全點)才能停下。併發

2.2 安全點選取

安全點的選取既不能太多,也不能太少:高併發

  • 若太少,收集器會等待過長時間;性能

  • 若太多,則會過度增長運行時的內存負荷。

安全點位置的選取標準:是否具備讓程序長時間執行的特徵。什麼樣的程序會長時間執行呢?

最明顯的特徵就是指令序列的複用,如方法調用、循環跳轉、異常跳轉等,只有具有這些功能的指令纔會產生安全點。

2.3 如何讓線程跑到最近的安全點

垃圾收集發生時,如何讓全部線程(不包括 JNI 調用的線程)都跑到最近的安全點、而後停頓下來呢?有以下兩種方案能夠採用。

2.3.1 搶先式中斷
  • 思想:無需用戶線程代碼配合,垃圾收集時,系統首先把全部用戶線程所有中斷;此時如有用戶線程不在安全點,則恢復執行,直至它到達安全點再中斷。

  • 這種方案如今幾乎不用了。

2.3.2 主動式中斷
  • 思想:垃圾收集須要中斷線程時,不直接操做線程,只是設置一個標誌位,各個線程執行過程當中不停地主動輪詢該標誌位,若標誌位爲真,則在本身最近的安全點主動中斷掛起。

  • 輪詢標誌的地方和安全點是重合的。

如何高效輪詢呢?HotSpot 使用內存保護陷阱的方式,經過一條彙編指令來完成安全點輪詢和觸發線程中斷。

2.4 優缺點

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

可是,沒法解決程序「不執行」的狀況(好比用戶線程處於 Sleep 或者 Blocked 狀態),因爲此時線程沒法響應虛擬機的中斷請求,沒法再走到安全點掛起本身。

3. 安全區域

3.1 概述

爲了解決安全點機制中程序「不執行」的狀況,從而引入了安全區域(Safe Region)。

安全區域是指可以確保在某一段代碼片斷中,引用關係不會發生變化,所以在這個區域中任意地方開始垃圾收集都是安全的。能夠理解擴展拉伸的安全點。

3.2 實現思路

當用戶線程執行到安全區域裏的代碼時,會標識本身已經進入了安全區域。

虛擬機發起垃圾收集時,沒必要理會已聲明在安全區域的線程;而當線程離開安全區域時,會檢查虛擬機是否已經完成根節點枚舉(或者其餘暫停用戶線程的階段):

  • 若完成,則繼續執行;

  • 不然就必須等待,直至收到能夠離開安全區域的信號。

4. 記憶集與卡表

4.1 跨代引用問題

分代收集理論中,爲了解決對象跨代引用所帶來的問題,垃圾收集器在新生代創建了名爲記憶集(Remembered Set)的數據結構,以免把整個老年代加入 GC Roots 的掃描範圍。

實際上,全部涉及部分區域收集(Partial GC)行爲的垃圾收集器(例如 G一、ZGC、Shenandoah 等)都會面臨一樣的問題。

4.2 記憶集

「記憶集」是一種抽象的數據結構,用於記錄從「非收集區域」指向「收集區域」的指針集合。

垃圾收集場景中,收集器只需經過記憶集判斷出某一塊非收集區域是否存在指向收集區域的指針便可,無需瞭解跨代引用指針的所有細節。

所以,在實現記憶集時,能夠採用不一樣的記錄粒度,以節省記憶集的存儲和維護成本,幾種精度舉例以下:

  • 字長精度:每一個記錄精確到一個機器字長(處理器的尋址位數,如常見的 32 位或 64 位),該字包含跨代指針

  • 對象精度:每一個記錄精確到一個對象,該對象中有字段包含跨代指針

  • 卡精度:每一個記錄精確到一塊內存區域,該區域中有對象包含跨代指針

4.3 卡表

4.3.1 記憶集&卡表

其中,上述第三種「卡精度」指的是用一種「卡表(Card Table)」的方式來實現記憶集,也是目前最經常使用的實現方式。

記憶集與卡表的關係:可類比 Java 語言中接口與實現類的關係(好比 Map 與 HashMap)。

4.3.2 卡表&卡頁

卡表最簡單的形式能夠是一個字節數組,數組中的每一個元素都對應着其標識的內存區域中一塊特定大小的內存塊,該內存塊稱爲「卡頁(Card Page)」,它們的關係如圖所示:

一個卡頁的內存中一般包含不止一個對象,只要卡頁內有一個(或更多)對象的字段存在跨代指針,就將對應卡表的數組元素的值標識爲 1,稱爲該元素變髒(Dirty),若無則標識爲 0.

4.4 卡表的維護

卡表何時變髒?誰來把它變髒呢?

  • 什麼時候:當有其餘分代區域中的對象引用了本區域對象時,其對應的卡表元素就應該變髒。

  • 如何變髒:HotSpot 虛擬機是經過寫屏障實現的。

下面介紹什麼是寫屏障。

5. 寫屏障

5.1 簡述

寫屏障(Write Barrier)能夠看作在虛擬機層面對「引用類型字段賦值」動做的 AOP 切面,賦值前的寫屏障稱爲「寫前屏障(Pre-Write Barrier)」,賦值後的寫屏障稱爲「寫後屏障(Post-Write Barrier)」。

應用寫屏障後,虛擬機會爲全部賦值操做生成相應的指令,一旦收集器在寫屏障中增長了更新卡表操做,不管更新的是否是老年代對新生代的引用,每次只要對引用進行更新,就會產生額外的開銷。

5.1 僞共享問題

5.1.1 僞共享

除了寫屏障的開銷,高併發場景下還存在「僞共享(False Sharing)」問題:即,多線程修改互相獨立的變量時,若是這些變量剛好共享一個緩存行,會彼此影響而致使性能下降。

該問題是處理底層細節時常常須要考慮的。

5.1.2 如何避免

如何避免「僞共享」問題:不採用無條件寫屏障,而是先檢查卡表標記,僅當該卡表元素未被標記過期纔將其標記爲變髒。

若以 SQL 的更新操做(UPDATE)進行類比,則:

  • 無條件寫屏障:每次 UPDATE 不加判斷,直接更新;

  • 開啓判斷條件後:每次 UPDATE 前,先執行 SELECT,查詢結果知足必定條件時再執行 UPDATE。

若開啓該判斷條件,能避免僞共享問題;但同時也會增長一次額外判斷的開銷。所以須要根據實際狀況來權衡。

JDK 7 以後,HotSpot 虛擬機增長了以下參數來決定是否開啓卡表更新的判斷條件:

# 是否開啓卡表更新的判斷條件
-XX:+UseCondCardMark複製代碼

6. 併發的可達性分析

6.1 併發標記問題

可達性分析算法理論上要求全過程都基於一個能保障一致性的快照中才能進行分析,這意味着必須全程凍結用戶線程(Stop The World)。

爲何必須在一個能保證一致性的快照上才能進行對象圖的遍歷呢?

  1. 若是用戶線程是凍結的,沒問題。

  2. 若用戶線程沒凍結,也就是用戶線程與收集器併發工做呢?收集器在對象圖標記,同時用戶線程在修改引用關係(修改對象圖的結構),這樣可能出現兩種後果:

    1. 把本來消亡的對象錯誤標記爲存活,這種狀況雖很差(產生了浮動垃圾),但還能夠容忍。

    2. 把本來存活的對象標記爲消亡,這就很嚴重了,程序確定會所以報錯。

下面用三色標記(Tri-color Marking)演示這種狀況是如何產生的。

6.2 垃圾收集器標記過程

垃圾收集器從 GC Roots 開始標記的過程示意圖以下:

上圖三色含義:

  • 白色:對象還沒有被垃圾收集器訪問過(若在分析結束後,對象仍爲白色,則表示不可達)

  • 黑色:對象已被垃圾收集器訪問過,且該對象全部引用都已被掃描(安全存活的)

  • 灰色:對象已被垃圾收集器訪問過,但未掃描完全部引用(即該對象正在被掃描,可理解爲中間態)

注意引用是有方向的。

可是,若是在標記過程當中,用戶線程對引用關係作了修改,以下:

在上圖的(4)中:

  1. 原先對象 A 未引用 C,對象 B 引用了 C;

  2. 但標記到 B 時,用戶線程斷開了 B 到 C 的引用,而使 A 引用了 C;

  3. 則垃圾收集器標記完成後,C 依然是白色(即會被回收掉);

  4. 對象 DEFG 同理。

這樣致使的後果就是:正在被對象 A 和 D 引用的對象 C 和 G,在垃圾收集器標記的過程當中,因爲用戶線程的運行,致使本應存活的對象被垃圾收集器標記爲消亡、並回收了。程序會所以報錯,這是個嚴重的問題。

6.3 如何解決對象消失

如何解決上述「對象消失」的問題呢?理論證實,當且僅當如下兩個條件同時知足時,纔會產生「對象消失」的問題:

  1. 賦值器插入了一條或多條從黑色對象到白色對象的新引用;

  2. 賦值器刪除了所有從灰色對象到該白色對象的直接或間接引用。

針對這兩個條件,在上圖中,以對象 A、B、C 爲例解釋以下:

  • 若只增長了 A 對 C 的引用,則 C 在垃圾回收後依然是存活的,不會出錯。

  • 若只有 B 斷開了對 A 的引用,則 C 在垃圾回收後是消亡的,但並無 A 對 C 的引用,所以也不會出錯。

所以,要解決併發掃描時的對象消失問題,只需破壞其中一個便可。由此產生了兩種解決方案:增量更新(Increment Update)和原始快照(Snapshot At The Begining, SATB)。

6.3.1 增量更新
  • 思路:破壞第一個條件。

  • 作法:黑色對象(A)插入新的指向白色對象(C)的引用關係(A→C)時,就將這個新插入的引用記錄下來,待併發掃描結束以後,再以這些記錄過的引用關係中的黑色爲根,從新掃描一次。

  • 簡化理解:黑色對象一旦新插入了指向白色對象的引用,它就變爲灰色(需從新掃描)了。

6.3.2 原始快照
  • 思路:破壞第二個條件。

  • 作法:當灰色對象(B)要刪除指向白色對象(C)的引用關係(B→C)時,就將這個要刪除的引用記錄下來,併發掃描結束後,再以這些記錄過的引用關係中的灰色對象爲根,從新掃描一次。

  • 簡化理解:不管引用關係刪除與否,都會按照剛開始掃描那一刻的對象圖快照來進行搜索。

這兩種方案都有在用:在 HotSpot 虛擬機中,CMS 是基於增量更新來作併發標記的,G一、Shenandoah 則是用原始快照實現的。

6.3.3 舉例

以上圖爲例:在併發掃描時,增長了 A→C 引用,而且刪除了 B→C 引用,若不採起任何措施,則掃描結束後對象 C 會消失。

兩種解決方案的作法分別以下:

  • 增量更新:將已標記爲黑色的對象 A 置爲灰色,待併發掃描結束後,從新掃描對象 A。此時能夠掃描到 A→C 引用,對象 C 不會消失。

  • 原始快照:若要刪除 B→C 引用,則將原始的 B→C 引用記錄下來(原始的快照),待併發掃描結束後,從新掃描對象 B,因爲記錄的是原始信息,其中包含 B→C 引用。這樣,即使未掃描到 A→C 引用,對象 C 也不會消失。

此外,不管引用關係記錄的插入仍是刪除,虛擬機都是經過寫屏障實現的。

相關文章
相關標籤/搜索