深入淺出解析JVM中的Safepoint | 得物技術

得物技術發表於2023-05-11

1.初識Safepoint-GC中的Safepoint

最早接觸JVM中的安全點概念是在讀《深入理解Java虛擬機器》那本書垃圾回收器章節的內容時。相信大部分人也一樣,都是透過這樣的方式第一次對安全點有了初步認識。不妨,先複習一下《深入理解Java虛擬機器》書中安全點那一章節的內容。

書中是在講解垃圾收集器-垃圾收集演算法的章節引入安全點的介紹,為了快速準確地完成GC Roots列舉,避免為每條指令都生成對應的OopMap造成大量儲存空間的浪費,只在“特定的位置”生成對應的OopMap,這些位置被稱為安全點。然後,書中提到了安全點位置的選擇標準是:是否能讓程式長時間執行;所以會在方法呼叫、迴圈跳轉、異常跳轉等處才會產生安全點。

書中還提到了JVM如何在GC時讓使用者執行緒在最近的安全點處停頓下來:搶先式中斷和主動式中斷。搶先式中斷不需要執行緒的執行程式碼主動去配合,在GC發生時,系統首先把所有使用者執行緒全部中斷,如果發現有使用者執行緒中斷的地方不在安全點上,就恢復這條執行緒執行,讓它一會再重新中斷,直到跑到安全點上。而主動式中斷的思想是當GC需要中斷執行緒時,不直接對執行緒操作,僅僅簡單地設定一個標誌位,各個執行緒執行過程時不停地主動去輪詢這個標誌,一旦發現中斷標誌為真就自己在最近的安全點上主動中斷掛起。現在基本上所有虛擬機器實現都採用主動式中斷方式來暫停執行緒響應GC事件。

總結一下初識安全點學到的知識點:

  • JVM GC時需要讓使用者執行緒在安全點處停頓下來(Stop The World)
  • JVM會在方法呼叫、迴圈跳轉、異常跳轉等處放置安全點
  • JVM透過主動中斷方式到達全域性STW:設定一個標誌位,各個執行緒執行過程時不停地主動去輪詢這個標誌,一旦發現中斷標誌為真就自己在最近的安全點上主動中斷掛起。

以上基本上就是《深入理解Java虛擬機器》這本書對JVM安全點的所有介紹了,當時覺得安全點還是很好理解,認為安全點就是在垃圾回收時為了STW而設計的。

後來發現,經過一些線上問題和網上看到有關安全點有趣的示例,發現安全點其實也不簡單,不只有GC才會用到安全點;簡單的程式碼如果寫的不當,安全點也會帶來一些莫名其妙的問題;其在JVM內部的實現以及JIT對它的最佳化,也經常讓人摸不著頭腦。本文嘗試在初識安全點後已知知識點的基礎上,透過一段簡單的示例程式碼,多問幾個為什麼,來進一步更全面的瞭解一下安全點。

2.透過一段示例程式碼深入剖析Safepoint

2.1  示例程式碼

這段示例程式碼可直接複製到本地執行,本文所有對示例程式碼的執行環境都是jdk 1.8。


public static AtomicInteger *counter* = new AtomicInteger(0);

public static void main(String[] args) throws Exception{

long startTime = System.*currentTimeMillis*();

Runnable runnable = () -> {

System.*out*.println(*interval*(startTime) + "ms後," + Thread.*currentThread*().getName() + "子執行緒開始執行");

for(int i = 0; i < 100000000; i++) {

*counter*.getAndAdd(1);

}

System.*out*.println(*interval*(startTime) + "ms後," + Thread.*currentThread*().getName() + "子執行緒結束執行, counter=" + *counter*);

};

Thread t1 = new Thread(runnable, "zz-t1");

Thread t2 = new Thread(runnable, "zz-t2");

t1.start();

t2.start();

System.*out*.println(*interval*(startTime) + "ms後,主執行緒開始sleep.");

Thread.*sleep*(1000L);

System.*out*.println(*interval*(startTime) + "ms後,主執行緒結束sleep.");

System.*out*.println(*interval*(startTime) + "ms後,主執行緒結束,counter:" + *counter*);

}

private static long interval(Long startTime) {

return System.*currentTimeMillis*() - startTime;

}

}


示例程式碼中主執行緒啟動兩個子執行緒,然後主執行緒睡眠1s,透過列印時間來觀察主執行緒和子執行緒的執行情況。按道理來說這裡主執行緒和兩個子執行緒獨立併發,沒有任何顯性的依賴,主執行緒的執行是不會受子執行緒影響的:主執行緒睡眠結束後會直接結束。但是執行結果卻和期望不一樣。

執行結果如下方動圖展示:

79fd53629b0542ae9d38b17c66e90818.gif

1.png

從執行結果看,主執行緒在啟動兩個執行緒後進入睡眠狀態,程式碼中指定睡眠時間為1s,但是主執行緒卻在3s多之後才睡眠結束。是什麼導致了主執行緒睡過頭了呢,從結果來看主執行緒睡覺結束時間和子執行緒結束時間是一致的。所以,我們有理由懷疑主執行緒沒有按時提前結束應該是被兩個子執行緒阻塞了。

2.2  先給結論

由於VMThread的某些操作需要STW,主執行緒在sleep結束前進入了JVM全域性安全點,然後主執行緒要等待其他執行緒全部進入安全點,所以主執行緒被長時間沒有進入安全點的其他執行緒給阻塞了。

2.3  驗證結論

新增JVM列印安全點日誌引數-XX:+PrintSafepointStatistics後再執行上面的例項程式碼,結果如下截圖:

2.png

可以從安全點日誌中看到,JVM想要執行no vm operation,這個操作需要執行緒進入安全點,整個期間有12個執行緒,正在執行的執行緒有兩個,需要等待這兩個執行緒進入安全點,等待耗時2251ms。

加上 -XX:+SafepointTimeout 和-XX:SafepointTimeoutDelay=2000 引數後執行程式碼可以進一步看等待哪兩個執行緒進入安全點。 3.png

果然和猜測的一樣,沒有到達安全點的兩個執行緒正是示例程式碼中定義的zz-t1和zz-t2執行緒。

2.4  為什麼

到這裡這個示例的執行結果的原因已經有了結論並且得到了驗證,基本上已經知其然了。但是如果深入思考一下,初識安全點時學到的知識點還不能解釋,所以為了知其所以然,這裡提了幾個為什麼。

(1)為什麼會進入安全點

換句話問,是什麼觸發了進入安全點?

由初識安全點得到的基礎知識知道進入安全點需要兩個條件:

  • JVM操作設定了主動中斷標誌
  • 執行的程式碼中存在安全點

首先想到的是GC觸發JVM設定主動中斷標誌,加上 -XX:-PrintGC再執行示例程式碼並沒有列印 GC 日誌,可以排除掉GC。

既然不是GC,還是再回到安全點日誌上尋找線索吧,發現有個vmop(虛擬機器操作型別):no vm operation 關於no vm operation,網上有大神透過解析JVM原始碼得到了結論,這裡不對JVM原始碼展開做詳細解讀,直接給結論:

在 JVM 正常執行的時候,如果設定了進入安全點的間隔,就會隔一段時間判斷是否有程式碼快取要清理,如果有,會進入安全點。這個觸發條件不是 VM 操作,所以會將 \_vmop\_type 設定成-1,輸出日誌的時候列印對應的 「no vm operation」,也就是我們看到的安全點日誌。

在 VM 操作為空的情況下,只要滿足以下 3 個條件,也是會進入安全點的:

1、VMThread 處於正常執行狀態

2、設定了進入安全點的間隔時間

3、SafepointALot 是否為 true 或者是否需要清理

用 Java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal 2>&1 | grep Safepoint 命令檢視 JVM 關於安全點的預設引數:

