根節點枚舉
固定可作為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的卡也內存。如圖所示:
一個卡頁的內存中通常包含不止一個對象,只要卡頁內有一個(或更多)對象的字段存在這跨帶的指針,那就將對應的卡表的數組元素的值標識為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則是用原始快照來實現的。