你竟然不懂JVM中垃圾回收基本知識:暫停應用程式STW之安全點?

大數據架構師 發佈 2022-12-07T19:17:57.603517+00:00

安全點在垃圾回收中最常用的詞就是STW。什麼是STW?當GC運行時,為了遍歷對象的引用關係,需要應用程式暫停,防止應用程式修改對象的引用關係導致GC標記錯誤,暫停應用程式就是所謂的Stop The World(簡稱STW)。但是STW背後的實現原理是什麼?

安全點

在垃圾回收中最常用的詞就是STW。什麼是STW?當GC運行時,為了遍歷對象的引用關係,需要應用程式暫停,防止應用程式修改對象的引用關係導致GC標記錯誤,暫停應用程式就是所謂的Stop The World(簡稱STW)。但是STW背後的實現原理是什麼?應用線程如何暫停,又如何恢復?

STW中涉及的第一個概念就是安全點(safepoint)。safepoint可以理解為代碼執行過程中的一些特殊位置,當線程執行到這些位置時,說明虛擬機當前的狀態是安全、可控的(安全可控指的是,通過JVM控制線程能找到活躍對象;能夠檢查或者更新Mutator狀態),當Mutator到達這個位置時放棄CPU的執行,讓JVM控制線程(VMThread是JVM的控制線程)執行。讓Mutator在安全點停止的原因可以總結為兩個:讓VMThread能夠原子地運行,不受Mutator的干擾;實現簡單。

其實線程暫停有主動暫停和被動暫停,JVM實現的是主動暫停,在暫停之前,需要讓手頭的事情做完整以便暫停後能正常恢復。安全點在JVM中非常常見,不僅在GC中使用,在Deoptimization、一些工具類(比如dump heap等)中都會涉及。

由於JVM支持多線程及JVM內部的複雜性,可能同時存在不同的線程執行不同的代碼的情況,例如解釋器線程解釋執行字節碼,Java線程執行編譯後的代碼,線程執行本地代碼,還存在JVM內部線程,這些線程也會執行一些並發工作,也會訪問Java對象。不同的線程進入安全點的方法不同,下面分別介紹。

解釋線程進入安全點

對於Mutator線程來說,如果它正處於解釋執行狀態,即通過解釋器對每一條字節碼執行,那麼此時該如何主動放棄CPU?基本思路是當虛擬機要求解釋線程暫停時,解釋器會執行完當前的字節碼,然後暫停。

參考解釋執行那節JVM對解釋器的實現,虛擬機提供一個正常指令派發表,還提供一個異常指令派發表,需要進入安全點的時候,JVM會用異常指令派發表替換這個正常指令派發表,那麼當前字節碼指令執行完畢之後再執行下一條字節碼指令,就會進入異常指令派發表。

解釋線程進入安全點的時間通常是可控的,進入暫停的最大等待時間是一條字節碼的執行時間。

編譯線程進入安全點

編譯線程指的是正在執行編譯優化代碼的線程。JIT將一段字節碼片段編譯成機器碼,可以想像正在執行的機器碼不包含讓線程主動暫停的指令,所以如果沒有額外的處理,編譯後的機器代碼無法暫停。為了讓編譯後的代碼能夠主動暫停,一種有效的方法是在編譯後的機器代碼中插入一些額外的指令,這些指令可能讓編譯代碼執行時能夠主動地暫停。

對於這種方法,有兩個問題需要考慮:

1)在什麼地方插入額外的指令?如果插入過多的指令,可能會影響編譯代碼的執行速度,但是插入的指令太少,可能導致編譯線程遲遲無法進入暫停狀態。

2)插入的額外指令應該是什麼樣子的?插入指令不應該對編譯優化後的機器碼產生負面影響(即不影響程序正確運行),同時效率應該足夠高。

對於第一個問題,在執行效率和暫停效率之間取得平衡,通常只在一些特殊位置之後才會插入特殊指令,這些特殊位置通常包含函數調用點、函數返回、循環回收等。GC安全點支持和1.4.4節OSR編譯替換技術有一些相似之處,虛擬機僅在特定地方做相關功能的支持。表2-2總結了OSR和安全點支持可能發生的位置。

