JPDA(Java Platform Debugger Architecture)是 Java 平台調試體系結構的縮寫。通過 JPDA 提供的 API,開發人員可以方便靈活的搭建 Java 調試應用程序。 JPDA 主要由三個部分組成:Java 虛擬機工具接口(JVMTI)、Java 調試線協議(JDWP),以及 Java 調試接口(JDI)。本系列將會詳細介紹這三個模塊的內部細節,並通過實例為讀者揭開 JPDA 的面紗。
本系列的 第 1 部分 從整體上介紹 JPDA 的各個組成,以及它們彼此之間的內在關聯。本文是該系列的第 2 篇,將會著重介紹強大的虛擬機接口 - JVMTI,以及如何使用 JVMTI 編寫用戶自定義的 Java 調試和診斷程序。
Java 程序的診斷和調試
開發人員對 Java 程序的診斷和調試有許多不同種類、不同層次的需求,這就使得開發人員需要使用不同的工具來解決問題。比如,在 Java 程序運行的過程中,程序員希望掌握它總體的運行狀況,這個時候程序員可以直接使用 JDK 提供的 jconsole 程序。如果希望提高程序的執行效率,開發人員可以使用各種 Java Profiler。這種類型的工具非常多,各有優點,能夠幫助開發人員找到程序的瓶頸,從而提高程序的運行速度。開發人員還會遇到一些與內存相關的問題,比如內存占用過多,大量內存不能得到釋放,甚至導致內存溢出錯誤(OutOfMemoryError)等等,這時可以把當前的內存輸出到 Dump 文件,再使用堆分析器或者 Dump 文件分析器等工具進行研究,查看當前運行態堆(Heap)中存在的實例整體狀況來診斷問題。所有這些工具都有一個共同的特點,就是最終他們都需要通過和虛擬機進行交互,來發現 Java 程序運行的問題。
已有的這些工具雖然強大易用,但是在一些高級的應用環境中,開發者常常會有一些特殊的需求,這個時候就需要定制工具來達成目標。 JDK 本身定義了目標明確並功能完善的 API 來與虛擬機直接交互,而且這些 API 能很方便的進行擴展,從而滿足開發者各式的需求。在本文中,將比較詳細地介紹 JVMTI,以及如何使用 JVMTI 編寫一個定制的 Agent 。
Agent
Agent 即 JVMTI 的客戶端,它和執行 Java 程序的虛擬機運行在同一個進程上,因此通常他們的實現都很緊湊,他們通常由另一個獨立的進程控制,充當這個獨立進程和當前虛擬機之間的中介,通過調用 JVMTI 提供的接口和虛擬機交互,負責獲取並返回當前虛擬機的狀態或者轉發控制命令。
JVMTI 的簡介
JVMTI(JVM Tool Interface)是 Java 虛擬機所提供的 native 編程接口,是 JVMPI(Java Virtual Machine Profiler Interface)和 JVMDI(Java Virtual Machine Debug Interface)的更新版本。從這個 API 的發展歷史軌跡中我們就可以知道,JVMTI 提供了可用於 debug 和 profiler 的接口;同時,在 Java 5/6 中,虛擬機接口也增加了監聽(Monitoring),線程分析(Thread analysis)以及覆蓋率分析(Coverage Analysis)等功能。正是由於 JVMTI 的強大功能,它是實現 Java 調試器,以及其它 Java 運行態測試與分析工具的基礎。
JVMTI 並不一定在所有的 Java 虛擬機上都有實現,不同的虛擬機的實現也不盡相同。不過在一些主流的虛擬機中,比如 Sun 和 IBM,以及一些開源的如 Apache Harmony DRLVM 中,都提供了標准 JVMTI 實現。
JVMTI 是一套本地代碼接口,因此使用 JVMTI 需要我們與 C/C++ 以及 JNI 打交道。事實上,開發時一般采用建立一個 Agent 的方式來使用 JVMTI,它使用 JVMTI 函數,設置一些回調函數,並從 Java 虛擬機中得到當前的運行態信息,並作出自己的判斷,最後還可能操作虛擬機的運行態。把 Agent 編譯成一個動態鏈接庫之後,我們就可以在 Java 程序啟動的時候來加載它(啟動加載模式),也可以在 Java 5 之後使用運行時加載(活動加載模式)。
-agentlib:agent-lib-name=options
-agentpath:path-to-agent=options
Agent 的工作過程
啟動
Agent 是在 Java 虛擬機啟動之時加載的,這個加載處於虛擬機初始化的早期,在這個時間點上:
所有的 Java 類都未被初始化;
所有的對象實例都未被創建;
因而,沒有任何 Java 代碼被執行;
但在這個時候,我們已經可以:
操作 JVMTI 的 Capability 參數;
使用系統參數;
動態庫被加載之後,虛擬機會先尋找一個 Agent 入口函數:
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved)
在這個函數中,虛擬機傳入了一個 JavaVM 指針,以及命令行的參數。通過 JavaVM,我們可以獲得 JVMTI 的指針,並獲得 JVMTI 函數的使用能力,所有的 JVMTI 函數都通過這個 jvmtiEnv 獲取,不同的虛擬機實現提供的函數細節可能不一樣,但是使用的方式是統一的。
jvmtiEnv *jvmti;
(*jvm)->GetEnv(jvm, &jvmti, JVMTI_VERSION_1_0);
這裡傳入的版本信息參數很重要,不同的 JVMTI 環境所提供的功能以及處理方式都可能有所不同,不過它在同一個虛擬機中會保持不變(有心的讀者可以去比較一下 JNI 環境)。命令行參數事實上就是上面啟動命令行中的 options 部分,在 Agent 實現中需要進行解析並完成後續處理工作。參數傳入的字符串僅僅在 Agent_OnLoad 函數裡有效,如果需要長期使用,開發者需要做內存的復制工作,同時在最後還要釋放這塊存儲。另外,有些 JDK 的實現會使用 JAVA_TOOL_OPTIONS 所提供的參數,這個常見於一些嵌入式的 Java 虛擬機(不使用命令行)。需要強調的是,這個時候由於虛擬機並未完成初始化工作,並不是所有的 JVMTI 函數都可以被使用。
Agent 還可以在運行時加載,如果您了解 Java Instrument 模塊(可以參考這篇文章),您一定對它的運行態加載有印象,這個新功能事實上也是 Java Agent 的一個實現。具體說來,虛擬機會在運行時監聽並接受 Agent 的加載,在這個時候,它會使用 Agent 的:
JNIEXPORT jint JNICALL Agent_OnAttach(JavaVM* vm, char *options, void *reserved);
同樣的在這個初始化階段,不是所有的 JVMTI 的 Capability 參數都處於可操作狀態,而且 options 這個 char 數組在這個函數運行之後就會被丟棄,如果需要,需要做好保留工作。
Agent 的主要功能是通過一系列的在虛擬機上設置的回調(callback)函數完成的,一旦某些事件發生,Agent 所設置的回調函數就會被調用,來完成特定的需求。
卸載
最後,Agent 完成任務,或者虛擬機關閉的時候,虛擬機都會調用一個類似於類析構函數的方法來完成最後的清理任務,注意這個函數和虛擬機自己的 VM_DEATH 事件是不同的。
JNIEXPORT void JNICALL Agent_OnUnload(JavaVM *vm)
JVMTI 的環境和錯誤處理
我們使用 JVMTI 的過程,主要是設置 JVMTI 環境,監聽虛擬機所產生的事件,以及在某些事件上加上我們所希望的回調函數。
JVMTI 環境
我們可以通過操作 jvmtiCapabilities 來查詢、增加、修改 JVMTI 的環境參數。當然,對於每一個不同的虛擬機來說,基於他們的實現不盡相同,導致了 JVMTI 的環境也不一定一致。標准的 jvmtiCapabilities 定義了一系列虛擬機的功能,比如 can_redefine_any_class 定義了虛擬機是否支持重定義類,can_retransform_classes 定義了是否支持在運行的時候改變類定義等等。如果熟悉 Java Instrumentation,一定不會對此感到陌生,因為 Instrumentation 就是對這些在 Java 層上的包裝。對用戶來說,這塊最主要的是查看當前 JVMTI 環境,了解虛擬機具有的功能。要了解這個,其實很簡單,只需通過對 jvmtiCapabilities 的一系列變量的考察就可以。
err = (*jvmti)->GetCapabilities(jvmti, &capa); // 取得 jvmtiCapabilities 指針。
if (err == JVMTI_ERROR_NONE) {
if (capa.can_redefine_any_class) { ... }
} // 查看是否支持重定義類
另外,虛擬機有自己的一些功能,一開始並未被啟動,那麼增加或修改 jvmtiCapabilities 也是可能的,但不同的虛擬機對這個功能的處理也不太一樣,多數的虛擬機允許增改,但是有一定的限制,比如僅支持在 Agent_OnLoad 時,即虛擬機啟動時作出,它某種程度上反映了虛擬機本身的構架。開發人員無需要考慮 Agent 的性能和內存占用,就可以在 Agent 被加載的時候啟用所有功能:
err = (*jvmti)->GetPotentialCapabilities(jvmti, &capa); // 取得所有可用的功能
if (err == JVMTI_ERROR_NONE) {
err = (*jvmti)->AddCapabilities(jvmti, &capa);
...
}
最後我們要注意的是,JVMTI 的函數調用都有其時間性,即特定的函數只能在特定的虛擬機狀態下才能調用,比如 SuspendThread(掛起線程)這個動作,僅在 Java 虛擬機處於運行狀態(live phase)才能調用,否則導致一個內部異常。
JVMTI 錯誤處理
JVMTI 沿用了基本的錯誤處理方式,即使用返回的錯誤代碼通知當前的錯誤,幾乎所有的 JVMTI 函數調用都具有以下模式:
jvmtiError err = jvmti->someJVMTImethod (somePara … );
其中 err 就是返回的錯誤代碼,不同函數的錯誤信息可以在 Java 規范裡查到。
JVMTI 基本功能
JVMTI 的功能非常豐富,包含了虛擬機中線程、內存 / 堆 / 棧,類 / 方法 / 變量,事件 / 定時器處理等等 20 多類功能,下面我們介紹一下,並舉一些簡單列子。
事件處理和回調函數
從上文我們知道,使用 JVMTI 一個基本的方式就是設置回調函數,在某些事件發生的時候觸發並作出相應的動作。因此這一部分的功能非常基本,當前版本的 JVMTI 提供了許多事件(Event)的回調,包括虛擬機初始化、開始運行、結束,類的加載,方法出入,線程始末等等。如果想對這些事件進行處理,我們需要首先為該事件寫一個函數,然後在 jvmtiEventCallbacks 這個結構中指定相應的函數指針。比如,我們對線程啟動感興趣,並寫了一個 HandleThreadStart 函數,那麼我們需要在 Agent_OnLoad 函數裡加入:
jvmtiEventCallbacks eventCallBacks;
memset(&ecbs, 0, sizeof(ecbs)); // 初始化
eventCallBacks.ThreadStart = &HandleThreadStart; // 設置函數指針
...
在設置了這些回調之後,就可以調用下述方法,來最終完成設置。在接下來的虛擬機運行過程中,一旦有線程開始運行發生,虛擬機就會回調 HandleThreadStart 方法。
jvmti->SetEventCallbacks(eventCallBacks, sizeof(eventCallBacks));
設置回調函數的時候,開發者需要注意以下幾點:
如同 Java 異常機制一樣,如果在回調函數中自己拋出一個異常(Exception),或者在調用 JNI 函數的時候制造了一些麻煩,讓 JNI 丟出了一個異常,那麼任何在回調之前發生的異常就會丟失,這就要求開發人員要在處理錯誤的時候需要當心。
虛擬機不保證回調函數會被同步,換句話說,程序有可能同時運行同一個回調函數(比如,好幾個線程同時開始運行了,這個 HandleThreadStart 就會被同時調用幾次),那麼開發人員在開發回調函數時需要處理同步的問題。
內存控制和對象獲取
內存控制是一切運行態的基本功能。 JVMTI 除了提供最簡單的內存申請和撤銷之外(這塊內存不受 Java 堆管理,開發人員需要自行進行清理工作,不然會造成內存洩漏),也提供了對 Java 堆的操作。眾所周知,Java 堆中存儲了 Java 的類、對象和基本類型(Primitive),通過對堆的操作,開發人員可以很容易的查找任意的類、對象,甚至可以強行執行垃圾收集工作。 JVMTI 中對 Java 堆的操作與眾不同,它沒有提供一個直接獲取的方式(由此可見,虛擬機對對象的管理並非是哈希表,而是某種樹 / 圖方式),而是使用一個迭代器(iterater)的方式遍歷:
jvmtiError FollowReferences(jvmtiEnv* env,
jint heap_filter,
jclass klass,
jobject initial_object,// 該方式可以指定根節點
const jvmtiHeapCallbacks* callbacks,// 設置回調函數
const void* user_data)
或者
jvmtiError IterateThroughHeap(jvmtiEnv* env,
jint heap_filter,
jclass klass,
const jvmtiHeapCallbacks* callbacks,
const void* user_data)// 遍歷整個 heap
在遍歷的過程中,開發者可以設定一定的條件,比如,指定是某一個類的對象,並設置一個回調函數,如果條件被滿足,回調函數就會被執行。開發者可以在回調函數中對當前傳回的指針進行打標記(tag)操作——這又是一個特殊之處,在第一遍遍歷中,只能對滿足條件的對象進行 tag ;然後再使用 GetObjectsWithTags 函數,獲取需要的對象。
jvmtiError GetObjectsWithTags(jvmtiEnv* env,
jint tag_count,
const jlong* tags, // 設定特定的 tag,即我們上面所設置的
jint* count_ptr,
jobject** object_result_ptr,
jlong** tag_result_ptr)
如果你僅僅想對特定 Java 對象操作,應該避免設置其他類型的回調函數,否則會影響效率,舉例來說,多增加一個 primitive 的回調函數,可能會使整個操作效率下降一個數量級。
線程和鎖
線程是 Java 運行態中非常重要的一個部分,在 JVMTI 中也提供了很多 API 進行相應的操作,包括查詢當前線程狀態,暫停,恢復或者終端線程,還可以對線程鎖進行操作。開發者可以獲得特定線程所擁有的鎖:
jvmtiError GetOwnedMonitorInfo(jvmtiEnv* env,
jthread thread,
jint* owned_monitor_count_ptr,
jobject** owned_monitors_ptr)
也可以獲得當前線程正在等待的鎖:
jvmtiError GetCurrentContendedMonitor(jvmtiEnv* env,
jthread thread,
jobject* monitor_ptr)
知道這些信息,事實上我們也可以設計自己的算法來判斷是否死鎖。更重要的是,JVMTI 提供了一系列的監視器(Monitor)操作,來幫助我們在 native 環境中實現同步。主要的操作是構建監視器(CreateRawMonitor),獲取監視器(RawMonitorEnter),釋放監視器(RawMonitorExit),等待和喚醒監視器 (RawMonitorWait,RawMonitorNotify) 等操作,通過這些簡單鎖,程序的同步操作可以得到保證。
調試功能
調試功能是 JVMTI 的基本功能之一,這主要包括了設置斷點、調試(step)等,在 JVMTI 裡面,設置斷點的 API 本身很簡單:
jvmtiError SetBreakpoint(jvmtiEnv* env,
jmethodID method,
jlocation location)
jlocation 這個數據結構在這裡代表的是對應方法方法中一個可執行代碼的行數。在斷點發生的時候,虛擬機會觸發一個事件,開發者可以使用在上文中介紹過的方式對事件進行處理。
JVMTI 數據結構
JVMTI 中使用的數據結構,首先也是一些標准的 JNI 數據結構,比如 jint,jlong ;其次,JVMTI 也定義了一些基本類型,比如 jthread,表示一個 thread,jvmtiEvent,表示 jvmti 所定義的事件;更復雜的有 JVMTI 的一些需要用結構體表示的數據結構,比如堆的信息(jvmtiStackInfo)。這些數據結構在文檔中都有清楚的定義,本文就不再詳細解釋。
一個簡單的 Agent 實現
下面將通過一個具體的例子,來闡述如何開發一個簡單的 Agent 。這個 Agent 是通過 C++ 編寫的(讀者可以在最後下載到完整的代碼),他通過監聽 JVMTI_EVENT_METHOD_ENTRY 事件,注冊對應的回調函數來響應這個事件,來輸出所有被調用函數名。有興趣的讀者還可以參照這個基本流程,通過 JVMTI 提供的豐富的函數來進行擴展和定制。
Agent 的設計
具體實現都在 MethodTraceAgent 這個類裡提供。按照順序,他會處理環境初始化、參數解析、注冊功能、注冊事件響應,每個功能都被抽象在一個具體的函數裡。
class MethodTraceAgent
{
public:
void Init(JavaVM *vm) const throw(AgentException);
void ParseOptions(const char* str) const throw(AgentException);
void AddCapability() const throw(AgentException);
void RegisterEvent() const throw(AgentException);
...
private:
...
static jvmtiEnv * m_jvmti;
static char* m_filter;
};
Agent_OnLoad 函數會在 Agent 被加載的時候創建這個類,並依次調用上述各個方法,從而實現這個 Agent 的功能。
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved)
{
...
MethodTraceAgent* agent = new MethodTraceAgent();
agent->Init(vm);
agent->ParseOptions(options);
agent->AddCapability();
agent->RegisterEvent();
...
}
運行過程如圖 1 所示:
圖 1. Agent 時序圖
Agent 編譯和運行
Agent 的編譯非常簡單,他和編譯普通的動態鏈接庫沒有本質區別,只是需要將 JDK 提供的一些頭文件包含進來。
Windows: cl /EHsc -I${JAVA_HOME}\include\ -I${JAVA_HOME}\include\win32
-LD MethodTraceAgent.cpp Main.cpp -FeAgent.dll
Linux: g++ -I${JAVA_HOME}/include/ -I${JAVA_HOME}/include/linux
MethodTraceAgent.cpp Main.cpp -fPIC -shared -o libagent.so
在附帶的代碼文件裡提供了一個可運行的 Java 類,默認情況下運行的結果如下圖所示:
圖 2. 默認運行輸出
現在,我們運行程序前告訴 Java 先加載編譯出來的 Agent:
java -agentlib:Agent=first MethodTraceTest
這次的輸出如圖 3. 所示:
圖 3. 添加 Agent 後輸出
可以當程序運行到到 MethodTraceTest 的 first 方法是,Agent 會輸出這個事件。“ first ”是 Agent 運行的參數,如果不指定話,所有的進入方法的觸發的事件都會被輸出,如果讀者把這個參數去掉再運行的話,會發現在運行 main 函數前,已經有非常基本的類庫函數被調用了。
結語
Java 虛擬機通過 JVMTI 提供了一整套函數來幫助用戶檢測管理虛擬機運行態,它主要通過 Agent 的方式實現與用戶的互操作。本文簡單介紹了 Agent 的實現方式和 JVMTI 的使用。通過 Agent 這種方式不僅僅用戶可以使用,事實上,JDK 裡面的很多工具,比如 Instrumentation 和 JDI, 都采用了這種方式。這種方式無需把這些工具綁定在虛擬機上,減少了虛擬機的負荷和內存占用。在下一篇中,我們將介紹 JDWP 如何采用 Agent 的方式,定義自己的一套通信原語,並通過多種通信方式,對外提供基本調試功能。
本文配套源碼