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

研發人員在遇到線上報警或須要優化系統性能時,經常須要分析程序運行行爲和性能瓶頸。Profiling技術是一種在應用運行時收集程序相關信息的動態分析手段,經常使用的JVM Profiler能夠從多個方面對程序進行動態分析,如CPU、Memory、Thread、Classes、GC等,其中CPU Profiling的應用最爲普遍。CPU Profiling常常被用於分析代碼的執行熱點,如「哪一個方法佔用CPU的執行時間最長」、「每一個方法佔用CPU的比例是多少」等等,經過CPU Profiling獲得上述相關信息後,研發人員就能夠輕鬆針對熱點瓶頸進行分析和性能優化,進而突破性能瓶頸,大幅提高系統的吞吐量。html

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

CPU Profiler簡介

社區實現的JVM Profiler不少,好比已經商用且功能強大的JProfiler,也有免費開源的產品,如JVM-Profiler,功能各有所長。咱們平常使用的Intellij IDEA最新版內部也集成了一個簡單好用的Profiler,詳細的介紹參見官方Bloglinux

在用IDEA打開須要診斷的Java項目後,在「Preferences -> Build, Execution, Deployment -> Java Profiler」界面添加一個「CPU Profiler」,而後回到項目,單擊右上角的「Run with Profiler」啓動項目並開始CPU Profiling過程。必定時間後(推薦5min),在Profiler界面點擊「Stop Profiling and Show Results」,便可看到Profiling的結果,包含火焰圖和調用樹,以下圖所示:git

Intellij IDEA - 性能火焰圖

Intellij IDEA - 調用堆棧樹

火焰圖是根據調用棧的樣本集生成的可視化性能分析圖,《如何讀懂火焰圖?》一文對火焰圖進行了不錯的講解,你們能夠參考一下。簡而言之,看火焰圖時咱們須要關注「平頂」,由於那裏就是咱們程序的CPU熱點。調用樹是另外一種可視化分析的手段,與火焰圖同樣,也是根據同一份樣本集而生成,按需選擇便可。github

這裏要說明一下,由於咱們沒有在項目中引入任何依賴,僅僅是「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。macos

執行Java命令,咱們能夠看到Agent相關的命令行參數:編程

Plain Text
    -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虛擬機中都有實現。api

當咱們要基於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的開發成本。社區開源的產品如GreysArthasJVM-SandboxJVM-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功能提供了兩種方式選項: Sampling和Instrumentation,它們也是實現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(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提供的定時器默認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-ProfilerHonest-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

效果以下圖所示:

經過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>」文件頗有可能就是外部進程與JVM進程間通訊的橋樑。

查閱官方文檔,獲得以下描述:

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 = {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函數首先建立了一個名爲「/tmp/.attach_pid<pid>」的空文件,而後向目標JVM進程發送了一個SIGQUIT信號,這個信號彷佛觸發了JVM的某種機制?緊接着,start_attach_mechanism函數開始陷入了一種等待,每20ms調用一次check_socket函數檢查鏈接是否被創建,若是等了300ms尚未成功就放棄。函數的最後調用Unlink刪掉.attach_pid<pid>文件並返回。

如此看來,HotSpot彷佛提供了一種特殊的機制,只要給它發送一個SIGQUIT信號,並預先準備好.attach_pid<pid>文件,HotSpot會主動建立一個地址爲「/tmp/.java_pid<pid>」的UNIX Socket,接下來主動Connect這個地址便可創建鏈接執行命令。

查閱文檔,獲得以下描述:

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<pid>」文件,並向目標JVM進程發送一個SIGQUIT信號,HotSpot就會開始監聽「/tmp/.java_pid<pid>」地址上的UNIX Socket,接收並執行相關Attach的命令。至於爲何必定要建立.attach_pid<pid>文件才能夠觸發Attach Listener的建立,經查閱資料,咱們獲得了兩種說法:一是JVM不止接收從外部Attach進程發送的SIGQUIT信號,必須配合外部進程建立的外部文件才能肯定這是一次Attach請求;二是爲了安全。

繼續看jattach的源碼,果不其然,它調用了connect_socket函數對「/tmp/.java_pid<pid>」進行鏈接,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;
}

一個很普通的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等工具也沒有想象中那麼神祕和複雜了。

參考資料

做者簡介

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

團隊信息

美團點評基礎架構團隊誠招高級、資深技術專家,Base北京、上海。咱們致力於建設美團點評全公司統一的高併發高性能分佈式基礎架構平臺,涵蓋數據庫、分佈式監控、服務治理、高性能通訊、消息中間件、基礎存儲、容器化、集羣調度等基礎架構主要的技術領域。歡迎有興趣的同窗投送簡歷至:tech@meituan.com。

相關文章
相關標籤/搜索