來聊聊 JVM 中安全點(diǎn)的概念
近期在分享關(guān)于synchronized關(guān)鍵字的文章的時(shí)候提到了一個(gè)關(guān)于安全點(diǎn)的概念,有讀者反饋這塊知識(shí)點(diǎn)講的有些潦草,遂以此文簡(jiǎn)單介紹一下JVM中關(guān)于安全點(diǎn)的概念。

一、詳解safepoint基本概念
1. 什么是安全點(diǎn)?為什么需要安全點(diǎn)
在正式講解安全點(diǎn)之前,我們不妨復(fù)習(xí)一下JVM中垃圾回收的基本過程,我們以CMS垃圾回收器為例,其垃圾回收過程在完成GC Roots查找與收集之后就會(huì)按照如下步驟執(zhí)行:
- 初始標(biāo)記
- 并發(fā)回收
- 最終標(biāo)記(重新標(biāo)記)
- 并發(fā)清除
要知道固定可作為GC Roots的節(jié)點(diǎn)主要是:
- 全局引用:例如常量或者靜態(tài)變量。
- 執(zhí)行上下文即棧幀中的變量表。
對(duì)于現(xiàn)代java應(yīng)用而言,光是方法區(qū)就可能有數(shù)百上千兆,所以對(duì)于這些起源的引用也并非一件容易的事情。這也就意味著JVM在進(jìn)行垃圾回收時(shí)并不能通過逐個(gè)掃描檢查來實(shí)現(xiàn)。 就目前主流的JVM來說,針對(duì)根節(jié)點(diǎn)枚舉基本都是采用空間換時(shí)間的策略,也就是使用一組OopMap,全稱為"Object Pointer Map"(對(duì)象指針映射),本質(zhì)上就是一個(gè)位圖索引,它會(huì)通過以下兩個(gè)時(shí)機(jī)完成對(duì)象信息的緩存:
- 類加載完成后,hotSpot就會(huì)基于類的偏移量信息計(jì)算出來并緩存。
- JIT階段也會(huì)在特定的時(shí)機(jī)(這一點(diǎn)后續(xù)會(huì)詳細(xì)說明)計(jì)算出棧或寄存器中的那些位置是引用,并將其緩存。
如此一來,下次進(jìn)行根枚舉時(shí)就可以直接基于OopMap高效完成:

但是java進(jìn)程的運(yùn)行的瞬息萬變的,可能此刻的對(duì)象在下一刻就不可用,下一刻又有新的對(duì)象誕生,這種引用關(guān)系的實(shí)時(shí)變化亦或者說導(dǎo)致OopMap內(nèi)容變化的指令是非常多的,若針對(duì)每一個(gè)指令都設(shè)置對(duì)應(yīng)的oopMap,那么內(nèi)存的開銷是非常高昂的。
所以就有了安全點(diǎn)(safepoint)的概念,這也就是我們上文所提及的特定的位置,基于這個(gè)設(shè)定,用戶的程序僅僅會(huì)在特定的情況下生成oopMap,同理在垃圾回收時(shí),也要求所有線程達(dá)到安全點(diǎn)后才能夠暫停并進(jìn)入STW從而開始進(jìn)行初始標(biāo)記、最終標(biāo)記等操作:

例如下面這段代碼:
Object o=new Object();對(duì)應(yīng)匯編碼如下,可以看到0x00000000031ffb8f的call指令,它指明偏移量40-852處有一個(gè)普通對(duì)象指針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如何讓線程跑到最近的安全點(diǎn)
對(duì)于安全點(diǎn)上的線程中斷策略,大體來說是有兩種:
- 搶占式:當(dāng)需要進(jìn)入安全點(diǎn)時(shí),JVM會(huì)主動(dòng)掛起所有的用戶線程,如果線程未在安全點(diǎn)則等到該線程進(jìn)入安全點(diǎn)進(jìn)入安全點(diǎn)并完成中斷。這種做法最大的缺點(diǎn)就是時(shí)間不可控即很可能存在性能不穩(wěn)定亦或者吞吐量的波動(dòng),所以截至目前還有那款虛擬機(jī)采用搶占式的方式完成線程中斷。
- 主動(dòng)式:這種方式是讓線程去維護(hù)一個(gè)標(biāo)志位,需要進(jìn)入安全點(diǎn)時(shí)修改該變量,用戶線程就會(huì)在合適的時(shí)機(jī)檢查這個(gè)變量值,如果這個(gè)值為真時(shí)就進(jìn)入安全點(diǎn)。
3. 線程什么時(shí)候需要進(jìn)入安全點(diǎn)
除了常見的垃圾回收標(biāo)記觸發(fā)STW使得所有線程需要進(jìn)入安全點(diǎn)以外,對(duì)應(yīng)的進(jìn)入安全點(diǎn)的時(shí)機(jī)還有:
- 使用jstat、jmap、jstack等命令,為保證監(jiān)控堆棧信息的實(shí)時(shí)正確性,所有線程需要STW并進(jìn)入安全點(diǎn)暫停。
- JDK8默認(rèn)情況下定時(shí)進(jìn)入安全點(diǎn),保證一些需要進(jìn)入安全點(diǎn)的操作能夠及時(shí)運(yùn)行。
- JIT編譯代碼優(yōu)化例如:OSR(棧上替換即一種運(yùn)行時(shí)替換棧幀的技術(shù))或者去優(yōu)化即Bailout(將JIT編譯后的代碼回退,解釋器模式),因?yàn)榭赡艽嬖趫?zhí)行指令的變化,線程就需要進(jìn)入安全點(diǎn)。
- java agent需要對(duì)類進(jìn)行增強(qiáng)導(dǎo)致類重新定義,需要修改類的相關(guān)信息,所以需要進(jìn)入安全點(diǎn)。
- 高并發(fā)情況下,鎖升級(jí)機(jī)制會(huì)涉及偏向鎖撤銷,需要進(jìn)入STW檢查每個(gè)線程的使用狀態(tài),所以也需要進(jìn)入安全點(diǎn)。
4. JVM如何保證線程高效進(jìn)入安全點(diǎn)
我們以線程運(yùn)行JIT編譯好的代碼為例,它的設(shè)計(jì)與實(shí)現(xiàn)步驟為:
- JVM初始化一個(gè)異常處理器,專門捕獲對(duì)應(yīng)的page fault缺頁(yè)中斷異常。
- JIT編譯代碼期間,會(huì)基于我們上述的規(guī)則在特定位置插入一條精簡(jiǎn)的指令,作為安全點(diǎn)檢查。
- VM線程通知當(dāng)前線程進(jìn)入安全點(diǎn),將線程內(nèi)部維護(hù)的內(nèi)存頁(yè)即polling page設(shè)置為不可讀。
- 線程執(zhí)行這條機(jī)器碼指令發(fā)現(xiàn)內(nèi)存頁(yè)不可讀,觸發(fā)缺點(diǎn)中斷。
- 異常處理器捕獲這個(gè)異常,線程進(jìn)入安全點(diǎn)。

對(duì)應(yīng)的我們也給出這段精簡(jiǎn)的匯編碼指令,即test %eax,0x160100 ; {poll}這段指令,這段指令本質(zhì)上就是執(zhí)行poll操作檢查安全點(diǎn),嘗試訪問線程內(nèi)存頁(yè)對(duì)應(yīng)地址為0x160100,如果發(fā)現(xiàn)不可訪問則觸發(fā)缺頁(yè)中斷進(jìn)入安全點(diǎn):
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,%esi5. 如何設(shè)置安全點(diǎn)
而這個(gè)輪詢操作的合適的時(shí)機(jī)也就是觸發(fā)安全點(diǎn)的時(shí)機(jī),對(duì)于安全點(diǎn)的選定不能太過于頻繁導(dǎo)致過分增加內(nèi)存的負(fù)荷,如果進(jìn)入安全點(diǎn)的選定太少同樣也會(huì)導(dǎo)致線程無法及時(shí)進(jìn)入安全點(diǎn)而導(dǎo)致未能及時(shí)GC而OOM,所以我們對(duì)于安全點(diǎn)的選定一定要符合能夠讓程序長(zhǎng)時(shí)間的運(yùn)行為標(biāo)準(zhǔn):
無界循環(huán)或者大循環(huán):對(duì)于長(zhǎng)時(shí)間的while(true)或者以long類型為輪詢次數(shù)的for循環(huán),JVM會(huì)插入安全點(diǎn)保證循環(huán)能夠在需要停頓時(shí)及時(shí)停頓:
for (long i = 0; i < 10_0000_0000; i++) {
counter.getAndAdd(1);
//safe point
}
while (true){
counter.getAndAdd(1);
//safe point
}在執(zhí)行方法調(diào)用返回之前:
private boolean function() {
//do something
return true;
//safe point
}可能拋出異常的位置,例如下面這段代碼:
public static void main(String[] args) {
int result = 10 / 0;
}對(duì)應(yīng)的匯編碼如下,可以看到因?yàn)樗銛?shù)可能存在異常,它就在0x00000000033f0695設(shè)置了一個(gè)test安全點(diǎn)檢查:
0x00000000033f066c: mov $0xa,%eax ; 將被除數(shù)10加載到eax寄存器
0x00000000033f0671: mov $0x0,%esi ; 將除數(shù)0加載到esi寄存器(準(zhǔn)備觸發(fā)除零異常)
0x00000000033f0676: cmp $0x80000000,%eax ; 檢查被除數(shù)是否為Integer.MIN_VALUE(-2^31)
0x00000000033f067c: jne 0x00000000033f068d ; 如果不是則跳轉(zhuǎn)到常規(guī)除法流程
0x00000000033f0682: xor %edx,%edx ; 清零edx寄存器(特殊溢出處理路徑)
0x00000000033f0684: cmp $0xffffffff,%esi ; 檢查除數(shù)是否為-1(配合前面對(duì)MIN_VALUE的檢查)
0x00000000033f0687: je 0x00000000033f0690 ; 如果是-1則跳過除法(避免MIN_VALUE/-1溢出)
0x00000000033f068d: cltd ; 將eax符號(hào)位擴(kuò)展到edx(準(zhǔn)備64位被除數(shù))
0x00000000033f068e: idiv %esi ; 執(zhí)行有符號(hào)除法eax/esi(此處會(huì)觸發(fā)除零異常)
; 對(duì)應(yīng)Java代碼:int result = 10 / 0;
; 隱式異常處理分支:dispatches to 0x00000000033f069c
0x00000000033f0690: add $0x30,%rsp ; 調(diào)整棧指針(清理?xiàng)?0x00000000033f0694: pop %rbp ; 恢復(fù)調(diào)用者的基址指針
0x00000000033f0695: test %eax,-0x28b059b(%rip) ; 安全點(diǎn)檢查:{poll_return}
; 檢查線程本地polling page是否可訪問
; 不可訪問則進(jìn)入安全點(diǎn)處理6. 用一次GC解釋不同狀態(tài)的線程如何進(jìn)入safepoint
當(dāng)VM線程(JVM內(nèi)部的一種特殊的系統(tǒng)線程)需要觸發(fā)GC時(shí),它需要所有的線程都進(jìn)入安全點(diǎn),從而實(shí)現(xiàn)STW:
- 線程正在運(yùn)行字節(jié)碼:這種情況也就是常規(guī)的情況,這解釋器會(huì)查看線程是否被設(shè)置為poll armed,如果為真則將其block阻塞。
- 線程運(yùn)行JIT編譯好的代碼:JIT會(huì)在指定位置插入安全點(diǎn)檢查,如果需要進(jìn)入安全點(diǎn),JVM則會(huì)將線程內(nèi)部維護(hù)的內(nèi)存頁(yè)即polling page設(shè)置為不可讀,當(dāng)線程在安全點(diǎn)檢查時(shí)感知到這一點(diǎn)之后,就會(huì)直接將內(nèi)存block。
- 線程正在運(yùn)行native代碼:VM線程不會(huì)等待該線程進(jìn)入阻塞狀態(tài),而是將線程設(shè)置為poll armed,當(dāng)線程執(zhí)行完成native代碼并返回時(shí)看到這個(gè)標(biāo)識(shí),如果還是需要停在safepoint,則會(huì)直接block阻塞。
- 正在阻塞的線程,這種情況下線程就會(huì)一直阻塞,直到所有線程完成safepoint對(duì)應(yīng)的操作。
- 正在執(zhí)行狀態(tài)切換,或者VM線程狀態(tài)(即執(zhí)行虛擬機(jī)線程正在工作的一種狀態(tài))的線程:這種情況下安全點(diǎn)檢查相關(guān)的代碼會(huì)不斷輪詢?cè)摼€程,直到該線程因?yàn)闋顟B(tài)切換或者鎖定safepoint checked monitor(安全點(diǎn)監(jiān)視鎖)完成阻塞。
需要注意這一小節(jié),筆者強(qiáng)調(diào)的是如何進(jìn)入安全點(diǎn)(各個(gè)線程如何進(jìn)入安全點(diǎn)),而不是何處插入安全點(diǎn)(插入安全點(diǎn)時(shí)機(jī)的選擇),讀者在閱讀本文時(shí)一定要梳理清楚插入安全時(shí)機(jī)設(shè)置和進(jìn)入安全點(diǎn)的時(shí)機(jī)設(shè)置的區(qū)別。
二、實(shí)踐-基于主線程休眠了解安全點(diǎn)的工作過程
1. 代碼示例
這段代碼比較簡(jiǎn)單,兩個(gè)子線程異步累加源自類,主線程休眠1s后輸出自己休眠的時(shí)長(zhǎng):
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() + "運(yùn)行結(jié)束,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);
//休眠結(jié)束后,等待其他線程進(jìn)入安全點(diǎn)才開始結(jié)束休眠,所以輸出耗時(shí)19s
long cost = System.currentTimeMillis() - begin;
System.out.println("主線程結(jié)束休眠,耗時(shí):" + cost + "ms");很多讀者可能認(rèn)為主線程打印的耗時(shí)差不多是1s,但事與愿違,輸出的結(jié)果竟然是30s:
主線程開始休眠
t1運(yùn)行結(jié)束,counter:2000000000
t2運(yùn)行結(jié)束,counter:2000000000
主線程結(jié)束休眠,耗時(shí):29956ms由上述我們講解進(jìn)入,主線程調(diào)用native休眠返回后,程序因?yàn)槟撤N原因進(jìn)入全局安全點(diǎn),而兩個(gè)子線程因?yàn)槭怯薪缪h(huán)未能進(jìn)入安全點(diǎn),最終導(dǎo)致主線程長(zhǎng)時(shí)間等待兩個(gè)子線程進(jìn)入安全而導(dǎo)致休眠打印變長(zhǎng):

