徹底理解JVM垃圾回收-重要概念理解(九)

根節點枚舉

固定可作爲GC Roots的節點主要存在全局性引用(例如常量或者類靜態屬性)與執行上下文(例如棧幀中的本地變量表)中,儘管目標比較明確但是要高效查找這些節點並非易事。迄今爲止,所有收集器的根節點枚舉這一步都需要暫停用戶線程的,毫無疑問枚舉根節點需要面臨”Stop the world“的困擾。現在可達性分析算法耗時最長的查找引用鏈的過程已經可以做到與用戶線程一起併發(CMS),但根節點的枚舉始終還是必須在一個能保障一致性(整個枚舉期間執行子系統看起來像被凍結在某個時間點上)的快照中才得以進行,不會出現分析過程中,根節點集合的跟節點引用關心還在不斷變化的情況,若這點不能滿足的話,分析結果的準確性就無法保證。這是導致垃圾收集過程必須停頓所有用戶線程的其中一個重要原因,即使號稱停頓時間可控,或者幾乎不會發生停頓的CMS、G1、ZGC等收集器,枚舉根節點時也是必須要停頓的。
目前主流的Java虛擬機使用的都是準確式的垃圾收集,所有當用戶線程停頓下來之後,其實並不需要一個不漏的檢查完所有執行上下文和全局的引用位置,虛擬機應當有辦法直接得到哪些地方存放着對象引用的。在HotSpot的解決方案中,是使用一組成爲OopMap(Ordinary Object Pointer,OOP)的數據結構來達到這個目的的。一旦類加載動作完成的時候,HotSpot就會把對象內什麼偏移量上是什麼類型的數據計算出來,在即時編譯過程中,也會在特定的位置記錄下棧裏和寄存器裏哪些位置是引用。這樣收集器在掃描時就可以直接得知這些信息了,並不需要真正一個不漏的從方法區等GC Roots開始查找。

安全點

在OopMap的協助下,HotSpot可以快速準確的完成GC Roots枚舉,但一個很現實的問題隨之而來:可能導致引用關係變化,或者說導致OopMap的內容變化的指令非常多,如果爲每一條指令都生成OopMap,那將需要大量的額外的內存空間去存儲。
實際上HotSpot並沒有爲每條指令生成OopMap,只是在特定位置記錄了這些信息,稱之爲安全點(SafePoint)。有了安全點的設定,也就是決定了用戶程序執行時並非在代碼指令流的任意位置都能停頓下來進行垃圾收集,而是強制要求必須執行到安全點後才能暫停。因此,安全點的選定既不能太少以至於讓收集器等待時間過長,也不能太多以至於過分增大運行時的內存負荷。安全點位置的選取基本是以”是否具有讓程序長時間執行的特徵“爲標準進行選定的,因爲每條指令執行的時間都非常短暫,程序不太可能因爲指令流長度太長這樣的原因而長時間執行,”長時間執行“的最明顯特徵就是指令序列的複用,例如方法調用、循環跳轉、異常跳轉等屬於指令序列複用,所以只有這些工功能的指令纔會產生安全點。
垃圾收集發生時,如何讓所有線程(不包括執行JNI【Java Native Interface】調用的線程)都跑到最近的安全點,然後停頓下來,這裏提供了兩種方式:
(1)搶斷式中斷:搶斷式中斷不需要線程的執行代碼配合,在垃圾收集的時,系統首先把所有用戶線程全部中斷,如果發現有用戶線程中斷的地方不在安全點上,就恢復這條線程執行,讓它跑到最近的安全點上。現在幾乎沒有虛擬機實現採用搶斷式中斷來暫停線響應GC事件
(2)主動式中斷:當垃圾收集時需要中斷用戶線程時,不需要直接對線程操作,僅僅簡單的設置一個標誌位,各個線程執行過程時,會不停的主動輪詢這個標誌位。一旦發現中斷標誌爲真時就自己在最近的安全點上主動中斷掛起。輪詢標誌的地方和安全點是重合的,另外還要加上所有創建對象和其他需要在Java堆上分配內存的地方,這是爲了方便檢查是否即將要發生垃圾收集,避免沒有足夠內存分配新對象。

