來聊聊 JVM 中安全點的概念
近期在分享關于synchronized關鍵字的文章的時候提到了一個關于安全點的概念,有讀者反饋這塊知識點講的有些潦草,遂以此文簡單介紹一下JVM中關于安全點的概念。
一、詳解safepoint基本概念
1. 什么是安全點?為什么需要安全點
在正式講解安全點之前,我們不妨復習一下JVM中垃圾回收的基本過程,我們以CMS垃圾回收器為例,其垃圾回收過程在完成GC Roots查找與收集之后就會按照如下步驟執(zhí)行:
- 初始標記
- 并發(fā)回收
- 最終標記(重新標記)
- 并發(fā)清除
要知道固定可作為GC Roots的節(jié)點主要是:
- 全局引用:例如常量或者靜態(tài)變量。
- 執(zhí)行上下文即棧幀中的變量表。
對于現代java應用而言,光是方法區(qū)就可能有數百上千兆,所以對于這些起源的引用也并非一件容易的事情。這也就意味著JVM在進行垃圾回收時并不能通過逐個掃描檢查來實現。 就目前主流的JVM來說,針對根節(jié)點枚舉基本都是采用空間換時間的策略,也就是使用一組OopMap,全稱為"Object Pointer Map"(對象指針映射),本質上就是一個位圖索引,它會通過以下兩個時機完成對象信息的緩存:
- 類加載完成后,hotSpot就會基于類的偏移量信息計算出來并緩存。
- JIT階段也會在特定的時機(這一點后續(xù)會詳細說明)計算出棧或寄存器中的那些位置是引用,并將其緩存。
如此一來,下次進行根枚舉時就可以直接基于OopMap高效完成:
但是java進程的運行的瞬息萬變的,可能此刻的對象在下一刻就不可用,下一刻又有新的對象誕生,這種引用關系的實時變化亦或者說導致OopMap內容變化的指令是非常多的,若針對每一個指令都設置對應的oopMap,那么內存的開銷是非常高昂的。
所以就有了安全點(safepoint)的概念,這也就是我們上文所提及的特定的位置,基于這個設定,用戶的程序僅僅會在特定的情況下生成oopMap,同理在垃圾回收時,也要求所有線程達到安全點后才能夠暫停并進入STW從而開始進行初始標記、最終標記等操作:
例如下面這段代碼:
Object o=new Object();
對應匯編碼如下,可以看到0x00000000031ffb8f的call指令,它指明偏移量40-852處有一個普通對象指針Oop(Ordinary Object Pointer):
0x00000000031ffb80: mov $0xf5,%edx
0x00000000031ffb85: mov %ecx,%ebp
0x00000000031ffb87: mov %rbx,0x28(%rsp)
0x00000000031ffb8c: data16 xchg %ax,%ax
0x00000000031ffb8f: callq 0x00000000030957a0 ; OopMap{[40]=Oop off=852}
;*new ; - java.lang.String::<init>@58 (line 205)
; - java.lang.String::substring@52 (line 1933)
; {runtime_call}
2. JVM如何讓線程跑到最近的安全點
對于安全點上的線程中斷策略,大體來說是有兩種:
- 搶占式:當需要進入安全點時,JVM會主動掛起所有的用戶線程,如果線程未在安全點則等到該線程進入安全點進入安全點并完成中斷。這種做法最大的缺點就是時間不可控即很可能存在性能不穩(wěn)定亦或者吞吐量的波動,所以截至目前還有那款虛擬機采用搶占式的方式完成線程中斷。
- 主動式:這種方式是讓線程去維護一個標志位,需要進入安全點時修改該變量,用戶線程就會在合適的時機檢查這個變量值,如果這個值為真時就進入安全點。
3. 線程什么時候需要進入安全點
除了常見的垃圾回收標記觸發(fā)STW使得所有線程需要進入安全點以外,對應的進入安全點的時機還有:
- 使用jstat、jmap、jstack等命令,為保證監(jiān)控堆棧信息的實時正確性,所有線程需要STW并進入安全點暫停。
- JDK8默認情況下定時進入安全點,保證一些需要進入安全點的操作能夠及時運行。
- JIT編譯代碼優(yōu)化例如:OSR(棧上替換即一種運行時替換棧幀的技術)或者去優(yōu)化即Bailout(將JIT編譯后的代碼回退,解釋器模式),因為可能存在執(zhí)行指令的變化,線程就需要進入安全點。
- java agent需要對類進行增強導致類重新定義,需要修改類的相關信息,所以需要進入安全點。
- 高并發(fā)情況下,鎖升級機制會涉及偏向鎖撤銷,需要進入STW檢查每個線程的使用狀態(tài),所以也需要進入安全點。
4. JVM如何保證線程高效進入安全點
我們以線程運行JIT編譯好的代碼為例,它的設計與實現步驟為:
- JVM初始化一個異常處理器,專門捕獲對應的page fault缺頁中斷異常。
- JIT編譯代碼期間,會基于我們上述的規(guī)則在特定位置插入一條精簡的指令,作為安全點檢查。
- VM線程通知當前線程進入安全點,將線程內部維護的內存頁即polling page設置為不可讀。
- 線程執(zhí)行這條機器碼指令發(fā)現內存頁不可讀,觸發(fā)缺點中斷。
- 異常處理器捕獲這個異常,線程進入安全點。
對應的我們也給出這段精簡的匯編碼指令,即test %eax,0x160100 ; {poll}這段指令,這段指令本質上就是執(zhí)行poll操作檢查安全點,嘗試訪問線程內存頁對應地址為0x160100,如果發(fā)現不可訪問則觸發(fā)缺頁中斷進入安全點:
0x01b6d627: call 0x01b2b210 ; OopMap{[60]=Oop off=460}
;*invokeinterface size
; - Client1::main@113 (line 23)
; {virtual_call}
0x01b6d62c: nop ; OopMap{[60]=Oop off=461}
;*if_icmplt
; - Client1::main@118 (line 23)
0x01b6d62d: test %eax,0x160100 ; {poll}
0x01b6d633: mov 0x50(%esp),%esi
0x01b6d637: cmp %eax,%esi
5. 如何設置安全點
而這個輪詢操作的合適的時機也就是觸發(fā)安全點的時機,對于安全點的選定不能太過于頻繁導致過分增加內存的負荷,如果進入安全點的選定太少同樣也會導致線程無法及時進入安全點而導致未能及時GC而OOM,所以我們對于安全點的選定一定要符合能夠讓程序長時間的運行為標準:
無界循環(huán)或者大循環(huán):對于長時間的while(true)或者以long類型為輪詢次數的for循環(huán),JVM會插入安全點保證循環(huán)能夠在需要停頓時及時停頓:
for (long i = 0; i < 10_0000_0000; i++) {
counter.getAndAdd(1);
//safe point
}
while (true){
counter.getAndAdd(1);
//safe point
}
在執(zhí)行方法調用返回之前:
private boolean function() {
//do something
return true;
//safe point
}
可能拋出異常的位置,例如下面這段代碼:
public static void main(String[] args) {
int result = 10 / 0;
}
對應的匯編碼如下,可以看到因為算數可能存在異常,它就在0x00000000033f0695設置了一個test安全點檢查:
0x00000000033f066c: mov $0xa,%eax ; 將被除數10加載到eax寄存器
0x00000000033f0671: mov $0x0,%esi ; 將除數0加載到esi寄存器(準備觸發(fā)除零異常)
0x00000000033f0676: cmp $0x80000000,%eax ; 檢查被除數是否為Integer.MIN_VALUE(-2^31)
0x00000000033f067c: jne 0x00000000033f068d ; 如果不是則跳轉到常規(guī)除法流程
0x00000000033f0682: xor %edx,%edx ; 清零edx寄存器(特殊溢出處理路徑)
0x00000000033f0684: cmp $0xffffffff,%esi ; 檢查除數是否為-1(配合前面對MIN_VALUE的檢查)
0x00000000033f0687: je 0x00000000033f0690 ; 如果是-1則跳過除法(避免MIN_VALUE/-1溢出)
0x00000000033f068d: cltd ; 將eax符號位擴展到edx(準備64位被除數)
0x00000000033f068e: idiv %esi ; 執(zhí)行有符號除法eax/esi(此處會觸發(fā)除零異常)
; 對應Java代碼:int result = 10 / 0;
; 隱式異常處理分支:dispatches to 0x00000000033f069c
0x00000000033f0690: add $0x30,%rsp ; 調整棧指針(清理棧幀)
0x00000000033f0694: pop %rbp ; 恢復調用者的基址指針
0x00000000033f0695: test %eax,-0x28b059b(%rip) ; 安全點檢查:{poll_return}
; 檢查線程本地polling page是否可訪問
; 不可訪問則進入安全點處理
6. 用一次GC解釋不同狀態(tài)的線程如何進入safepoint
當VM線程(JVM內部的一種特殊的系統線程)需要觸發(fā)GC時,它需要所有的線程都進入安全點,從而實現STW:
- 線程正在運行字節(jié)碼:這種情況也就是常規(guī)的情況,這解釋器會查看線程是否被設置為poll armed,如果為真則將其block阻塞。
- 線程運行JIT編譯好的代碼:JIT會在指定位置插入安全點檢查,如果需要進入安全點,JVM則會將線程內部維護的內存頁即polling page設置為不可讀,當線程在安全點檢查時感知到這一點之后,就會直接將內存block。
- 線程正在運行native代碼:VM線程不會等待該線程進入阻塞狀態(tài),而是將線程設置為poll armed,當線程執(zhí)行完成native代碼并返回時看到這個標識,如果還是需要停在safepoint,則會直接block阻塞。
- 正在阻塞的線程,這種情況下線程就會一直阻塞,直到所有線程完成safepoint對應的操作。
- 正在執(zhí)行狀態(tài)切換,或者VM線程狀態(tài)(即執(zhí)行虛擬機線程正在工作的一種狀態(tài))的線程:這種情況下安全點檢查相關的代碼會不斷輪詢該線程,直到該線程因為狀態(tài)切換或者鎖定safepoint checked monitor(安全點監(jiān)視鎖)完成阻塞。
需要注意這一小節(jié),筆者強調的是如何進入安全點(各個線程如何進入安全點),而不是何處插入安全點(插入安全點時機的選擇),讀者在閱讀本文時一定要梳理清楚插入安全時機設置和進入安全點的時機設置的區(qū)別。
二、實踐-基于主線程休眠了解安全點的工作過程
1. 代碼示例
這段代碼比較簡單,兩個子線程異步累加源自類,主線程休眠1s后輸出自己休眠的時長:
AtomicInteger counter = new AtomicInteger(0);
Runnable runnable = () -> {
for (int i = 0; i < 10_0000_0000; i++) {
counter.getAndAdd(1);
}
System.out.println(Thread.currentThread().getName() + "運行結束,counter:" + counter.get());
};
Thread t1 = new Thread(runnable, "t1");
Thread t2 = new Thread(runnable, "t2");
t1.start();
t2.start();
//主線程開始休眠
long begin = System.currentTimeMillis();
System.out.println("主線程開始休眠");
Thread.sleep(1000);
//休眠結束后,等待其他線程進入安全點才開始結束休眠,所以輸出耗時19s
long cost = System.currentTimeMillis() - begin;
System.out.println("主線程結束休眠,耗時:" + cost + "ms");
很多讀者可能認為主線程打印的耗時差不多是1s,但事與愿違,輸出的結果竟然是30s:
主線程開始休眠
t1運行結束,counter:2000000000
t2運行結束,counter:2000000000
主線程結束休眠,耗時:29956ms
由上述我們講解進入,主線程調用native休眠返回后,程序因為某種原因進入全局安全點,而兩個子線程因為是有界循環(huán)未能進入安全點,最終導致主線程長時間等待兩個子線程進入安全而導致休眠打印變長:
2. 基于日志印證執(zhí)行流程
對此我們基于-XX:+SafepointTimeout -XX:SafepointTimeoutDelay=2000參數,查看進入安全點超過2s后,主線程(包括其他未強調的用戶線程)在等待那些線程進入安全點。
很明顯,從輸出結果來看所有線程都在等待兩個子線程進入安全點:
更進一步,我們添加-XX:+PrintSafepointStatistics參數,查看所有線程進入安全點的耗時,可以發(fā)現:
- JVM內部非安全點的線程自旋等待進入安全點時間和主線程休眠時間基本一致。
- 同步所有線程進入安全點時間block和上述休眠結束的耗時基本一致。
由此印證的問題根因:子線程未能及時進入安全點:
最后一個問題,這個觸發(fā)安全點的時機是什么呢?
通過-XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal打印JVM參數可以發(fā)現下面這個參數,查閱資料我們可以知曉該參數GuaranteedSafepointInterval,會默認1s讓程序進入一次全局安全點:
所以我們最終得出根因,假設主線程在程序啟動100ms進入休眠,程序對應的執(zhí)行步驟為:
- 代碼執(zhí)行,JVM啟動,由于GuaranteedSafepointInterval參數配置為1000ms,所以在1s時需要設置一個安全點標識位,讓所有用戶線程更新oopMap。
- 主線程在1100ms時完成了1s的休眠準備退出native的sleep,檢查安全點標識為真因為缺頁異常進入安全點。
- 兩個子線程因為有界循環(huán)沒能進入安全點繼續(xù)循環(huán)。
- 主線程和其他用戶線程等待t1、t2完成循環(huán)。
3. 優(yōu)化思路
基于上述的分析,我們可以得出導致主線程休眠過長的時間的主要原因:
- 由于GuaranteedSafepointInterval定時觸發(fā)安全點,導致主線程休眠結束后進入安全點。
- 主線程進入安全點期間,子線程因為有界循環(huán)而未能進入安全點。
所以,基于原因1我們可以通過-XX:+UnlockDiagnosticVMOptions -XX:GuaranteedSafepointInterval=0強制關閉進入安全點,得到如下效果:
主線程開始休眠
主線程結束休眠,耗時:1014ms
對于原因2,將有界循環(huán)改為long,讓JIT識別到這個大循環(huán)而插入安全點:
三、關于安全點更進一步的理解
1. 關于安全點的調優(yōu)建議
- 高并發(fā)微服務的場景下建議關閉偏向鎖-XX:-UseBiasedLocking。
- 避免有界循環(huán)可以在循環(huán)到一定次數時,設置Thread.sleep(0)輔助進入安全點。
- int長有界循環(huán)改為long,讓JIT為其插入安全點。
- 優(yōu)化有界但是耗時長的循環(huán)代碼。
- 關閉定時進入安全點-XX:+UnlockDiagnosticVMOptions -XX:GuaranteedSafepointInterval=0。
2. JDK11對于安全點的優(yōu)化
考慮到定時進入安全點的非必要性和高并發(fā)場景下的阻塞風險,jdk11默認情況下已經將該參數關閉,對此我們可以鍵入jinfo -flag GuaranteedSafepointInterval <pid>查看。
輸出結果如下,可以看到筆者將項目改為JDK11之后,對應的參數值設置為0,即不定時進入安全點:
3. RocketMQ中對于安全點的優(yōu)化
基于上述的調優(yōu)建議,我們也給出RocketMQ對于安全點的優(yōu)化,以4.8.0版本為例,MappedFile為避免零拷貝循環(huán)寫入操作的耗時,它會定時的調用Thread.sleep(0);讓線程調用native方法從而到達安全點,保證需要時進入STW完成GC:
public void warmMappedFile(FlushDiskType type, int pages) {
long beginTime = System.currentTimeMillis();
ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
int flush = 0;
long time = System.currentTimeMillis();
for (int i = 0, j = 0; i < this.fileSize; i += MappedFile.OS_PAGE_SIZE, j++) {
byteBuffer.put(i, (byte) 0);
//......
// prevent gc
//定期休眠,保證及時進入安全點,不阻塞其他需要進入安全點的線程
if (j % 1000 == 0) {
log.info("j={}, costTime={}", j, System.currentTimeMillis() - time);
time = System.currentTimeMillis();
try {
Thread.sleep(0);
} catch (InterruptedException e) {
log.error("Interrupted", e);
}
}
}
//......
}
后續(xù)版本, 為了保證更加合理的進入安全點,這塊代碼也被直接優(yōu)化為long的長循環(huán),讓JIT自行優(yōu)化,在合適的位置插入安全點保證線程能夠及時進入安全點配合需要STW等工作: