JVM CPU Profiler技術原理及原始碼深度解析

美團技術團隊發表於2022-12-05

本文介紹了JVM平臺上CPU Profiler的實現原理,希望能幫助讀者在使用類似工具的同時也能清楚其內部的技術實現。

引言

研發人員在遇到線上報警或需要最佳化系統效能時,常常需要分析程式執行行為和效能瓶頸。Profiling技術是一種在應用執行時收集程式相關資訊的動態分析手段,常用的JVM Profiler可以從多個方面對程式進行動態分析,如CPU、Memory、Thread、Classes、GC等,其中CPU Profiling的應用最為廣泛。

CPU Profiling經常被用於分析程式碼的執行熱點,如“哪個方法佔用CPU的執行時間最長”、“每個方法佔用CPU的比例是多少”等等,透過CPU Profiling得到上述相關資訊後,研發人員就可以輕鬆針對熱點瓶頸進行分析和效能最佳化,進而突破效能瓶頸,大幅提升系統的吞吐量。

CPU Profiler簡介

社群實現的JVM Profiler很多,比如已經商用且功能強大的JProfiler,也有免費開源的產品,如JVM-Profiler,功能各有所長。我們日常使用的Intellij IDEA最新版內部也整合了一個簡單好用的Profiler,詳細的介紹參見官方Blog。

在用IDEA開啟需要診斷的Java專案後,在“Preferences -> Build, Execution, Deployment -> Java Profiler”介面新增一個“CPU Profiler”,然後回到專案,單擊右上角的“Run with Profiler”啟動專案並開始CPU Profiling過程。一定時間後(推薦5min),在Profiler介面點選“Stop Profiling and Show Results”,即可看到Profiling的結果,包含火焰圖和呼叫樹,如下圖所示:

JVM CPU Profiler技術原理及原始碼深度解析Intellij IDEA - 效能火焰圖

JVM CPU Profiler技術原理及原始碼深度解析

Intellij IDEA - 呼叫堆疊樹

火焰圖是根據呼叫棧的樣本集生成的視覺化效能分析圖,《如何讀懂火焰圖?》一文對火焰圖進行了不錯的講解,大家可以參考一下。簡而言之,看火焰圖時我們需要關注“平頂”,因為那裡就是我們程式的CPU熱點。呼叫樹是另一種視覺化分析的手段,與火焰圖一樣,也是根據同一份樣本集而生成,按需選擇即可。

這裡要說明一下,因為我們沒有在專案中引入任何依賴,僅僅是“Run with Profiler”,Profiler就能獲取我們程式執行時的資訊。這個功能其實是透過JVM Agent實現的,為了更好地幫助大家系統性的瞭解它,我們在這裡先對JVM Agent做個簡單的介紹。

JVM Agent簡介

JVM Agent是一個按一定規則編寫的特殊程式庫,可以在啟動階段透過命令列引數傳遞給JVM,作為一個伴生庫與目標JVM執行在同一個程式中。在Agent中可以透過固定的介面獲取JVM程式內的相關資訊。Agent既可以是用C/C++/Rust編寫的JVMTI Agent,也可以是用Java編寫的Java Agent。

執行Java命令,我們可以看到Agent相關的命令列引數:

    -agentlib:<庫名>[=<選項>]
                  載入本機代理庫 <庫名>, 例如 -agentlib:jdwp
                  另請參閱 -agentlib:jdwp=help
    -agentpath:<路徑名>[=<選項>]
                  按完整路徑名載入本機代理庫
    -javaagent:<jar 路徑>[=<選項>]
                  載入 Java 程式語言代理, 請參閱 java.lang.instrument

JVMTI Agent

JVMTI(JVM Tool Interface)是JVM提供的一套標準的C/C++程式設計介面,是實現Debugger、Profiler、Monitor、Thread Analyser等工具的統一基礎,在主流Java虛擬機器中都有實現。

當我們要基於JVMTI實現一個Agent時,需要實現如下入口函式:

// $JAVA_HOME/include/jvmti.h

JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved);

使用C/C++實現該函式,並將程式碼編譯為動態連線庫(Linux上是.so),透過-agentpath引數將庫的完整路徑傳遞給Java程式,JVM就會在啟動階段的合適時機執行該函式。在函式內部,我們可以透過JavaVM指標引數拿到JNI和JVMTI的函式指標表,這樣我們就擁有了與JVM進行各種複雜互動的能力。

更多JVMTI相關的細節可以參考官方文件。

Java Agent

在很多場景下,我們沒有必要必須使用C/C++來開發JVMTI Agent,因為成本高且不易維護。JVM自身基於JVMTI封裝了一套Java的Instrument API介面,允許使用Java語言開發Java Agent(只是一個jar包),大大降低了Agent的開發成本。社群開源的產品如Greys、Arthas、JVM-Sandbox、JVM-Profiler等都是純Java編寫的,也是以Java Agent形式來執行。

在Java Agent中,我們需要在jar包的MANIFEST.MF中將Premain-Class指定為一個入口類,並在該入口類中實現如下方法:

public static void premain(String args, Instrumentation ins) {
    // implement
}

這樣打包出來的jar就是一個Java Agent,可以透過-javaagent引數將jar傳遞給Java程式伴隨啟動,JVM同樣會在啟動階段的合適時機執行該方法。

在該方法內部,引數Instrumentation介面提供了Retransform Classes的能力,我們利用該介面就可以對宿主程式的Class進行修改,實現方法耗時統計、故障注入、Trace等功能。Instrumentation介面提供的能力較為單一,僅與Class位元組碼操作相關,但由於我們現在已經處於宿主程式環境內,就可以利用JMX直接獲取宿主程式的記憶體、執行緒、鎖等資訊。無論是Instrument API還是JMX,它們內部仍是統一基於JVMTI來實現

更多Instrument API相關的細節可以參考官方文件。

CPU Profiler原理解析

在瞭解完Profiler如何以Agent的形式執行後,我們可以開始嘗試構造一個簡單的CPU Profiler。但在此之前,還有必要了解下CPU Profiling技術的兩種實現方式及其區別。

Sampling vs Instrumentation

使用過JProfiler的同學應該都知道,JProfiler的CPU Profiling功能提供了兩種方式選項: SamplingInstrumentation,它們也是實現CPU Profiler的兩種手段。

Sampling方式顧名思義,基於對StackTrace的“取樣”進行實現,核心原理如下:

  1. 引入Profiler依賴,或直接利用Agent技術注入目標JVM程式並啟動Profiler。

  2. 啟動一個取樣定時器,以固定的取樣頻率每隔一段時間(毫秒級)對所有執行緒的呼叫棧進行Dump。

  3. 彙總並統計每次呼叫棧的Dump結果,在一定時間內採到足夠的樣本後,匯出統計結果,內容是每個方法被取樣到的次數及方法的呼叫關係。

Instrumentation則是利用Instrument API,對所有必要的Class進行位元組碼增強,在進入每個方法前進行埋點,方法執行結束後統計本次方法執行耗時,最終進行彙總。二者都能得到想要的結果,那麼它們有什麼區別呢?或者說,孰優孰劣?

Instrumentation方式對幾乎所有方法新增了額外的AOP邏輯,這會導致對線上服務造成鉅額的效能影響,但其優勢是:絕對精準的方法呼叫次數、呼叫時間統計

Sampling方式基於無侵入的額外執行緒對所有執行緒的呼叫棧快照進行固定頻率抽樣,相對前者來說它的效能開銷很低。但由於它基於“取樣”的模式,以及JVM固有的只能在安全點(Safe Point)進行取樣的“缺陷”,會導致統計結果存在一定的偏差。譬如說:某些方法執行時間極短,但執行頻率很高,真實佔用了大量的CPU Time,但Sampling Profiler的取樣週期不能無限調小,這會導致效能開銷驟增,所以會導致大量的樣本呼叫棧中並不存在剛才提到的”高頻小方法“,進而導致最終結果無法反映真實的CPU熱點。更多Sampling相關的問題可以參考《Why (Most) Sampling Java Profilers Are Fucking Terrible》。

具體到“孰優孰劣”的問題層面,這兩種實現技術並沒有非常明顯的高下之判,只有在分場景討論下才有意義。Sampling由於低開銷的特性,更適合用在CPU密集型的應用中,以及不可接受大量效能開銷的線上服務中。而Instrumentation則更適合用在I/O密集的應用中、對效能開銷不敏感以及確實需要精確統計的場景中。社群的Profiler更多的是基於Sampling來實現,本文也是基於Sampling來進行講解。

基於Java Agent + JMX實現

一個最簡單的Sampling CPU Profiler可以用Java Agent + JMX方式來實現。以Java Agent為入口,進入目標JVM程式後開啟一個ScheduledExecutorService,定時利用JMX的threadMXBean.dumpAllThreads()來匯出所有執行緒的StackTrace,最終彙總並匯出即可。

Uber的JVM-Profiler實現原理也是如此,關鍵部分程式碼如下:

// com/uber/profiling/profilers/StacktraceCollectorProfiler.java

/*
 * StacktraceCollectorProfiler等同於文中所述CpuProfiler,僅命名偏好不同而已
 * jvm-profiler的CpuProfiler指代的是CpuLoad指標的Profiler
 */


// 實現了Profiler介面,外部由統一的ScheduledExecutorService對所有Profiler定時執行
@Override
public void profile() {
    ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(falsefalse);
    // ...
    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提供的定時器預設Interval是100ms,對於CPU Profiler來說,這略顯粗糙。但由於dumpAllThreads()的執行開銷不容小覷,Interval不宜設定的過小,所以該方法的CPU Profiling結果會存在不小的誤差。

JVM-Profiler的優點在於支援多種指標的Profiling(StackTrace、CPUBusy、Memory、I/O、Method),且支援將Profiling結果透過Kafka上報回中心Server進行分析,也即支援叢集診斷。

基於JVMTI + GetStackTrace實現

使用Java實現Profiler相對較簡單,但也存在一些問題,譬如說Java Agent程式碼與業務程式碼共享AppClassLoader,被JVM直接載入的agent.jar如果引入了第三方依賴,可能會對業務Class造成汙染。截止發稿時,JVM-Profiler都存在這個問題,它引入了Kafka-Client、http-Client、Jackson等元件,如果與業務程式碼中的元件版本發生衝突,可能會引發未知錯誤。Greys/Arthas/JVM-Sandbox的解決方式是分離入口與核心程式碼,使用定製的ClassLoader載入核心程式碼,避免影響業務程式碼。

在更底層的C/C++層面,我們可以直接對接JVMTI介面,使用原生C API對JVM進行操作,功能更豐富更強大,但開發效率偏低。基於上節同樣的原理開發CPU Profiler,使用JVMTI需要進行如下這些步驟:

1. 編寫Agent_OnLoad(),在入口透過JNI的JavaVM*指標的GetEnv()函式拿到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. 開啟一個執行緒定時迴圈,定時使用jvmtiEnv指標配合呼叫如下幾個JVMTI函式:

// 獲取所有執行緒的jthread
jvmtiError GetAllThreads(jvmtiEnv *env, jint *threads_count_ptr, jthread **threads_ptr);

// 根據jthread獲取該執行緒資訊(name、daemon、priority...)
jvmtiError GetThreadInfo(jvmtiEnv *env, jthread thread, jvmtiThreadInfo* info_ptr);

// 根據jthread獲取該執行緒呼叫棧
jvmtiError GetStackTrace(jvmtiEnv *env,
                         jthread thread,
                         jint start_depth,
                         jint max_frame_count,
                         jvmtiFrameInfo *frame_buffer,
                         jint *count_ptr)
;

主邏輯大致是:首先呼叫GetAllThreads()獲取所有執行緒的“控制程式碼”jthread,然後遍歷根據jthread呼叫GetThreadInfo()獲取執行緒資訊,按執行緒名過濾掉不需要的執行緒後,繼續遍歷根據jthread呼叫GetStackTrace()獲取執行緒的呼叫棧。

3. 在Buffer中儲存每一次的取樣結果,最終生成必要的統計資料即可。

按如上步驟即可實現基於JVMTI的CPU Profiler。但需要說明的是,即便是基於原生JVMTI介面使用GetStackTrace()的方式獲取呼叫棧,也存在與JMX相同的問題——只能在安全點(Safe Point)進行取樣

SafePoint Bias問題

基於Sampling的CPU Profiler透過採集程式在不同時間點的呼叫棧樣本來近似地推算出熱點方法,因此,從理論上來講Sampling CPU Profiler必須遵循以下兩個原則:
  1. 樣本必須足夠多。

  2. 程式中所有正在執行的程式碼點都必須以相同的機率被Profiler取樣。

如果只能在安全點取樣,就違背了第二條原則。因為我們只能採集到位於安全點時刻的呼叫棧快照,意味著某些程式碼可能永遠沒有機會被取樣,即使它真實耗費了大量的CPU執行時間,這種現象被稱為“SafePoint Bias”。

上文我們提到,基於JMX與基於JVMTI的Profiler實現都存在SafePoint Bias,但一個值得了解的細節是:單獨來說,JVMTI的GetStackTrace()函式並不需要在Caller的安全點執行,但當呼叫GetStackTrace()獲取其他執行緒的呼叫棧時,必須等待,直到目標執行緒進入安全點;而且,GetStackTrace()僅能透過單獨的執行緒同步定時呼叫,不能在UNIX訊號處理器的Handler中被非同步呼叫。綜合來說,GetStackTrace()存在與JMX一樣的SafePoint Bias。更多安全點相關的知識可以參考《Safepoints: Meaning, Side Effects and Overheads》。

那麼,如何避免SafePoint Bias?社群提供了一種Hack思路——AsyncGetCallTrace。

基於JVMTI + AsyncGetCallTrace實現

如上節所述,假如我們擁有一個函式可以獲取當前執行緒的呼叫棧且不受安全點干擾,另外它還支援在UNIX訊號處理器中被非同步呼叫,那麼我們只需註冊一個UNIX訊號處理器,在Handler中呼叫該函式獲取當前執行緒的呼叫棧即可。由於UNIX訊號會被髮送給程式的隨機一執行緒進行處理,因此最終訊號會均勻分佈在所有執行緒上,也就均勻獲取了所有執行緒的呼叫棧樣本。

OracleJDK/OpenJDK內部提供了這麼一個函式——AsyncGetCallTrace,它的原型如下:

// 棧幀
typedef struct {
 jint lineno;
 jmethodID method_id;
} AGCT_CallFrame;

// 呼叫棧
typedef struct {
    JNIEnv *env;
    jint num_frames;
    AGCT_CallFrame *frames;
} AGCT_CallTrace;

// 根據ucontext將呼叫棧填充進trace指標
void AsyncGetCallTrace(AGCT_CallTrace *trace, jint depth, void *ucontext);

透過原型可以看到,該函式的使用方式非常簡潔,直接透過ucontext就能獲取到完整的Java呼叫棧。

顧名思義,AsyncGetCallTrace是“async”的,不受安全點影響,這樣的話取樣就可能發生在任何時間,包括Native程式碼執行期間、GC期間等,在這時我們是無法獲取Java呼叫棧的,AGCT_CallTrace的num_frames欄位正常情況下標識了獲取到的呼叫棧深度,但在如前所述的異常情況下它就表示為負數,最常見的-2代表此刻正在GC

由於AsyncGetCallTrace非標準JVMTI函式,因此我們無法在jvmti.h中找到該函式宣告,且由於其目標檔案也早已連結進JVM二進位制檔案中,所以無法透過簡單的宣告來獲取該函式的地址,這需要透過一些Trick方式來解決。簡單說,Agent最終是作為動態連結庫載入到目標JVM程式的地址空間中,因此可以在Agent_OnLoad內透過glibc提供的dlsym()函式拿到當前地址空間(即目標JVM程式地址空間)名為“AsyncGetCallTrace”的符號地址。這樣就拿到了該函式的指標,按照上述原型進行型別轉換後,就可以正常呼叫了。

透過AsyncGetCallTrace實現CPU Profiler的大致流程:

1. 編寫Agent_OnLoad(),在入口拿到jvmtiEnv和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階段,我們還需要做一件事,即註冊OnClassLoad和OnClassPrepare這兩個Hook,原因是jmethodID是延遲分配的,使用AGCT獲取Traces依賴預先分配好的資料。我們在OnClassPrepare的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訊號來進行定時取樣:

// 這裡訊號handler傳進來的的ucontext即AsyncGetCallTrace需要的ucontext
void signal_handler(int signo, siginfo_t *siginfo, void *ucontext) {
    // 使用AsyncCallTrace進行取樣,注意處理num_frames為負的異常情況
}

// ...

// 註冊SIGPROF訊號的handler
struct sigaction sa;
sigemptyset(&sa.sa_mask);
sa.sa_sigaction = signal_handler;
sa.sa_flags = SA_RESTART | SA_SIGINFO;
sigaction(SIGPROF, &sa, NULL);

// 定時產生SIGPROF訊號
// interval是nanoseconds表示的取樣間隔,AsyncGetCallTrace相對於同步取樣來說可以適當高頻一些
long sec = interval / 1000000000;
long usec = (interval % 1000000000) / 1000;
struct itimerval tv = {{sec, usec}, {sec, usec}};
setitimer(ITIMER_PROF, &tv, NULL);

4.在Buffer中儲存每一次的取樣結果,最終生成必要的統計資料即可。

按如上步驟即可實現基於AsyncGetCallTrace的CPU Profiler,這是社群中目前效能開銷最低、相對效率最高的CPU Profiler實現方式,在Linux環境下結合perf_events還能做到同時取樣Java棧與Native棧,也就能同時分析Native程式碼中存在的效能熱點。該方式的典型開源實現有Async-Profiler和Honest-Profiler,Async-Profiler實現質量較高,感興趣的話建議大家閱讀參考原始碼。有趣的是,IntelliJ IDEA內建的Java Profiler,其實就是Async-Profiler的包裝。更多關於AsyncGetCallTrace的內容,大家可以參考《The Pros and Cons of AsyncGetCallTrace Profilers》。

生成效能火焰圖

現在我們擁有了取樣呼叫棧的能力,但是呼叫棧樣本集是以二維陣列的資料結構形式存在於記憶體中的,如何將其轉換為視覺化的火焰圖呢?

火焰圖通常是一個svg檔案,部分優秀專案可以根據文字檔案自動生成火焰圖檔案,僅對文字檔案的格式有一定要求。FlameGraph專案的核心只是一個Perl指令碼,可以根據我們提供的呼叫棧文字生成相應的火焰圖svg檔案。呼叫棧的文字格式相當簡單,如下所示:

base_func;func1;func2;func3 10
base_func;funca;funcb 15

將我們取樣到的呼叫棧樣本集進行整合後,需輸出如上所示的文字格式。每一行代表一“類“呼叫棧,空格左邊是呼叫棧的方法名排列,以分號分割,左棧底右棧頂,空格右邊是該樣本出現的次數。

將樣本檔案交給flamegraph.pl指令碼執行,就能輸出相應的火焰圖了:

$ flamegraph.pl stacktraces.txt > stacktraces.svg

效果如下圖所示:

JVM CPU Profiler技術原理及原始碼深度解析透過flamegraph.pl生成的火焰圖

HotSpot的Dynamic Attach機制解析

到目前為止,我們已經瞭解了CPU Profiler完整的工作原理,然而使用過JProfiler/Arthas的同學可能會有疑問,很多情況下可以直接對線上執行中的服務進行Profling,並不需要在Java程式的啟動引數新增Agent引數,這是透過什麼手段做到的?答案是Dynamic Attach

JDK在1.6以後提供了Attach API,允許向執行中的JVM程式新增Agent,這項手段被廣泛使用在各種Profiler和位元組碼增強工具中,其官方簡介如下:

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.

總的來說,Dynamic Attach是HotSpot提供的一種特殊能力,它允許一個程式向另一個執行中的JVM程式傳送一些命令並執行,命令並不限於載入Agent,還包括Dump記憶體、Dump執行緒等等。

透過sun.tools進行Attach

Attach雖然是HotSpot提供的能力,但JDK在Java層面也對其做了封裝。

前文已經提到,對於Java Agent來說,PreMain方法在Agent作為啟動引數執行的時候執行,其實我們還可以額外實現一個AgentMain方法,並在MANIFEST.MF中將Agent-Class指定為該Class:

public static void agentmain(String args, Instrumentation ins) {
    // implement
}

這樣打包出來的jar,既可以作為-javaagent引數啟動,也可以被Attach到執行中的目標JVM程式。JDK已經封裝了簡單的API讓我們直接Attach一個Java Agent,下面以Arthas中的程式碼進行演示:

// 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程式,找出目標程式
    for (VirtualMachineDescriptor descriptor : VirtualMachine.list()) {
        String pid = descriptor.id();
        if (pid.equals(Integer.toString(configure.getJavaPid()))) {
            virtualMachineDescriptor = descriptor;
        }
    }
    VirtualMachine virtualMachine = null;
    try {
        // 針對某個JVM程式呼叫VirtualMachine.attach()方法,拿到VirtualMachine例項
        if (null == virtualMachineDescriptor) {
            virtualMachine = VirtualMachine.attach("" + configure.getJavaPid());
        } else {
            virtualMachine = VirtualMachine.attach(virtualMachineDescriptor);
        }

        // ...

        // 呼叫VirtualMachine#loadAgent(),將arthasAgentPath指定的jar attach到目標JVM程式中
        // 第二個引數為attach引數,即agentmain的首個String引數args
        virtualMachine.loadAgent(arthasAgentPath, configure.getArthasCore() + ";" + configure.toString());
    } finally {
        if (null != virtualMachine) {
            // 呼叫VirtualMachine#detach()釋放
            virtualMachine.detach();
        }
    }
}

直接對HotSpot進行Attach

sun.tools封裝的API足夠簡單易用,但只能使用Java編寫,也只能用在Java Agent上,因此有些時候我們必須手工對JVM程式直接進行Attach。對於JVMTI,除了Agent_OnLoad()之外,我們還需實現一個Agent_OnAttach()函式,當將JVMTI Agent Attach到目標程式時,從該函式開始執行:

// $JAVA_HOME/include/jvmti.h

JNIEXPORT jint JNICALL Agent_OnAttach(JavaVM *vm, char *options, void *reserved);

下面我們以Async-Profiler中的jattach原始碼為線索,探究一下如何利用Attach機制給執行中的JVM程式傳送命令。jattach是Async-Profiler提供的一個Driver,使用方式比較直觀:

Usage:
    jattach <pid> <cmd> [args ...]
Args:
    <pid>  目標JVM程式的程式ID
    <cmd>  要執行的命令
    <args> 命令引數

使用方式如:

$ jattach 1234 load /absolute/path/to/agent/libagent.so true

執行上述命令,libagent.so就被載入到ID為1234的JVM程式中並開始執行Agent_OnAttach函式了。有一點需要注意,執行Attach的程式euid及egid,與被Attach的目標JVM程式必須相同。接下來開始分析jattach原始碼。

如下所示的Main函式描述了一次Attach的整體流程:

// async-profiler/src/jattach/jattach.c

int main(int argc, char** argv) {
    // 解析命令列引數
    // 檢查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;
}

忽略掉命令列引數解析與檢查euid和egid的過程。jattach首先呼叫了check_socket函式進行了“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作業系統提供了一種基於檔案的Socket介面,稱為“UNIX Socket”(一種常用的程式間通訊方式)。在該函式中使用S_ISSOCK宏來判斷該檔案是否被繫結到了UNIX Socket,如此看來,“/tmp/.java_pid<pid>

查閱官方文件,得到如下描述:

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函式的作用很容易理解了:判斷外部程式與目標JVM程式之間是否已經建立了UNIX Socket連線

回到Main函式,在使用check_socket確定連線尚未建立後,緊接著呼叫start_attach_mechanism函式,函式名很直觀地描述了它的作用,原始碼如下:

// 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 /tmp
        snprintf(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() call
    kill(pid, SIGQUIT);

    // Start with 20 ms sleep and increment delay each iteration
    struct timespec ts = {020000000};
    int result;
    do {
        nanosleep(&ts, NULL);
        result = check_socket(nspid);
    } while (!result && (ts.tv_nsec += 20000000) < 300000000);

    unlink(path);
    return result;
}