安全區域

使用安全點似乎完美解決了如何停頓用戶線程,讓虛擬機進入垃圾回收狀態的問題了,但實際情況並不一定,安全點機制保證了程序執行時,在不太長的時間內就會遇到可進入垃圾收集過程的安全點,但是程序“不執行”的時候呢?程序不執行就是沒有分配處理器時間,典型的場景就是用戶線程處於Sleep狀態或者Blocked狀態,這時候線程無法響應虛擬機的中斷請求,不能再走到安全的地方再中斷掛起自己,虛擬機也顯然不可能等待線程被重新激活分配處理器時間,對於這種情況採用安全區域(Safe Region)來解決
當用戶線程執行到安全區域的代碼片段中,引用關係就不會發生變化,因此這個區域中任意位置開始垃圾手機都是安全的,我們也可以把安全域看作被拉伸了的安全點。
當用戶線程執行到安全區域的代碼時,首先會標識自己已經進入安全域,那樣當這段時間裏虛擬機要發起垃圾收集時就不必去管這些已聲明自己在安全區域內的線程了。當線程要離開安全域,它要檢查虛擬機是否已經完成了根節點的枚舉(或者垃圾收集過程中其他需要暫停用戶線程的階段),如果完成了,那麼線程繼續執行,否則就一直等待,直到收到可以離開安全域的信號爲止。

記憶集與卡表

分代垃圾收集中爲了解決對象跨代引用的問題,垃圾收集器在新生代中建立了名爲記憶集(Remembered Set)的數據結構,用以避免把整個老年代加進GC Roots掃描範圍。事實上並不是只有新新生代、老年代之間纔有跨帶引用問題,所有涉及部分區域手機行爲的垃圾收集器,如G1、ZGC和Shenandoah收集器,都會面臨跨代引用的問題。
記憶集是用於記錄從非收集區域指向收集區域的指針集合的抽象數據結構。如果不考慮效率和成本的話,最簡單的實現可以用非收集區域中所包含跨代引用的對象數組來實現這個數據結構。這個只記錄全部含跨代引用對象的實現方案,無論是空間佔用還是維護成本都非常高昂。而在垃圾收集的場景中,收集器只需要通過記憶集判斷出某一塊非收集區域是否存在有指向了收集區域的指針就可以了,並不需要了解這些跨帶指針的全部細節。那設計者可以選擇比較粗獷的記錄粒度來節省記憶集的存儲和維護成本,下面列舉了一些可供選擇(當然也可以選擇這個範圍以外的)的記錄精度:
(1)字長精度:記錄精確到一個機器字長(就是處理器的尋址位數,如常見的32位或64位,這個精度決定了機器訪問物理內存地址的指針長度),該字包含跨代指針。
(2)對象精度:每個記錄精確到一個對象,該對象裏有字段患有跨代指針。
(3)卡精度:每個記錄精確到一塊內存區域。該區域內有對象含有跨代指針。

其中卡精度是採用一種卡表(Card Table)的方式去實現記憶集,這也是目前最常用的一種記憶集實現形式。記憶集是一種抽象的“數據結構”,而卡表是記憶集的一種具體實現,它定義了記憶集的記錄精度、與堆內存的映射關係等。卡表的最簡單的形式可以只是一個字節數組,HotSpot虛擬機確實也是這樣做的。如下代碼爲HotSpot的默認卡表實現

CARD_TABLE [this address >> 9] =0