4.png

發現 GuaranteedSafepointInterval 預設設定成了 1 秒,每隔1s就會嘗試進入安全點。

那麼,修改GuaranteedSafepointInterval引數值,看看是否能阻止進入安全點。

GuaranteedSafepointInterval引數是JVM診斷引數,修改這個引數的值,需要配合-XX:+UnlockDiagnosticVMOptions一起使用。

另外不建議線上上對這個引數的值做修改。

  • 關閉定時進入安全點

透過-XX:GuaranteedSafepointInterval = 0關閉定時進入安全點,看看程式碼執行結果是怎麼樣的

5.png

由執行結果可以看出,關閉定時進入安全點後,主執行緒睡眠1s後正常結束,不受其他執行緒阻塞。從安全點日誌看,之前等待進入安全點的兩個執行緒也沒有了。

  • 調大定時進入安全點間隔時間

由列印的執行結果可以看到子執行緒執行時間是3s多,如果把進入安全點間隔時間調整為5s,即在子執行緒結束之後再嘗試進入安全點是不是也能避免等待子執行緒進入安全點呢?修改引數-XX:GuaranteedSafepointInterval = 5000調整安全點間隔時間再次執行結果:

6.png

從執行結果可以看出,調大安全點間隔時間和關閉定時進入安全點的效果是一樣的,也可以避免等待子執行緒進入安全點的。

(2)主執行緒是在哪裡進入的安全點

從示例程式碼在預設JVM引數執行結果看,主執行緒睡眠時間超過了3s,事實上主執行緒是在Thread.sleep()方法內部進入安全點。 這裡對JVM 安全點實現的原始碼簡單做一下分析:

Safepoint實現原始碼:Safepoint.cpp

7.png

讀原始碼太費勁,看註釋吧,所幸從註釋中也能找到答案。上面截圖的註釋說在程式進入 Safepoint 的時候,Java 執行緒可能正處於的五種不同的狀態,針對不同的狀態的不同處理機制。假設現在有一個操作觸發了某個 VM 執行緒所有執行緒需要進入 SafePoint,如果其他執行緒現在:

  • 執行位元組碼:執行位元組碼時,直譯器會看執行緒是否被標記為 poll armed,如果是,VM 執行緒呼叫 SafepointSynchronize::block(JavaThread *thread)進行 block。
  • 執行 native 程式碼:當執行 native 程式碼時,VM 執行緒略過這個執行緒,但是給這個執行緒設定 poll armed,讓它在執行完 native 程式碼之後,它會檢查是否 poll armed,如果還需要停在 SafePoint,則直接 block。
  • 執行 JIT 編譯好的程式碼:由於執行的是編譯好的機器碼,直接檢視本地 local polling page 是否為髒,如果為髒則需要 block。這個特性是在 Java 10 引入的 JEP 312: Thread-Local Handshakes 之後,才是只用檢查本地 local polling page 是否為髒就可以了。
  • 處於 BLOCK 狀態:在需要所有執行緒需要進入 SafePoint 的操作完成之前,不許離開 BLOCK 狀態
  • 處於執行緒切換狀態或者處於 VM 執行狀態:會一直輪詢執行緒狀態直到執行緒處於阻塞狀態(執行緒肯定會變成上面說的那四種狀態,變成哪個都會 block 住)。

再看一下Thread.sleep方法的宣告,就和上面Safepoint.cpp原始碼註釋截圖紅框對上了,Thread.sleep正是一個native方法。

8.png

Thread.sleep(0)在RocketMQ中的妙用

9.png

上面這段程式碼是RocketMQ的一段程式碼,16年最早版本的實現for迴圈內每迴圈1000次會呼叫一次Thread.sleep(0),這貌似是一段無用的程式碼,作者真實的目的是為了在這裡放置一個安全點,避免for迴圈執行時間過長導致系統長時間SWT。從程式碼的變更記錄看,22年9月份有人對這段程式碼換了一種寫法:把for迴圈變數型別定義成long型,同時註釋掉了迴圈內部Thread.sleep(0)程式碼,為什麼可以這樣寫以及為什麼要這樣寫這裡先按下不表。

(3)子執行緒為什麼無法進入安全點

現在已經知道了主執行緒為什麼進入會進入安全點,以及主執行緒在哪裡進入的安全點,按照已知知識點JVM會在迴圈跳轉處和方法呼叫處放置安全點,為什麼子執行緒沒有進入安全點?

可數迴圈和不可數迴圈

JVM為了避免安全點過多帶來過重的負擔,對迴圈有一項最佳化措施,認為迴圈次數較少的話,執行時間應該不會太長,所以使用int型別和範圍更小的資料型別作為索引值的迴圈預設是不會被放置安全點的。這種迴圈被稱為可數迴圈,相對應的,使用long或者範圍更大的資料型別作為索引值的迴圈就被稱為不可數迴圈,將被放置安全點。

在示例程式碼中,子執行緒的迴圈索引值資料型別是int,也就是可數迴圈,所以JVM沒有在迴圈跳轉處放置安全點。

把迴圈索引值資料型別改成long型,迴圈成為不可數迴圈,就能夠成功在迴圈跳轉處放置安全點,避免子執行緒長時間無法進入安全點阻塞主執行緒。

10.png11.png

從上面的執行結果可以看到,把迴圈索引值資料型別改成long型,主執行緒在睡眠1s之後立即結束了睡眠,並沒有等待子執行緒的執行。

到這裡,也就知道為什麼上面貼的RocketMQ大那段程式碼,把迴圈索引值資料型別改成long型可以替換迴圈內部Thread.Sleep(0)達到放置安全點的目的了。

其實,還可以透過-XX:+UseCountedLoopSafepoints引數關閉JVM 對可數迴圈放置安全點的最佳化。下面的執行結果可以看出,新增了-XX:+UseCountedLoopSafepoints引數後,也能讓執行結果到達預期。

12.png

還有一個疑惑

13.png

仔細看例項程式碼,發現子執行緒迴圈體內呼叫了AtomicInteger類的getAndAdd方法,再深入看jdk getAndAdd方法的實現,發現底層是呼叫了sun.misc.Unsafe#getIntVolatile 這個方法和Thread.sleep方法一樣,也是一個native方法,為什麼這裡沒有進入像Thread.sleep方法一樣進入安全點?

14.png

是的,好可怕,確實被最佳化了,被 JIT給最佳化了。為了驗證是被JIT最佳化了,可以用

-Djava.compiler=NONE關閉JIT然後看一下執行結果。

15.png

從執行結果看,關閉了JIT最佳化後,主執行緒確實在睡眠1s後立即結束了,不過子執行緒執行的時間比JIT最佳化開啟時多了不少。所以,JIT還是能夠帶來一定的效能最佳化的,有時也會帶來一些奇怪的現象。

3.更全面的安全點定義

區別於初識安全點的時候侷限於GC中的安全點概念,這裡給安全點一個比較全面的定義:

Safepoint 可以理解成是在程式碼執行過程中的一些特殊位置,當執行緒執行到這些位置的時候,執行緒可以暫停。在 SafePoint 儲存了其他位置沒有的一些當前執行緒的執行資訊,供其他執行緒讀取。這些資訊包括:執行緒上下文的任何資訊,例如物件或者非物件的內部指標等等。我們一般這麼理解 SafePoint,就是執行緒只有執行到了 SafePoint 的位置,他的一切狀態資訊,才是確定的,也只有這個時候,才知道這個執行緒用了哪些記憶體,沒有用哪些;並且,只有執行緒處於 SafePoint 位置,這時候對 JVM的堆疊資訊進行修改,例如回收某一部分不用的記憶體,執行緒才會感知到,之後繼續執行,每個執行緒都有一份自己的記憶體使用快照,這時候其他執行緒對於記憶體使用的修改,執行緒就不知道了,只有再進行到 SafePoint 的時候,才會感知。