2. 基于日志印證執(zhí)行流程
對(duì)此我們基于-XX:+SafepointTimeout -XX:SafepointTimeoutDelay=2000參數(shù),查看進(jìn)入安全點(diǎn)超過2s后,主線程(包括其他未強(qiáng)調(diào)的用戶線程)在等待那些線程進(jìn)入安全點(diǎn)。
很明顯,從輸出結(jié)果來看所有線程都在等待兩個(gè)子線程進(jìn)入安全點(diǎn):

更進(jìn)一步,我們添加-XX:+PrintSafepointStatistics參數(shù),查看所有線程進(jìn)入安全點(diǎn)的耗時(shí),可以發(fā)現(xiàn):
- JVM內(nèi)部非安全點(diǎn)的線程自旋等待進(jìn)入安全點(diǎn)時(shí)間和主線程休眠時(shí)間基本一致。
- 同步所有線程進(jìn)入安全點(diǎn)時(shí)間block和上述休眠結(jié)束的耗時(shí)基本一致。
由此印證的問題根因:子線程未能及時(shí)進(jìn)入安全點(diǎn):

最后一個(gè)問題,這個(gè)觸發(fā)安全點(diǎn)的時(shí)機(jī)是什么呢?
通過-XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal打印JVM參數(shù)可以發(fā)現(xiàn)下面這個(gè)參數(shù),查閱資料我們可以知曉該參數(shù)GuaranteedSafepointInterval,會(huì)默認(rèn)1s讓程序進(jìn)入一次全局安全點(diǎn):

