JVM CPU Profiler技术原理及源码深度解析
本文介紹了JVM平臺(tái)上CPU Profiler的實(shí)現(xiàn)原理,希望能幫助讀者在使用類似工具的同時(shí)也能清楚其內(nèi)部的技術(shù)實(shí)現(xiàn)。
引言
研發(fā)人員在遇到線上報(bào)警或需要優(yōu)化系統(tǒng)性能時(shí),常常需要分析程序運(yùn)行行為和性能瓶頸。Profiling技術(shù)是一種在應(yīng)用運(yùn)行時(shí)收集程序相關(guān)信息的動(dòng)態(tài)分析手段,常用的JVM Profiler可以從多個(gè)方面對(duì)程序進(jìn)行動(dòng)態(tài)分析,如CPU、Memory、Thread、Classes、GC等,其中CPU Profiling的應(yīng)用最為廣泛。
CPU Profiling經(jīng)常被用于分析代碼的執(zhí)行熱點(diǎn),如“哪個(gè)方法占用CPU的執(zhí)行時(shí)間最長(zhǎng)”、“每個(gè)方法占用CPU的比例是多少”等等,通過(guò)CPU Profiling得到上述相關(guān)信息后,研發(fā)人員就可以輕松針對(duì)熱點(diǎn)瓶頸進(jìn)行分析和性能優(yōu)化,進(jìn)而突破性能瓶頸,大幅提升系統(tǒng)的吞吐量。
CPU Profiler簡(jiǎn)介
社區(qū)實(shí)現(xiàn)的JVM Profiler很多,比如已經(jīng)商用且功能強(qiáng)大的JProfiler,也有免費(fèi)開源的產(chǎn)品,如JVM-Profiler,功能各有所長(zhǎng)。我們?nèi)粘J褂玫腎ntellij IDEA最新版內(nèi)部也集成了一個(gè)簡(jiǎn)單好用的Profiler,詳細(xì)的介紹參見官方Blog。
在用IDEA打開需要診斷的Java項(xiàng)目后,在“Preferences -> Build, Execution, Deployment -> Java Profiler”界面添加一個(gè)“CPU Profiler”,然后回到項(xiàng)目,單擊右上角的“Run with Profiler”啟動(dòng)項(xiàng)目并開始CPU Profiling過(guò)程。一定時(shí)間后(推薦5min),在Profiler界面點(diǎn)擊“Stop Profiling and Show Results”,即可看到Profiling的結(jié)果,包含火焰圖和調(diào)用樹,如下圖所示:
Intellij IDEA - 性能火焰圖?
Intellij IDEA - 調(diào)用堆棧樹火焰圖是根據(jù)調(diào)用棧的樣本集生成的可視化性能分析圖,《如何讀懂火焰圖?》一文對(duì)火焰圖進(jìn)行了不錯(cuò)的講解,大家可以參考一下。簡(jiǎn)而言之,看火焰圖時(shí)我們需要關(guān)注“平頂”,因?yàn)槟抢锞褪俏覀兂绦虻腃PU熱點(diǎn)。調(diào)用樹是另一種可視化分析的手段,與火焰圖一樣,也是根據(jù)同一份樣本集而生成,按需選擇即可。
這里要說(shuō)明一下,因?yàn)槲覀儧](méi)有在項(xiàng)目中引入任何依賴,僅僅是“Run with Profiler”,Profiler就能獲取我們程序運(yùn)行時(shí)的信息。這個(gè)功能其實(shí)是通過(guò)JVM Agent實(shí)現(xiàn)的,為了更好地幫助大家系統(tǒng)性的了解它,我們?cè)谶@里先對(duì)JVM Agent做個(gè)簡(jiǎn)單的介紹。
JVM Agent簡(jiǎn)介
JVM Agent是一個(gè)按一定規(guī)則編寫的特殊程序庫(kù),可以在啟動(dòng)階段通過(guò)命令行參數(shù)傳遞給JVM,作為一個(gè)伴生庫(kù)與目標(biāo)JVM運(yùn)行在同一個(gè)進(jìn)程中。在Agent中可以通過(guò)固定的接口獲取JVM進(jìn)程內(nèi)的相關(guān)信息。Agent既可以是用C/C++/Rust編寫的JVMTI Agent,也可以是用Java編寫的Java Agent。
執(zhí)行Java命令,我們可以看到Agent相關(guān)的命令行參數(shù):
-agentlib:<庫(kù)名>[=<選項(xiàng)>]加載本機(jī)代理庫(kù) <庫(kù)名>, 例如 -agentlib:jdwp另請(qǐng)參閱 -agentlib:jdwp=help-agentpath:<路徑名>[=<選項(xiàng)>]按完整路徑名加載本機(jī)代理庫(kù)-javaagent:<jar 路徑>[=<選項(xiàng)>]加載 Java 編程語(yǔ)言代理, 請(qǐng)參閱 java.lang.instrumentJVMTI Agent
JVMTI(JVM Tool Interface)是JVM提供的一套標(biāo)準(zhǔn)的C/C++編程接口,是實(shí)現(xiàn)Debugger、Profiler、Monitor、Thread Analyser等工具的統(tǒng)一基礎(chǔ),在主流Java虛擬機(jī)中都有實(shí)現(xiàn)。
當(dāng)我們要基于JVMTI實(shí)現(xiàn)一個(gè)Agent時(shí),需要實(shí)現(xiàn)如下入口函數(shù):
// $JAVA_HOME/include/jvmti.h JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved);使用C/C++實(shí)現(xiàn)該函數(shù),并將代碼編譯為動(dòng)態(tài)連接庫(kù)(Linux上是.so),通過(guò)-agentpath參數(shù)將庫(kù)的完整路徑傳遞給Java進(jìn)程,JVM就會(huì)在啟動(dòng)階段的合適時(shí)機(jī)執(zhí)行該函數(shù)。在函數(shù)內(nèi)部,我們可以通過(guò)JavaVM指針參數(shù)拿到JNI和JVMTI的函數(shù)指針表,這樣我們就擁有了與JVM進(jìn)行各種復(fù)雜交互的能力。
更多JVMTI相關(guān)的細(xì)節(jié)可以參考官方文檔。
Java Agent
在很多場(chǎng)景下,我們沒(méi)有必要必須使用C/C++來(lái)開發(fā)JVMTI Agent,因?yàn)槌杀靖咔也灰拙S護(hù)。JVM自身基于JVMTI封裝了一套Java的Instrument API接口,允許使用Java語(yǔ)言開發(fā)Java Agent(只是一個(gè)jar包),大大降低了Agent的開發(fā)成本。社區(qū)開源的產(chǎn)品如Greys、Arthas、JVM-Sandbox、JVM-Profiler等都是純Java編寫的,也是以Java Agent形式來(lái)運(yùn)行。
在Java Agent中,我們需要在jar包的MANIFEST.MF中將Premain-Class指定為一個(gè)入口類,并在該入口類中實(shí)現(xiàn)如下方法:
public static void premain(String args, Instrumentation ins) {// implement }這樣打包出來(lái)的jar就是一個(gè)Java Agent,可以通過(guò)-javaagent參數(shù)將jar傳遞給Java進(jìn)程伴隨啟動(dòng),JVM同樣會(huì)在啟動(dòng)階段的合適時(shí)機(jī)執(zhí)行該方法。
在該方法內(nèi)部,參數(shù)Instrumentation接口提供了Retransform Classes的能力,我們利用該接口就可以對(duì)宿主進(jìn)程的Class進(jìn)行修改,實(shí)現(xiàn)方法耗時(shí)統(tǒng)計(jì)、故障注入、Trace等功能。Instrumentation接口提供的能力較為單一,僅與Class字節(jié)碼操作相關(guān),但由于我們現(xiàn)在已經(jīng)處于宿主進(jìn)程環(huán)境內(nèi),就可以利用JMX直接獲取宿主進(jìn)程的內(nèi)存、線程、鎖等信息。無(wú)論是Instrument API還是JMX,它們內(nèi)部仍是統(tǒng)一基于JVMTI來(lái)實(shí)現(xiàn)。
更多Instrument API相關(guān)的細(xì)節(jié)可以參考官方文檔。
CPU Profiler原理解析
在了解完P(guān)rofiler如何以Agent的形式執(zhí)行后,我們可以開始嘗試構(gòu)造一個(gè)簡(jiǎn)單的CPU Profiler。但在此之前,還有必要了解下CPU Profiling技術(shù)的兩種實(shí)現(xiàn)方式及其區(qū)別。
Sampling vs Instrumentation
使用過(guò)JProfiler的同學(xué)應(yīng)該都知道,JProfiler的CPU Profiling功能提供了兩種方式選項(xiàng):?Sampling和Instrumentation,它們也是實(shí)現(xiàn)CPU Profiler的兩種手段。
Sampling方式顧名思義,基于對(duì)StackTrace的“采樣”進(jìn)行實(shí)現(xiàn),核心原理如下:
Instrumentation則是利用Instrument API,對(duì)所有必要的Class進(jìn)行字節(jié)碼增強(qiáng),在進(jìn)入每個(gè)方法前進(jìn)行埋點(diǎn),方法執(zhí)行結(jié)束后統(tǒng)計(jì)本次方法執(zhí)行耗時(shí),最終進(jìn)行匯總。二者都能得到想要的結(jié)果,那么它們有什么區(qū)別呢?或者說(shuō),孰優(yōu)孰劣?
Instrumentation方式對(duì)幾乎所有方法添加了額外的AOP邏輯,這會(huì)導(dǎo)致對(duì)線上服務(wù)造成巨額的性能影響,但其優(yōu)勢(shì)是:絕對(duì)精準(zhǔn)的方法調(diào)用次數(shù)、調(diào)用時(shí)間統(tǒng)計(jì)。
Sampling方式基于無(wú)侵入的額外線程對(duì)所有線程的調(diào)用棧快照進(jìn)行固定頻率抽樣,相對(duì)前者來(lái)說(shuō)它的性能開銷很低。但由于它基于“采樣”的模式,以及JVM固有的只能在安全點(diǎn)(Safe Point)進(jìn)行采樣的“缺陷”,會(huì)導(dǎo)致統(tǒng)計(jì)結(jié)果存在一定的偏差。譬如說(shuō):某些方法執(zhí)行時(shí)間極短,但執(zhí)行頻率很高,真實(shí)占用了大量的CPU Time,但Sampling Profiler的采樣周期不能無(wú)限調(diào)小,這會(huì)導(dǎo)致性能開銷驟增,所以會(huì)導(dǎo)致大量的樣本調(diào)用棧中并不存在剛才提到的”高頻小方法“,進(jìn)而導(dǎo)致最終結(jié)果無(wú)法反映真實(shí)的CPU熱點(diǎn)。更多Sampling相關(guān)的問(wèn)題可以參考《Why (Most) Sampling Java Profilers Are Fucking Terrible》。
具體到“孰優(yōu)孰劣”的問(wèn)題層面,這兩種實(shí)現(xiàn)技術(shù)并沒(méi)有非常明顯的高下之判,只有在分場(chǎng)景討論下才有意義。Sampling由于低開銷的特性,更適合用在CPU密集型的應(yīng)用中,以及不可接受大量性能開銷的線上服務(wù)中。而Instrumentation則更適合用在I/O密集的應(yīng)用中、對(duì)性能開銷不敏感以及確實(shí)需要精確統(tǒng)計(jì)的場(chǎng)景中。社區(qū)的Profiler更多的是基于Sampling來(lái)實(shí)現(xiàn),本文也是基于Sampling來(lái)進(jìn)行講解。
基于Java Agent + JMX實(shí)現(xiàn)
一個(gè)最簡(jiǎn)單的Sampling CPU Profiler可以用Java Agent + JMX方式來(lái)實(shí)現(xiàn)。以Java Agent為入口,進(jìn)入目標(biāo)JVM進(jìn)程后開啟一個(gè)ScheduledExecutorService,定時(shí)利用JMX的threadMXBean.dumpAllThreads()來(lái)導(dǎo)出所有線程的StackTrace,最終匯總并導(dǎo)出即可。
Uber的JVM-Profiler實(shí)現(xiàn)原理也是如此,關(guān)鍵部分代碼如下:
// com/uber/profiling/profilers/StacktraceCollectorProfiler.java /** StacktraceCollectorProfiler等同于文中所述CpuProfiler,僅命名偏好不同而已* jvm-profiler的CpuProfiler指代的是CpuLoad指標(biāo)的Profiler*/ // 實(shí)現(xiàn)了Profiler接口,外部由統(tǒng)一的ScheduledExecutorService對(duì)所有Profiler定時(shí)執(zhí)行 @Override public void profile() {ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);// ...for (ThreadInfo threadInfo : threadInfos) {String threadName = threadInfo.getThreadName();// ...StackTraceElement[] stackTraceElements = threadInfo.getStackTrace();// ...for (int i = stackTraceElements.length - 1; i >= 0; i--) {StackTraceElement stackTraceElement = stackTraceElements[i];// ...}// ...} }Uber提供的定時(shí)器默認(rèn)Interval是100ms,對(duì)于CPU Profiler來(lái)說(shuō),這略顯粗糙。但由于dumpAllThreads()的執(zhí)行開銷不容小覷,Interval不宜設(shè)置的過(guò)小,所以該方法的CPU Profiling結(jié)果會(huì)存在不小的誤差。
JVM-Profiler的優(yōu)點(diǎn)在于支持多種指標(biāo)的Profiling(StackTrace、CPUBusy、Memory、I/O、Method),且支持將Profiling結(jié)果通過(guò)Kafka上報(bào)回中心Server進(jìn)行分析,也即支持集群診斷。
基于JVMTI + GetStackTrace實(shí)現(xiàn)
使用Java實(shí)現(xiàn)Profiler相對(duì)較簡(jiǎn)單,但也存在一些問(wèn)題,譬如說(shuō)Java Agent代碼與業(yè)務(wù)代碼共享AppClassLoader,被JVM直接加載的agent.jar如果引入了第三方依賴,可能會(huì)對(duì)業(yè)務(wù)Class造成污染。截止發(fā)稿時(shí),JVM-Profiler都存在這個(gè)問(wèn)題,它引入了Kafka-Client、http-Client、Jackson等組件,如果與業(yè)務(wù)代碼中的組件版本發(fā)生沖突,可能會(huì)引發(fā)未知錯(cuò)誤。Greys/Arthas/JVM-Sandbox的解決方式是分離入口與核心代碼,使用定制的ClassLoader加載核心代碼,避免影響業(yè)務(wù)代碼。
在更底層的C/C++層面,我們可以直接對(duì)接JVMTI接口,使用原生C API對(duì)JVM進(jìn)行操作,功能更豐富更強(qiáng)大,但開發(fā)效率偏低。基于上節(jié)同樣的原理開發(fā)CPU Profiler,使用JVMTI需要進(jìn)行如下這些步驟:
1. 編寫Agent_OnLoad(),在入口通過(guò)JNI的JavaVM*指針的GetEnv()函數(shù)拿到JVMTI的jvmtiEnv指針:
// agent.c JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved) {jvmtiEnv *jvmti;(*vm)->GetEnv((void **)&jvmti, JVMTI_VERSION_1_0);// ...return JNI_OK; }2. 開啟一個(gè)線程定時(shí)循環(huán),定時(shí)使用jvmtiEnv指針配合調(diào)用如下幾個(gè)JVMTI函數(shù):
// 獲取所有線程的jthread jvmtiError GetAllThreads(jvmtiEnv *env, jint *threads_count_ptr, jthread **threads_ptr); // 根據(jù)jthread獲取該線程信息(name、daemon、priority...) jvmtiError GetThreadInfo(jvmtiEnv *env, jthread thread, jvmtiThreadInfo* info_ptr); // 根據(jù)jthread獲取該線程調(diào)用棧 jvmtiError GetStackTrace(jvmtiEnv *env,jthread thread,jint start_depth,jint max_frame_count,jvmtiFrameInfo *frame_buffer,jint *count_ptr);主邏輯大致是:首先調(diào)用GetAllThreads()獲取所有線程的“句柄”jthread,然后遍歷根據(jù)jthread調(diào)用GetThreadInfo()獲取線程信息,按線程名過(guò)濾掉不需要的線程后,繼續(xù)遍歷根據(jù)jthread調(diào)用GetStackTrace()獲取線程的調(diào)用棧。
3. 在Buffer中保存每一次的采樣結(jié)果,最終生成必要的統(tǒng)計(jì)數(shù)據(jù)即可。
按如上步驟即可實(shí)現(xiàn)基于JVMTI的CPU Profiler。但需要說(shuō)明的是,即便是基于原生JVMTI接口使用GetStackTrace()的方式獲取調(diào)用棧,也存在與JMX相同的問(wèn)題——只能在安全點(diǎn)(Safe Point)進(jìn)行采樣。
SafePoint Bias問(wèn)題
基于Sampling的CPU Profiler通過(guò)采集程序在不同時(shí)間點(diǎn)的調(diào)用棧樣本來(lái)近似地推算出熱點(diǎn)方法,因此,從理論上來(lái)講Sampling CPU Profiler必須遵循以下兩個(gè)原則:
如果只能在安全點(diǎn)采樣,就違背了第二條原則。因?yàn)?strong>我們只能采集到位于安全點(diǎn)時(shí)刻的調(diào)用??煺?#xff0c;意味著某些代碼可能永遠(yuǎn)沒(méi)有機(jī)會(huì)被采樣,即使它真實(shí)耗費(fèi)了大量的CPU執(zhí)行時(shí)間,這種現(xiàn)象被稱為“SafePoint Bias”。
上文我們提到,基于JMX與基于JVMTI的Profiler實(shí)現(xiàn)都存在SafePoint Bias,但一個(gè)值得了解的細(xì)節(jié)是:單獨(dú)來(lái)說(shuō),JVMTI的GetStackTrace()函數(shù)并不需要在Caller的安全點(diǎn)執(zhí)行,但當(dāng)調(diào)用GetStackTrace()獲取其他線程的調(diào)用棧時(shí),必須等待,直到目標(biāo)線程進(jìn)入安全點(diǎn);而且,GetStackTrace()僅能通過(guò)單獨(dú)的線程同步定時(shí)調(diào)用,不能在UNIX信號(hào)處理器的Handler中被異步調(diào)用。綜合來(lái)說(shuō),GetStackTrace()存在與JMX一樣的SafePoint Bias。更多安全點(diǎn)相關(guān)的知識(shí)可以參考《Safepoints: Meaning, Side Effects and Overheads》。
那么,如何避免SafePoint Bias?社區(qū)提供了一種Hack思路——AsyncGetCallTrace。
基于JVMTI + AsyncGetCallTrace實(shí)現(xiàn)
如上節(jié)所述,假如我們擁有一個(gè)函數(shù)可以獲取當(dāng)前線程的調(diào)用棧且不受安全點(diǎn)干擾,另外它還支持在UNIX信號(hào)處理器中被異步調(diào)用,那么我們只需注冊(cè)一個(gè)UNIX信號(hào)處理器,在Handler中調(diào)用該函數(shù)獲取當(dāng)前線程的調(diào)用棧即可。由于UNIX信號(hào)會(huì)被發(fā)送給進(jìn)程的隨機(jī)一線程進(jìn)行處理,因此最終信號(hào)會(huì)均勻分布在所有線程上,也就均勻獲取了所有線程的調(diào)用棧樣本。
OracleJDK/OpenJDK內(nèi)部提供了這么一個(gè)函數(shù)——AsyncGetCallTrace,它的原型如下:
// 棧幀 typedef struct {jint lineno;jmethodID method_id; } AGCT_CallFrame; // 調(diào)用棧 typedef struct {JNIEnv *env;jint num_frames;AGCT_CallFrame *frames; } AGCT_CallTrace; // 根據(jù)ucontext將調(diào)用棧填充進(jìn)trace指針 void AsyncGetCallTrace(AGCT_CallTrace *trace, jint depth, void *ucontext);通過(guò)原型可以看到,該函數(shù)的使用方式非常簡(jiǎn)潔,直接通過(guò)ucontext就能獲取到完整的Java調(diào)用棧。
顧名思義,AsyncGetCallTrace是“async”的,不受安全點(diǎn)影響,這樣的話采樣就可能發(fā)生在任何時(shí)間,包括Native代碼執(zhí)行期間、GC期間等,在這時(shí)我們是無(wú)法獲取Java調(diào)用棧的,AGCT_CallTrace的num_frames字段正常情況下標(biāo)識(shí)了獲取到的調(diào)用棧深度,但在如前所述的異常情況下它就表示為負(fù)數(shù),最常見的-2代表此刻正在GC。
由于AsyncGetCallTrace非標(biāo)準(zhǔn)JVMTI函數(shù),因此我們無(wú)法在jvmti.h中找到該函數(shù)聲明,且由于其目標(biāo)文件也早已鏈接進(jìn)JVM二進(jìn)制文件中,所以無(wú)法通過(guò)簡(jiǎn)單的聲明來(lái)獲取該函數(shù)的地址,這需要通過(guò)一些Trick方式來(lái)解決。簡(jiǎn)單說(shuō),Agent最終是作為動(dòng)態(tài)鏈接庫(kù)加載到目標(biāo)JVM進(jìn)程的地址空間中,因此可以在Agent_OnLoad內(nèi)通過(guò)glibc提供的dlsym()函數(shù)拿到當(dāng)前地址空間(即目標(biāo)JVM進(jìn)程地址空間)名為“AsyncGetCallTrace”的符號(hào)地址。這樣就拿到了該函數(shù)的指針,按照上述原型進(jìn)行類型轉(zhuǎn)換后,就可以正常調(diào)用了。
通過(guò)AsyncGetCallTrace實(shí)現(xiàn)CPU Profiler的大致流程:
1. 編寫Agent_OnLoad(),在入口拿到j(luò)vmtiEnv和AsyncGetCallTrace指針,獲取AsyncGetCallTrace方式如下:
typedef void (*AsyncGetCallTrace)(AGCT_CallTrace *traces, jint depth, void *ucontext); // ... AsyncGetCallTrace agct_ptr = (AsyncGetCallTrace)dlsym(RTLD_DEFAULT, "AsyncGetCallTrace"); if (agct_ptr == NULL) {void *libjvm = dlopen("libjvm.so", RTLD_NOW);if (!libjvm) {// 處理dlerror()...}agct_ptr = (AsyncGetCallTrace)dlsym(libjvm, "AsyncGetCallTrace"); }2. 在OnLoad階段,我們還需要做一件事,即注冊(cè)O(shè)nClassLoad和OnClassPrepare這兩個(gè)Hook,原因是jmethodID是延遲分配的,使用AGCT獲取Traces依賴預(yù)先分配好的數(shù)據(jù)。我們?cè)贠nClassPrepare的CallBack中嘗試獲取該Class的所有Methods,這樣就使JVMTI提前分配了所有方法的jmethodID,如下所示:
void JNICALL OnClassLoad(jvmtiEnv *jvmti, JNIEnv* jni, jthread thread, jclass klass) {} void JNICALL OnClassPrepare(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, jclass klass) {jint method_count;jmethodID *methods;jvmti->GetClassMethods(klass, &method_count, &methods);delete [] methods; } // ... jvmtiEventCallbacks callbacks = {0}; callbacks.ClassLoad = OnClassLoad; callbacks.ClassPrepare = OnClassPrepare; jvmti->SetEventCallbacks(&callbacks, sizeof(callbacks)); jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_CLASS_LOAD, NULL); jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_CLASS_PREPARE, NULL);3. 利用SIGPROF信號(hào)來(lái)進(jìn)行定時(shí)采樣:
// 這里信號(hào)handler傳進(jìn)來(lái)的的ucontext即AsyncGetCallTrace需要的ucontext void signal_handler(int signo, siginfo_t *siginfo, void *ucontext) {// 使用AsyncCallTrace進(jìn)行采樣,注意處理num_frames為負(fù)的異常情況 } // ... // 注冊(cè)SIGPROF信號(hào)的handler struct sigaction sa; sigemptyset(&sa.sa_mask); sa.sa_sigaction = signal_handler; sa.sa_flags = SA_RESTART | SA_SIGINFO; sigaction(SIGPROF, &sa, NULL); // 定時(shí)產(chǎn)生SIGPROF信號(hào) // interval是nanoseconds表示的采樣間隔,AsyncGetCallTrace相對(duì)于同步采樣來(lái)說(shuō)可以適當(dāng)高頻一些 long sec = interval / 1000000000; long usec = (interval % 1000000000) / 1000; struct itimerval tv = {{sec, usec}, {sec, usec}}; setitimer(ITIMER_PROF, &tv, NULL);4.在Buffer中保存每一次的采樣結(jié)果,最終生成必要的統(tǒng)計(jì)數(shù)據(jù)即可。
按如上步驟即可實(shí)現(xiàn)基于AsyncGetCallTrace的CPU Profiler,這是社區(qū)中目前性能開銷最低、相對(duì)效率最高的CPU Profiler實(shí)現(xiàn)方式,在Linux環(huán)境下結(jié)合perf_events還能做到同時(shí)采樣Java棧與Native棧,也就能同時(shí)分析Native代碼中存在的性能熱點(diǎn)。該方式的典型開源實(shí)現(xiàn)有Async-Profiler和Honest-Profiler,Async-Profiler實(shí)現(xiàn)質(zhì)量較高,感興趣的話建議大家閱讀參考源碼。有趣的是,IntelliJ IDEA內(nèi)置的Java Profiler,其實(shí)就是Async-Profiler的包裝。更多關(guān)于AsyncGetCallTrace的內(nèi)容,大家可以參考《The Pros and Cons of AsyncGetCallTrace Profilers》。
生成性能火焰圖
現(xiàn)在我們擁有了采樣調(diào)用棧的能力,但是調(diào)用棧樣本集是以二維數(shù)組的數(shù)據(jù)結(jié)構(gòu)形式存在于內(nèi)存中的,如何將其轉(zhuǎn)換為可視化的火焰圖呢?
火焰圖通常是一個(gè)svg文件,部分優(yōu)秀項(xiàng)目可以根據(jù)文本文件自動(dòng)生成火焰圖文件,僅對(duì)文本文件的格式有一定要求。FlameGraph項(xiàng)目的核心只是一個(gè)Perl腳本,可以根據(jù)我們提供的調(diào)用棧文本生成相應(yīng)的火焰圖svg文件。調(diào)用棧的文本格式相當(dāng)簡(jiǎn)單,如下所示:
base_func;func1;func2;func3 10 base_func;funca;funcb 15將我們采樣到的調(diào)用棧樣本集進(jìn)行整合后,需輸出如上所示的文本格式。每一行代表一“類“調(diào)用棧,空格左邊是調(diào)用棧的方法名排列,以分號(hào)分割,左棧底右棧頂,空格右邊是該樣本出現(xiàn)的次數(shù)。
將樣本文件交給flamegraph.pl腳本執(zhí)行,就能輸出相應(yīng)的火焰圖了:
$ flamegraph.pl stacktraces.txt > stacktraces.svg效果如下圖所示:
通過(guò)flamegraph.pl生成的火焰圖HotSpot的Dynamic Attach機(jī)制解析
到目前為止,我們已經(jīng)了解了CPU Profiler完整的工作原理,然而使用過(guò)JProfiler/Arthas的同學(xué)可能會(huì)有疑問(wèn),很多情況下可以直接對(duì)線上運(yùn)行中的服務(wù)進(jìn)行Profling,并不需要在Java進(jìn)程的啟動(dòng)參數(shù)添加Agent參數(shù),這是通過(guò)什么手段做到的?答案是Dynamic Attach。
JDK在1.6以后提供了Attach API,允許向運(yùn)行中的JVM進(jìn)程添加Agent,這項(xiàng)手段被廣泛使用在各種Profiler和字節(jié)碼增強(qiáng)工具中,其官方簡(jiǎn)介如下:
This is a Sun extension that allows a tool to 'attach' to another process running Java code and launch a JVM TI agent or a java.lang.instrument agent in that process.
總的來(lái)說(shuō),Dynamic Attach是HotSpot提供的一種特殊能力,它允許一個(gè)進(jìn)程向另一個(gè)運(yùn)行中的JVM進(jìn)程發(fā)送一些命令并執(zhí)行,命令并不限于加載Agent,還包括Dump內(nèi)存、Dump線程等等。
通過(guò)sun.tools進(jìn)行Attach
Attach雖然是HotSpot提供的能力,但JDK在Java層面也對(duì)其做了封裝。
前文已經(jīng)提到,對(duì)于Java Agent來(lái)說(shuō),PreMain方法在Agent作為啟動(dòng)參數(shù)運(yùn)行的時(shí)候執(zhí)行,其實(shí)我們還可以額外實(shí)現(xiàn)一個(gè)AgentMain方法,并在MANIFEST.MF中將Agent-Class指定為該Class:
public static void agentmain(String args, Instrumentation ins) {// implement }這樣打包出來(lái)的jar,既可以作為-javaagent參數(shù)啟動(dòng),也可以被Attach到運(yùn)行中的目標(biāo)JVM進(jìn)程。JDK已經(jīng)封裝了簡(jiǎn)單的API讓我們直接Attach一個(gè)Java Agent,下面以Arthas中的代碼進(jìn)行演示:
// com/taobao/arthas/core/Arthas.java import com.sun.tools.attach.VirtualMachine; import com.sun.tools.attach.VirtualMachineDescriptor; // ... private void attachAgent(Configure configure) throws Exception {VirtualMachineDescriptor virtualMachineDescriptor = null;// 拿到所有JVM進(jìn)程,找出目標(biāo)進(jìn)程for (VirtualMachineDescriptor descriptor : VirtualMachine.list()) {String pid = descriptor.id();if (pid.equals(Integer.toString(configure.getJavaPid()))) {virtualMachineDescriptor = descriptor;}}VirtualMachine virtualMachine = null;try {// 針對(duì)某個(gè)JVM進(jìn)程調(diào)用VirtualMachine.attach()方法,拿到VirtualMachine實(shí)例if (null == virtualMachineDescriptor) {virtualMachine = VirtualMachine.attach("" + configure.getJavaPid());} else {virtualMachine = VirtualMachine.attach(virtualMachineDescriptor);}// ...// 調(diào)用VirtualMachine#loadAgent(),將arthasAgentPath指定的jar attach到目標(biāo)JVM進(jìn)程中// 第二個(gè)參數(shù)為attach參數(shù),即agentmain的首個(gè)String參數(shù)argsvirtualMachine.loadAgent(arthasAgentPath, configure.getArthasCore() + ";" + configure.toString());} finally {if (null != virtualMachine) {// 調(diào)用VirtualMachine#detach()釋放virtualMachine.detach();}} }直接對(duì)HotSpot進(jìn)行Attach
sun.tools封裝的API足夠簡(jiǎn)單易用,但只能使用Java編寫,也只能用在Java Agent上,因此有些時(shí)候我們必須手工對(duì)JVM進(jìn)程直接進(jìn)行Attach。對(duì)于JVMTI,除了Agent_OnLoad()之外,我們還需實(shí)現(xiàn)一個(gè)Agent_OnAttach()函數(shù),當(dāng)將JVMTI Agent Attach到目標(biāo)進(jìn)程時(shí),從該函數(shù)開始執(zhí)行:
// $JAVA_HOME/include/jvmti.h JNIEXPORT jint JNICALL Agent_OnAttach(JavaVM *vm, char *options, void *reserved);下面我們以Async-Profiler中的jattach源碼為線索,探究一下如何利用Attach機(jī)制給運(yùn)行中的JVM進(jìn)程發(fā)送命令。jattach是Async-Profiler提供的一個(gè)Driver,使用方式比較直觀:
Usage:jattach <pid> <cmd> [args ...] Args:<pid> 目標(biāo)JVM進(jìn)程的進(jìn)程ID<cmd> 要執(zhí)行的命令<args> 命令參數(shù)使用方式如:
$ jattach 1234 load /absolute/path/to/agent/libagent.so true執(zhí)行上述命令,libagent.so就被加載到ID為1234的JVM進(jìn)程中并開始執(zhí)行Agent_OnAttach函數(shù)了。有一點(diǎn)需要注意,執(zhí)行Attach的進(jìn)程euid及egid,與被Attach的目標(biāo)JVM進(jìn)程必須相同。接下來(lái)開始分析jattach源碼。
如下所示的Main函數(shù)描述了一次Attach的整體流程:
// async-profiler/src/jattach/jattach.c int main(int argc, char** argv) {// 解析命令行參數(shù)// 檢查euid與egid// ...if (!check_socket(nspid) && !start_attach_mechanism(pid, nspid)) {perror("Could not start attach mechanism");return 1;}int fd = connect_socket(nspid);if (fd == -1) {perror("Could not connect to socket");return 1;}printf("Connected to remote JVM\n");if (!write_command(fd, argc - 2, argv + 2)) {perror("Error writing to socket");close(fd);return 1;}printf("Response code = ");fflush(stdout);int result = read_response(fd);close(fd);return result; }忽略掉命令行參數(shù)解析與檢查euid和egid的過(guò)程。jattach首先調(diào)用了check_socket函數(shù)進(jìn)行了“socket檢查?”,check_socket源碼如下:
// async-profiler/src/jattach/jattach.c // Check if remote JVM has already opened socket for Dynamic Attach static int check_socket(int pid) {char path[MAX_PATH];snprintf(path, MAX_PATH, "%s/.java_pid%d", get_temp_directory(), pid); // get_temp_directory()在Linux下固定返回"/tmp"struct stat stats;return stat(path, &stats) == 0 && S_ISSOCK(stats.st_mode); }我們知道,UNIX操作系統(tǒng)提供了一種基于文件的Socket接口,稱為“UNIX Socket”(一種常用的進(jìn)程間通信方式)。在該函數(shù)中使用S_ISSOCK宏來(lái)判斷該文件是否被綁定到了UNIX Socket,如此看來(lái),“/tmp/.java_pid<pid>”文件很有可能就是外部進(jìn)程與JVM進(jìn)程間通信的橋梁。
查閱官方文檔,得到如下描述:
The attach listener thread then communicates with the source JVM in an OS dependent manner:
- On Solaris, the Doors IPC mechanism is used. The door is attached to a file in the file system so that clients can access it.
- On Linux, a Unix domain socket is used. This socket is bound to a file in the filesystem so that clients can access it.
- On Windows, the created thread is given the name of a pipe which is served by the client. The result of the operations are written to this pipe by the target JVM.
證明了我們的猜想是正確的。目前為止check_socket函數(shù)的作用很容易理解了:判斷外部進(jìn)程與目標(biāo)JVM進(jìn)程之間是否已經(jīng)建立了UNIX Socket連接。
回到Main函數(shù),在使用check_socket確定連接尚未建立后,緊接著調(diào)用start_attach_mechanism函數(shù),函數(shù)名很直觀地描述了它的作用,源碼如下:
// async-profiler/src/jattach/jattach.c // Force remote JVM to start Attach listener. // HotSpot will start Attach listener in response to SIGQUIT if it sees .attach_pid file static int start_attach_mechanism(int pid, int nspid) {char path[MAX_PATH];snprintf(path, MAX_PATH, "/proc/%d/cwd/.attach_pid%d", nspid, nspid);int fd = creat(path, 0660);if (fd == -1 || (close(fd) == 0 && !check_file_owner(path))) {// Failed to create attach trigger in current directory. Retry in /tmpsnprintf(path, MAX_PATH, "%s/.attach_pid%d", get_temp_directory(), nspid);fd = creat(path, 0660);if (fd == -1) {return 0;}close(fd);}// We have to still use the host namespace pid here for the kill() callkill(pid, SIGQUIT);// Start with 20 ms sleep and increment delay each iterationstruct timespec ts = {0, 20000000};int result;do {nanosleep(&ts, NULL);result = check_socket(nspid);} while (!result && (ts.tv_nsec += 20000000) < 300000000);unlink(path);return result; }start_attach_mechanism函數(shù)首先創(chuàng)建了一個(gè)名為“/tmp/.attach_pid<pid>”的空文件,然后向目標(biāo)JVM進(jìn)程發(fā)送了一個(gè)SIGQUIT信號(hào),這個(gè)信號(hào)似乎觸發(fā)了JVM的某種機(jī)制?緊接著,start_attach_mechanism函數(shù)開始陷入了一種等待,每20ms調(diào)用一次check_socket函數(shù)檢查連接是否被建立,如果等了300ms還沒(méi)有成功就放棄。函數(shù)的最后調(diào)用Unlink刪掉.attach_pid文件并返回。
如此看來(lái),HotSpot似乎提供了一種特殊的機(jī)制,只要給它發(fā)送一個(gè)SIGQUIT信號(hào),并預(yù)先準(zhǔn)備好.attach_pid文件,HotSpot會(huì)主動(dòng)創(chuàng)建一個(gè)地址為“/tmp/.java_pid”的UNIX Socket,接下來(lái)主動(dòng)Connect這個(gè)地址即可建立連接執(zhí)行命令。
查閱文檔,得到如下描述:
Dynamic attach has an attach listener thread in the target JVM. This is a thread that is started when the first attach request occurs. On Linux and Solaris, the client creates a file named .attach_pid(pid) and sends a SIGQUIT to the target JVM process. The existence of this file causes the SIGQUIT handler in HotSpot to start the attach listener thread. On Windows, the client uses the Win32 CreateRemoteThread function to create a new thread in the target process.
這樣一來(lái)就很明確了,在Linux上我們只需創(chuàng)建一個(gè)“/tmp/.attach_pid”文件,并向目標(biāo)JVM進(jìn)程發(fā)送一個(gè)SIGQUIT信號(hào),HotSpot就會(huì)開始監(jiān)聽“/tmp/.java_pid”地址上的UNIX Socket,接收并執(zhí)行相關(guān)Attach的命令。至于為什么一定要?jiǎng)?chuàng)建.attach_pid文件才可以觸發(fā)Attach Listener的創(chuàng)建,經(jīng)查閱資料,我們得到了兩種說(shuō)法:一是JVM不止接收從外部Attach進(jìn)程發(fā)送的SIGQUIT信號(hào),必須配合外部進(jìn)程創(chuàng)建的外部文件才能確定這是一次Attach請(qǐng)求;二是為了安全。
繼續(xù)看jattach的源碼,果不其然,它調(diào)用了connect_socket函數(shù)對(duì)“/tmp/.java_pid”進(jìn)行連接,connect_socket源碼如下:
// async-profiler/src/jattach/jattach.c // Connect to UNIX domain socket created by JVM for Dynamic Attach static int connect_socket(int pid) {int fd = socket(PF_UNIX, SOCK_STREAM, 0);if (fd == -1) {return -1;}struct sockaddr_un addr;addr.sun_family = AF_UNIX;snprintf(addr.sun_path, sizeof(addr.sun_path), "%s/.java_pid%d", get_temp_directory(), pid);if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {close(fd);return -1;}return fd; }一個(gè)很普通的Socket創(chuàng)建函數(shù),返回Socket文件描述符。
回到Main函數(shù),主流程緊接著調(diào)用write_command函數(shù)向該Socket寫入了從命令行傳進(jìn)來(lái)的參數(shù),并且調(diào)用read_response函數(shù)接收從目標(biāo)JVM進(jìn)程返回的數(shù)據(jù)。兩個(gè)很常見的Socket讀寫函數(shù),源碼如下:
// async-profiler/src/jattach/jattach.c // Send command with arguments to socket static int write_command(int fd, int argc, char** argv) {// Protocol versionif (write(fd, "1", 2) <= 0) {return 0;}int i;for (i = 0; i < 4; i++) {const char* arg = i < argc ? argv[i] : "";if (write(fd, arg, strlen(arg) + 1) <= 0) {return 0;}}return 1; } // Mirror response from remote JVM to stdout static int read_response(int fd) {char buf[8192];ssize_t bytes = read(fd, buf, sizeof(buf) - 1);if (bytes <= 0) {perror("Error reading response");return 1;}// First line of response is the command result codebuf[bytes] = 0;int result = atoi(buf);do {fwrite(buf, 1, bytes, stdout);bytes = read(fd, buf, sizeof(buf));} while (bytes > 0);return result; }瀏覽write_command函數(shù)就可知外部進(jìn)程與目標(biāo)JVM進(jìn)程之間發(fā)送的數(shù)據(jù)格式相當(dāng)簡(jiǎn)單,基本如下所示:
<PROTOCOL VERSION>\0<COMMAND>\0<ARG1>\0<ARG2>\0<ARG3>\0以先前我們使用的Load命令為例,發(fā)送給HotSpot時(shí)格式如下:1\0load\0/absolute/path/to/agent/libagent.so\0true\0\0至此,我們已經(jīng)了解了如何手工對(duì)JVM進(jìn)程直接進(jìn)行Attach。
Attach補(bǔ)充介紹
Load命令僅僅是HotSpot所支持的諸多命令中的一種,用于動(dòng)態(tài)加載基于JVMTI的Agent,完整的命令表如下所示:
static AttachOperationFunctionInfo funcs[] = {{ "agentProperties", get_agent_properties },{ "datadump", data_dump },{ "dumpheap", dump_heap },{ "load", JvmtiExport::load_agent_library },{ "properties", get_system_properties },{ "threaddump", thread_dump },{ "inspectheap", heap_inspection },{ "setflag", set_flag },{ "printflag", print_flag },{ "jcmd", jcmd },{ NULL, NULL } };讀者可以嘗試下threaddump命令,然后對(duì)相同的進(jìn)程進(jìn)行jstack,對(duì)比觀察輸出,其實(shí)是完全相同的,其它命令大家可以自行進(jìn)行探索。
總結(jié)
總的來(lái)說(shuō),善用各類Profiler是提升性能優(yōu)化效率的一把利器,了解Profiler本身的實(shí)現(xiàn)原理更能幫助我們避免對(duì)工具的各種誤用。CPU Profiler所依賴的Attach、JVMTI、Instrumentation、JMX等皆是JVM平臺(tái)比較通用的技術(shù),在此基礎(chǔ)上,我們?nèi)?shí)現(xiàn)Memory Profiler、Thread Profiler、GC Analyzer等工具也沒(méi)有想象中那么神秘和復(fù)雜了。
參考資料
- JVM Tool Interface
- The Pros and Cons of AsyncGetCallTrace Profilers
- Why (Most) Sampling Java Profilers Are Fucking Terrible
- Safepoints: Meaning, Side Effects and Overheads
- Serviceability in HotSpot
- 如何讀懂火焰圖?
- IntelliJ IDEA 2018.3 EAP: Git Submodules, JVM Profiler (macOS and Linux) and more
作者簡(jiǎn)介
業(yè)祥、繼東,美團(tuán)基礎(chǔ)架構(gòu)部/服務(wù)框架組工程師。
《新程序員》:云原生和全面數(shù)字化實(shí)踐50位技術(shù)專家共同創(chuàng)作,文字、視頻、音頻交互閱讀總結(jié)
以上是生活随笔為你收集整理的JVM CPU Profiler技术原理及源码深度解析的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: Mysql高性能优化规范建议,太厉害了!
- 下一篇: Spring事务失效的 8 大原因,这次