4.什麼時候會進入Safepoint

當VM Thread需要做vm  操作時會讓執行緒進入安全點,vm操作型別有很多,可以參考VM_OP_ENUM原始碼 vmOperations.hpp。下面是幾種經常發生的進入Safepoint的情形:

(1)GC:由於需要每個執行緒的物件使用資訊,以及回收一些物件,釋放某些堆記憶體或者直接記憶體,所以需要 進入Safepoint來 Stop the world;

(2)定時進入 SafePoint:每經過-XX:GuaranteedSafepointInterval 配置的時間,都會讓所有執行緒進入 Safepoint,一旦所有執行緒都進入,立刻從 Safepoint 恢復。這個定時主要是為了一些沒必要立刻 Stop the world 的任務執行,可以設定-XX:GuaranteedSafepointInterval=0關閉這個定時。

(3)由於 jstack,jmap 和 jstat 等命令,會導致 Stop the world:這種命令都需要採集堆疊資訊,所以需要所有執行緒進入 Safepoint 並暫停。

(4)偏向鎖取消:鎖大部分情況是沒有競爭的(某個同步塊大多數情況都不會出現多執行緒同時競爭鎖),所以可以透過偏向來提高效能。即在無競爭時,之前獲得鎖的執行緒再次獲得鎖時,會判斷是否偏向鎖指向我,那麼該執行緒將不用再次獲得鎖,直接就可以進入同步塊。但是高併發的情況下,偏向鎖會經常失效,導致需要取消偏向鎖,取消偏向鎖的時候,需要 Stop the world,因為要獲取每個執行緒使用鎖的狀態以及執行狀態。

(5)Java Instrument 導致的 Agent 載入以及類的重定義:由於涉及到類重定義,需要修改棧上和這個類相關的資訊,所以需要 Stop the world

(6)Java Code Cache相關:當發生 JIT 編譯最佳化或者去最佳化,需要 OSR 或者 Bailout 或者清理程式碼快取的時候,由於需要讀取執行緒執行的方法以及改變執行緒執行的方法,所以需要 Stop the world

5.避免Safepoint副作用

Safepoint在一定程度上是可以理解成是為了讓所有使用者執行緒停頓(Stop The World)而設計的。STW對應用系統來說是一件很可怕的事情,JVM不論是在GC還是在其他的VM操作上都在努力避免STW和減少STW時間。

安全點最主要的副作用就是可能導致STW時間過長,應該極力避免這點副作用。

對第一個進入安全點的執行緒來說,STW是從它進入安全點開始的,如果有某個執行緒一直無法進入安全點就會導致進入安全點的時間一直處於等待狀態,進而導致STW的時間過長。所以,應避免執行緒執行過長無法進入安全點的情況。

可數迴圈體內執行時間過長以及JIT最佳化導致無法進入安全點的問題是最常見的無法進入安全點的情況。在寫大迴圈的時候可以把迴圈索引值資料型別定義成long。

在高併發應用中,偏向鎖並不能帶來效能提升,反而因為偏向鎖取消帶來了很多沒必要的某些執行緒進入安全點 。所以建議關閉:-XX:-UseBiasedLocking

jstack,jmap 和 jstat 等命令,也會導致進入安全點。所以,生產環境應該關閉Thead dump的開關,避免dump時間過長導致應用STW時間過長。

參考文獻:

[1] 《深入理解java虛擬機器》

[2]http://psy-lob-saw.blogspot.com/2015/12/safepoints.html

[3]https://xie.infoq.cn/article/a80542aca7ad53efaaab1a27a

[4]https://zhuanlan.zhihu.com/p/161710652

文:Simon

更多精彩文章請訪問得物技術官網:tech.dewu.com

活動推薦:得物技術沙龍開始報名啦!點選關注得物技術沙龍瞭解詳情!

相關文章