start_attach_mechanism函式首先建立了一個名為“/tmp/.attach_pid<pid>

如此看來,HotSpot似乎提供了一種特殊的機制,只要給它傳送一個SIGQUIT訊號,並預先準備好.attach_pid

查閱文件,得到如下描述:

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.

這樣一來就很明確了,在Linux上我們只需建立一個“/tmp/.attach_pid

繼續看jattach的原始碼,果不其然,它呼叫了connect_socket函式對“/tmp/.java_pid

// 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;
}

一個很普通的Socket建立函式,返回Socket檔案描述符。

回到Main函式,主流程緊接著呼叫write_command函式向該Socket寫入了從命令列傳進來的引數,並且呼叫read_response函式接收從目標JVM程式返回的資料。兩個很常見的Socket讀寫函式,原始碼如下:

// async-profiler/src/jattach/jattach.c

// Send command with arguments to socket
static int write_command(int fd, int argc, char** argv) {
    // Protocol version
    if (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 code
    buf[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函式就可知外部程式與目標JVM程式之間傳送的資料格式相當簡單,基本如下所示:

<PROTOCOL VERSION>\0<COMMAND>\0<ARG1>\0<ARG2>\0<ARG3>\0

以先前我們使用的Load命令為例,傳送給HotSpot時格式如下:

1\0load\0/absolute/path/to/agent/libagent.so\0true\0\0

至此,我們已經瞭解瞭如何手工對JVM程式直接進行Attach。

Attach補充介紹

Load命令僅僅是HotSpot所支援的諸多命令中的一種,用於動態載入基於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命令,然後對相同的程式進行jstack,對比觀察輸出,其實是完全相同的,其它命令大家可以自行進行探索。

總結

總的來說,善用各類Profiler是提升效能最佳化效率的一把利器,瞭解Profiler本身的實現原理更能幫助我們避免對工具的各種誤用。CPU Profiler所依賴的Attach、JVMTI、Instrumentation、JMX等皆是JVM平臺比較通用的技術,在此基礎上,我們去實現Memory Profiler、Thread Profiler、GC Analyzer等工具也沒有想象中那麼神秘和複雜了。

參考資料

  • 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

作者簡介

業祥、繼東,美團基礎架構部/服務框架組工程師。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31559353/viewspace-2659550/,如需轉載,請註明出處,否則將追究法律責任。

相關文章