字節數組CARD_TABLE的每一個元素都對應着其標識的內存區域中一塊特定大小的內存塊,這個內存塊被稱之爲卡頁。一般來說卡頁的大小都是以2的N次冪的字節數,通過上面代碼可以看出HotSpot中使用卡頁是2的9次冪,即512字節(地址右移9位,相當於除以512)。如果卡表內存起始地址爲0x0000的話,數組CARD_TABLE的第0、1、2號元素,分別對應了0x0000~0x001FF、0x0200~0x03FF、0x0400~0x05FF的卡也內存。如圖所示:
Card Table and Card Page
一個卡頁的內存中通常包含不止一個對象,只要卡頁內有一個(或更多)對象的字段存在這跨帶的指針,那就將對應的卡表的數組元素的值標識爲1,稱爲這個元素變髒(Dirty),沒有標識爲0。在垃圾收集發生時,只要篩選出卡表中變髒的元素,就能輕易得出哪些卡頁內存塊中包含跨代指針,把它加入GC Roots中一併掃描。

寫屏障

我們使用記憶集的方式來解決了GC Roots掃描範圍的問題,但是還沒有解決“卡表”的維護問題,例如它們何時變髒,由誰負責將它們變髒。
卡表何時變髒是明確的——其他分代區域中有對象引用了本區域對象時,其對應的卡表元素就應該變髒,變髒是時間點原則上應該發生在引用類型賦值的那一刻,但如何變髒,即如何在對象賦值的那一刻去更新維護卡表呢?加入是解釋執行的字節碼,那相對好處理,虛擬機負責每條字節碼的執行,有充分的介入空間,但在編譯執行的場景下,即時編譯後的代碼已經是純碎的機器指令流了,這就必須找到一個在機器碼層面的手段,把維護卡表的動作放在每一個賦值操作中。
在HotSpot虛擬機中,是通過寫屏障(Write Barrier)技術維護卡表狀態。寫屏障可以看做虛擬機層面對“引用類型字段賦值”這個動作的AOP切面,在引用類型賦值時,會產生一個環繞通知(Around),供程序執行額外的動作,也就是賦值的前後都是在寫屏障的覆蓋範圍之內。在賦值前的部分的寫屏障稱爲寫前屏障(Pre-write Barrier),在賦值之後的稱爲寫後屏障。HotSpot虛擬機除了G1收集器,其他的收集器都只用到了寫屏障。 如下代碼是寫後屏障更新卡表:

void oop_field_store(oop* field,oop new_value){
   //引用類型字段賦值
   *field = new_value;
   // 寫後屏障,更新卡表信息
   post_write_barrier(field,new_value);
}

應用寫屏障後,虛擬機會爲所有賦值操作生成相應的指令,一旦收集器在寫屏障中增加了更新卡表操作,無論更新的是不是老年代對新生代的引用,每次只要對引用進行更新,就會產生額外的開銷,不過這個開銷與Minor GC時掃描整個老年代的代價相比要低很多。
除了寫屏障的開銷外,卡表在高併發場景下還面臨着“僞共享(False Sharing)”問題。僞共享是處理併發底層細節時一種經常需要考慮的問題,現在中央處理器的緩存系統是以緩存行(Cache Line)爲單位存儲的,當多線程修改相互獨立變量時,如果這些變量恰巧共享一個緩存行,就會彼此影響(寫回、無效或者同步)而導致性能降低,這是僞共享問題。
假設處理器緩存行大小爲64字節,由於一個卡表元素佔用一個字節,64個卡表元素將共享同一個緩存行。這64個卡表元素對應的卡頁總的內存爲32KB(64*512字節),也就是說不同的卡表正好寫入同一個緩存行而影響性能,只有當該卡表元素未被標記過時纔將其標記爲變髒,即卡表更新將加上以下判斷邏輯:

if(CARD_TABLE[this address>>9] !=0 ){
	CARD_TABLE[this address>>9] =0;
}

在JDK1.7之後,HotSpot虛擬機增加了一個新的參數-XX:+UseCondCardMark,用來決定時候開啓卡表更新的判斷邏輯,開啓之後會增加一次額外的判斷開銷,但能避免僞共享問題,兩者各有性能損耗,時候開啓根據實際運行情況來測試權衡。

併發的可達性分析

當前主流編程語言的垃圾收集器基本上都是依靠可達性分析算法來判定對象是否存活,可達性分析算法理論上要求全過程都基於一個能保障一致性的快照中才能夠進行分析,這意味着必須全程都基於一個能保障一致性的快照中才能夠進行分析,這就意味着必須全程凍結用戶線程的運行。在根節點枚舉這個步驟中,由於GC Roots相比整個堆中全部對象相對還是極少數,且還存在各種優化手段(OopMap)的加持下,它帶來的停頓已經是非常短暫且相對固定的了(不隨堆精簡打增長而增長)。可是從GC Roots再往下遍歷對象圖,這一步驟的停頓時間必定與Java堆空間容量成正比:堆空間越大,存儲的對象越多,對象圖結構越複雜,要標記更多對象而產生的停頓時間自然就更長久。
“標記”階段是所有追蹤鏈式垃圾收集算法的共同特徵,如果這個階段會隨着堆變大而等比例增加停頓時間,其影響就會波及幾乎所有的垃圾收集器,如果能夠削弱這部分的停頓時間的話,收益就會是系統性的。
想解決或者降低用戶線程的停頓,首先搞清楚爲什麼要在一個能保證一致性的快照上才能進行對象圖的遍歷,這邊我們引入三色標記(Tri-color Marking)最爲工具來輔助推導,我們把遍歷對象圖過程中的對象,按照“是否訪問過”這個條件分成一下三種顏色:
(1)白色表示對象尚未被垃圾收集器訪問過 。顯然在可達性分析剛剛開始的階段,所有的對象都是白色的,若在分析結束的階段,仍然是白色的對象,即代表不可達。
(2)黑色表示對象已經被垃圾收集器訪問過,且這個對象的所有引用都已經掃描過。 黑色的對象代表已經掃描過,他是安全存活的,如果有其他對象引用指向了黑色對象,無須重新掃描一遍。黑色對象不可能直接(不經過灰色對象)指向某個白色對象。
(3)灰色表示對象已經被垃圾收集訪問過,但這個對象至少存在一個引用還沒有被掃描過

可達性分析過程中,如果是凍結用戶線程的情況下,只有收集器線程在工作,不會有任何問題。如果用戶線程和收集器是併發工作的情況下,收集器在對象上標記顏色,同時用戶線程在修改引用關係——即修改對象的圖結構,這樣可能出現兩種後果。一種是把原本標記消亡的對象錯誤標記爲存活,這種還是可以容忍的,只是產生了一些逃過收集的浮動垃圾而已,下次清理掉就好了。另一種是把原本存活的對象錯誤標記爲已消亡,這就是非常致命的後果,程序肯定會發生錯誤。下圖爲對象併發標記時產生錯誤過程示意圖:
在這裏插入圖片描述
只有在兩個條件同時滿足時才能產生”對象消失“的問題,即原本是黑色的對象被誤標記爲白色:
(1)賦值器插入了一條或者多條從黑色對象到白色對象的新引用。
(2)賦值器刪除了全部從灰色對象到白色對象的直接或者間接引用。
所以我們解決併發掃描時的對象消失問題,只需要破壞這兩個條件中任意一個即可。由此分別產生了兩種解決方案:增量更新(Incremental Update)原始快照(Snapshot At The Beginning,SATB)
增量更新要破壞的是第一個條件,當黑色對象插入新的指向白色對象的引用關係時,就將這個新插入的引用記錄下來,等併發掃描結束之後,重新掃描一次。可以簡化理解爲黑色對象一旦新插入了執行白色的引用之後,它就變回灰色對象了
原始快照要破壞的是第二個條件,當灰色對象要刪除執行白色對象的引用關係時,就將這個要刪除的引用記錄下來,在併發掃描結束之後,再講這些記錄過的引用關係中的灰色對象爲根,重新掃描一次。可以簡單理解爲:無論引用關係刪除與否 ,都會按照剛剛開始掃描那一刻的對象圖快照來進行搜索
無論是對引用關係記錄的插入還是刪除,虛擬機的記錄操作都是通過寫屏障實現的。在HotSpot虛擬機中,增量更新和原始快照這兩種方案都實際應用過,如,CMS是基於增量更新來做併發標記的,G1、Shenandoah則是用原始快照來實現的。

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章