所以我們最終得出根因,假設(shè)主線程在程序啟動(dòng)100ms進(jìn)入休眠,程序?qū)?yīng)的執(zhí)行步驟為:
- 代碼執(zhí)行,JVM啟動(dòng),由于GuaranteedSafepointInterval參數(shù)配置為1000ms,所以在1s時(shí)需要設(shè)置一個(gè)安全點(diǎn)標(biāo)識(shí)位,讓所有用戶線程更新oopMap。
- 主線程在1100ms時(shí)完成了1s的休眠準(zhǔn)備退出native的sleep,檢查安全點(diǎn)標(biāo)識(shí)為真因?yàn)槿表?yè)異常進(jìn)入安全點(diǎn)。
- 兩個(gè)子線程因?yàn)橛薪缪h(huán)沒能進(jìn)入安全點(diǎn)繼續(xù)循環(huán)。
- 主線程和其他用戶線程等待t1、t2完成循環(huán)。

3. 優(yōu)化思路
基于上述的分析,我們可以得出導(dǎo)致主線程休眠過長(zhǎng)的時(shí)間的主要原因:
- 由于GuaranteedSafepointInterval定時(shí)觸發(fā)安全點(diǎn),導(dǎo)致主線程休眠結(jié)束后進(jìn)入安全點(diǎn)。
- 主線程進(jìn)入安全點(diǎn)期間,子線程因?yàn)橛薪缪h(huán)而未能進(jìn)入安全點(diǎn)。
所以,基于原因1我們可以通過-XX:+UnlockDiagnosticVMOptions -XX:GuaranteedSafepointInterval=0強(qiáng)制關(guān)閉進(jìn)入安全點(diǎn),得到如下效果:
主線程開始休眠
主線程結(jié)束休眠,耗時(shí):1014ms對(duì)于原因2,將有界循環(huán)改為long,讓JIT識(shí)別到這個(gè)大循環(huán)而插入安全點(diǎn):

三、關(guān)于安全點(diǎn)更進(jìn)一步的理解
1. 關(guān)于安全點(diǎn)的調(diào)優(yōu)建議
- 高并發(fā)微服務(wù)的場(chǎng)景下建議關(guān)閉偏向鎖-XX:-UseBiasedLocking。
- 避免有界循環(huán)可以在循環(huán)到一定次數(shù)時(shí),設(shè)置Thread.sleep(0)輔助進(jìn)入安全點(diǎn)。
- int長(zhǎng)有界循環(huán)改為long,讓JIT為其插入安全點(diǎn)。
- 優(yōu)化有界但是耗時(shí)長(zhǎng)的循環(huán)代碼。
- 關(guān)閉定時(shí)進(jìn)入安全點(diǎn)-XX:+UnlockDiagnosticVMOptions -XX:GuaranteedSafepointInterval=0。
2. JDK11對(duì)于安全點(diǎn)的優(yōu)化
考慮到定時(shí)進(jìn)入安全點(diǎn)的非必要性和高并發(fā)場(chǎng)景下的阻塞風(fēng)險(xiǎn),jdk11默認(rèn)情況下已經(jīng)將該參數(shù)關(guān)閉,對(duì)此我們可以鍵入jinfo -flag GuaranteedSafepointInterval <pid>查看。
輸出結(jié)果如下,可以看到筆者將項(xiàng)目改為JDK11之后,對(duì)應(yīng)的參數(shù)值設(shè)置為0,即不定時(shí)進(jìn)入安全點(diǎn):

3. RocketMQ中對(duì)于安全點(diǎn)的優(yōu)化
基于上述的調(diào)優(yōu)建議,我們也給出RocketMQ對(duì)于安全點(diǎn)的優(yōu)化,以4.8.0版本為例,MappedFile為避免零拷貝循環(huán)寫入操作的耗時(shí),它會(huì)定時(shí)的調(diào)用Thread.sleep(0);讓線程調(diào)用native方法從而到達(dá)安全點(diǎn),保證需要時(shí)進(jìn)入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
//定期休眠,保證及時(shí)進(jìn)入安全點(diǎn),不阻塞其他需要進(jìn)入安全點(diǎn)的線程
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ù)版本, 為了保證更加合理的進(jìn)入安全點(diǎn),這塊代碼也被直接優(yōu)化為long的長(zhǎng)循環(huán),讓JIT自行優(yōu)化,在合適的位置插入安全點(diǎn)保證線程能夠及時(shí)進(jìn)入安全點(diǎn)配合需要STW等工作:




