在JVM中會在上述GC安全點支持的位置上插入額外的指令來判斷是否需要暫停。一種實現是設置一個全局狀態標記,當需要線程暫停時修改狀態值,額外指令可以判斷狀態是否發生變化,如果發生變化,則進入安全狀態並暫停線程的執行。

JVM在Linux中的實現很有代表性,首先在JVM初始化時產生一個全局的輪詢頁面(Polling Page),當需要編譯線程進入安全點時,該輪詢頁面會被設置為不可讀。編譯線程在執行過程中如果執行到檢查輪詢頁面的狀態,並發現頁面不可讀,則會產生一個信號量(SIGSEGV),JVM捕獲信號量保存編譯線程的狀態,然後暫停自身的執行,待GC執行結束後恢復狀態繼續執行。

需要注意的是,編譯代碼可能訪問堆中的對象,而進入安全點以後,GC執行可能會修改對象的位置及引用關係,所以在GC執行中需要對編譯代碼中引用的對象更新對象引用關係。為了更準確地支持編譯後代碼對象引用關係的更新,通常需要額外的數據結構存儲對象的位置。

在編譯代碼中需要針對循環進行額外處理,否則遇到一個超大循環時可能導致編譯線程長時間無法進入安全點,但是也不需要在循環的回邊中每次都插入額外的指令,那樣做會影響效率。一種可行的方法是每經過一定循環次數後執行額外的檢查指令,在JVM中使用參數UseCountedLoopSafepoints控制是否允許循環間隔檢查,並且提供了參數(LoopStripMiningIter)控制循環間隔的步長(默認值為1000),如果發現編譯線程長時間無法進入安全點,則可以嘗試使用這兩個參數進行調整。

本地線程進入安全點

如果線程正在執行本地代碼(Native Code,如C/C++代碼),本地代碼訪問的內存空間和Java堆空間不是一個,這意味著本地代碼不能直接訪問Java對象[1]。理論上本地線程不需要暫停。

但是可能存在這樣的情況:GC開始執行,本地線程也在並發執行,突然本地線程執行完畢切換到Java線程執行Java代碼。對於這種情況,GC已經發生,但是線程尚未暫停,如何設計合理的機制暫停線程?如果不暫停,線程可能改變對象的引用關係,進而引發GC的正確性問題。

對於這種情況,一個解決方案是:當線程從本地代碼執行結束切換到Java代碼執行時,讓線程暫停執行。當然,JVM中關於Java代碼和本地代碼的切換設計得相當複雜,這裡不做介紹,只介紹在互操作時確保GC的正確性。如果需要了解與互操作相關的更詳細的信息,可以參考其他書籍[2]

JVM內部並發線程進入安全點

在虛擬機內部也有一些並發線程,這些線程可能訪問Java堆中的對象,也可能並不訪問Java堆中的對象。

對於不訪問Java堆的線程,例如一些周期性統計線程,僅僅統計虛擬機內部的信息,在整個執行過程中都不訪問Java堆,所以對GC完全沒有影響,在執行GC操作時無須暫停,不會影響GC的正確性。

對於可能訪問Java堆空間對象的並發線程,在GC執行前也需要進入安全點。內部線程進入安全點的方式也是在一些控制代碼處主動檢查是否需要進入安全點,如果需要進入安全點,則會主動掛起自己,等待GC結束後通過信號量喚醒繼續執行,所以在虛擬機內部需要編寫額外的代碼主動檢查是否需要進入安全點。

另外,由於虛擬機內部線程可以訪問堆空間,為保證GC執行後的正確性,需要特別處理堆空間的對象訪問。一種實現是虛擬機內部不直接訪問堆空間的對象,而是通過間接方式,例如通過Handle的方式,在GC執行結束後調整Handle,以便線程能正確地訪問對象;

另外一種實現是虛擬機在進入安全點以後,在GC執行過程中將線程需要處理的對象處理完,待GC完成後,JVM內部並發線程總是從一個全新的狀態繼續執行。

安全點小結

至此,所有的線程都應該以不同的實現進入安全點。但是正如上面提到的,每種線程進入安全點的機制也不太相同,所以進入安全點花費的時間也不太相同。線程進入安全點的整體示意圖如圖2-18所示。

它們分別代表了5種不同的情況,如表2-3所示。

擴展閱讀:垃圾回收器請求內存設計

在Linux平台上,一些GC實現(如JVM)中使用mmap函數首先申請一大塊內存,然後自己管理對象的分配;一些GC實現使用glibc庫函數直接調用malloc函數滿足對象的分配;還有一些GC實現使用第三方庫函數(如TCMalloc)管理對象的分配。不同的選擇其考量是什麼?

要理解GC設計的策略,需要理解malloc/free的實現。先來看一段C程式設計師使用malloc/free管理內存代碼片段:

int* pInt = (int*) malloc(10 * sizeof(int));

//使用pInt,直到free分配的內存才釋放

free(pInt);

一個問題是free是如何知道釋放10個int大小的內存空間?在函數原型中free只是接收1個參數:待釋放的指針,所以這個指針指向的地址一定經過特殊的處理,讓free在執行時不需要內存的長度空間。

典型的實現是在使用malloc時對分配的內存做額外的變化,多申請一塊空間用於存儲內存的實際長度,這樣使用free的時候按照同樣的約定就可以找到內存的實際長度。下面給出malloc和free的功能描述:

函數malloc(size)實際完成的功能可以分解為:

1)實際向OS分配的內存長度為size+4,其中4位元組用於存儲內存的長度;假設OS返回的內存地址為pStart。

2)將長度寫入地址開始的位置,即*((int*)pStart)=size。

3)返回真實可用的內存空間給應用,即(void*)((char*)pStart + 4)。

函數free(pPointer)實際完成的功能可以分解為:

1)獲得指針指向的內存真實起始地址,即char* pRealStart =

(char*)pPointer-4。

2)獲得應用實際使用的內存長度,即int size = *((int*)pRealStart)。

3)通過OS的API真正釋放內存起始位置為pRealStart,長度為size+4的內存空間。

當然類庫在malloc中還可以額外分配更多的內存用於其他功能,例如校驗。這樣的設計就會導致真實分配的內存超過用戶請求的內存,意味著在使用庫函數的分配/釋放函數時有額外的內存消耗。

另外一種管理內存的方案是直接向OS請求一大塊內存空間,即使用類似mmap(Linux系統的API)的方式,由VM提供內存分配和回收的功能,VM通常不需要記錄內存使用的長度(在JVM中內存的長度信息通過類的元數據提供),這樣就可以避免這種內存消耗。在一些基準測試中,發現直接使用庫函數的分配/釋放與VM直接管理內存的方式相比會有額外的5%~15%的內存消耗。

由於glibc使用弱符號引用的方式允許用戶提供運行時的malloc/free,這樣就可以使用一些成熟的類庫(如TCMalloc)來提供高效的malloc/free。

TCMalloc有一個非常大的優點——高效,基於線程/CPU的緩存分配方式,能極大地提高應用運行的效率。當然TCMalloc也有不足之處,可能存在一定的內存浪費。除此之外,雖然TCMalloc是基於線程/CPU的緩存分配方式,避免了多線程分配的鎖競爭問題,但是效率與後文介紹的TLAB的效率還是略有差異。關於TCMalloc的更多內容可以參考官方文檔[1]

最後做一個簡單的總結,直接使用庫函數malloc甚至TCMalloc可能存在的問題如下:

1)回收效率不夠高,內存使用free釋放後,不一定會被立即重複使用。

2)內存使用效率不夠高,在malloc、new庫函數中除了分配真正的對象空間外,還會附加一些額外占用內存的信息,比如分配的長度、越界信息。

3)分配效率不夠高,通常在malloc中需要對堆進行加鎖,用於保證多個進程同時競爭堆空間的分配。即便TCMalloc中優化了基於線程的分配,也無法達到Mutator中TLAB的分配效率。

本文給大家講解的內容是JVM中垃圾回收相關的基本知識:安全點,解釋+編譯+本地+JVM內部並發線程進入安全點

  1. 下篇文章給大家講解的內容是JVM垃圾回收器詳解:串行回收,分代堆內存管理概述
  2. 感謝大家的支持!
關鍵字: