隨著多核 CPU 的日益普及,越來越多的 Java 應用程序使用多線程並行計算來充分發揮整個系統的性能。多線程的使用也給應用程序開發人員帶來了巨大的挑戰,不正確地使用多線程可能造成線程死鎖或資源競爭,導致系統癱瘓。因此,需要一種運行時線程監控工具來幫助開發人員診斷和跟蹤 Java 線程狀態的切換。JDK 1.5 及其後續版本提供了監控虛擬機運行狀態的接口 JVMTI。
JVMTI 工具接口
隨著多核 CPU 技術的發展,多線程編程技術被廣泛地應用,從而充分發揮整個系統的性能。Java 語言對多線程編程提供了語言級的支持,可以方便地創建、運行、銷毀線程。然而,多線程的使用也給應用程序開發人員帶來了巨大的挑戰,不正確地使用多線程可能造成線程死鎖或資源競爭,導致系統癱瘓。
為了幫助 Java 開發人員診斷和跟蹤 Java 線程狀態的切換,Sun 公司在 Java 開發工具包(Java2 Software Development Kit, JDK)1.5.0 版本中引進了 Java 虛擬機工具接口(Java Virtual Machine Toolkit Interface,JVMTI),用於替代在先前的 JDK 版本中作為試驗功能存在的 Java 虛擬機剖析接口(Java Virtual Machine Profiling Interface,JVMPI)和 Java 虛擬機調試接口(Java Virtual Machine Debugging Interface,JVMDI)。通過 JVMTI 接口可以創建代理程序(Agent)以監視和控制 Java 應用程序,包括剖析、調試、監控、分析線程等等,其架構模型如圖 1 所示。
圖 1. JVMTI 架構模型
Agent 可以向運行中的虛擬機實例訂閱感興趣的事件,當這些事件發生的時候,會以事件回調函數的方式激活代理程序,同時 JVMTI 提供了眾多的功能函數,以查詢和控制 Java 應用程序的運行狀態。Agent 通過 JVMTI 所提供的接口與虛擬機進行通信,並同步監控虛擬機的運行狀態,它與運行中的 Java 應用程序是相對獨立的,不會干擾程序的正常運行。Agent 可以用任何支持 C 語言標准的本地語言來編寫,並以動態鏈接庫的方式存在;Java 程序啟動的時候可以加載這個動態鏈接庫。
基於 JVMTI 接口構建的 Agent 可以方便地實現對 Java 線程狀態切換的跟蹤,從而使開發人員能夠在運行時清楚地了解多線程應用程序中線程的工作情況,方便進行調試和除錯。本文後續部分將介紹如何基於 JVMTI 接口構建 Java 線程切換監控代理。
Java 線程模型
要對 Java 線程的切換進行監控,必須先了解 JVM 中的 Java 線程模型。Java 線程模型可以用圖 2 所示的 Java 線程生命周期來描述。Java 線程的生命周期包括創建,就緒,運行,阻塞,死亡 5 個狀態。一個 Java 線程總是處於這 5 個生命周期狀態之一,並在一定條件下可以在不同狀態之間進行轉換 。
圖 2. Java 線程模型
創建狀態 (New Thread)
在 Java 語言中使用 new 操作符創建一個線程後,該線程僅僅是一個空對象,它具備了線程的一些特征,但此時系統沒有為其分配資源,這時的線程處於創建狀態。
就緒狀態 (Runnable)
使用 start() 方法啟動一個線程後,系統為該線程分配了除 CPU 外的所需資源,使該線程處於就緒狀態。此外,如果某個線程執行了 yield() 方法,那麼該線程會被暫時剝奪 CPU 資源,重新進入就緒狀態。
運行狀態 (Running)
Java 運行系統通過調度選中一個處於就緒狀態的線程,使其占有 CPU 並轉為運行狀態。此時,系統真正執行線程的 run() 方法。
阻塞狀態 (Blocked)
一個正在運行的線程因某些原因不能繼續運行時,它就進入阻塞狀態。這些原因包括:當執行了某個線程對象的 suspend()、sleep() 等阻塞類型的方法時,該線程對象會被置入一個阻塞集(Blocked Pool)內,等待被喚醒(執行 resume() 方法)或是因為超時而時自動蘇醒;當多個線程試圖進入某個同步區域(synchronized)時,沒能進入該同步區域的線程會被置入鎖定集(Lock Pool),直到獲得該同步區域的鎖,進入就緒狀態;當線程執行了某個對象的 wait() 方法時,線程會被置入該對象的等待集(Wait Pool)中,直到執行了該對象的 notify() 方法,wait()/notify() 方法的執行要求線程首先獲取到該對象的鎖。
死亡狀態 (Dead)
線程在 run() 方法執行結束後進入死亡狀態。此外,如果線程執行了 interrupt() 或 stop() 方法,那麼它也會以異常退出的方式進入死亡狀態。
Java 線程切換的上下文信息
通過對 Java 線程模型的分析,可以將 Java 線程切換所涉及的上下文信息分為顯式線程切換信息和隱式線程切換信息。在 Java 線程切換時通過 JVMTI 接口捕獲線程切換的上下文信息,即可實現對 Java 線程切換的監控。
顯式線程切換信息
顯式線程切換是指一個線程對象顯式地對另一個線程對象進行操作(調用線程方法),使目標線程的狀態發生變換;其上下文信息可以用三元組 < 操作線程,動作,被操作線程 > 來描述。例如,Java 線程的啟動上下文就可以描述為 <Thread-Caller, start, Thread-Callee>。從 Java 線程模型可知,Java 中線程的創建是通過派生 Thread 類或 Runnable 接口來實現的,並通過調用 Thread 類中的 start() 方法來啟動新建立的線程對象;因此,通過監控 start() 方法的調用,並結合對 Java 方法調用堆棧的分析,就可以分析出線程啟動的上下文信息。根據 Java 線程模型可知,顯式線程切換共涉及到 start()、interrupt()、join()、resume()、suspend()、sleep()、yield() 和 stop() 這幾個線程方法。
隱式線程切換信息
在某些情況下,線程的切換並不是通過線程之間直接調用線程方法進行的,而是通過線程間通信的方式進行。這種由線程間通信產生的上下文信息,稱之為隱式線程切換信息。一個典型的例子就是 Java 線程池。通常,線程池內存儲了若干個預先創建好的線程,這項線程處於阻塞狀態,並不處理任務;當某個任務到來的時候,負責任務調度的線程會喚醒線程池中某個處於阻塞狀態的空閒線程處理這個任務;一旦任務完成,執行該任務的線程不會被撤銷,而是繼續處於阻塞狀態,並被重新置回線程池中,等待下一次調度。雖然 Java 線程池的實現方式不盡相同,但究其本質都是通過 Java 中 Object 類的線程通信原語 wait() 和 notify()/notifyAll() 方法來實現的。wait() 方法會使線程在指定目標上等待,而 notify() 方法則使等待在指定目標上的某個線程蘇醒,具體哪個線程真正被喚醒取決於虛擬機的實現方式, notifyAll() 方法則是使所有的線程都蘇醒。隱式線程切換信息同樣可以用三元組 < 操作線程,動作,被操作線程 > 來描述,但該三元組的確立比顯式線程切換要復雜,需要通過分析線程通信原語執行的上下文信息來得到。
下一節將介紹如何獲取線程切換的上下文信息。
Java 線程切換的監控模型
根據 Java 線程切換機制設計的 Java 線程監控模型如圖 3 所示。線程監控代理 Agent 通過 JVMTI 在方法調用者和被調用者之間注冊一個事件監聽器,用於監聽線程切換事件(即引起線程切換的方法調用)。當某個線程方法即將被調用的時候,監聽器發送方法進入事件(Method Entry)到 Agent 的 Before Advice 回調方法;當某個線程方法返回的時候,監聽器發送方法返回(Method Exit)事件到 Agent 的 After Advice 回調方法。Before Advice 和 After Advice 分別負責處理線程方法調用前的線程狀態和線程方法調用後的線程狀態。這樣,線程監控代理就能夠監控到 Java 線程的狀態切換情況。
圖 3. Java 線程監控模型
獲取顯式線程切換的上下文信息
在 Java 方法調用發生的時候,通過 JVMTI 接口能夠很輕易的獲取到方法名稱和執行該方法的線程標識。因此,確定線程切換上下文三元組 < 操作線程,動作,被操作線程 > 的關鍵在於,如何獲取被操作線程對象標識,而這需要對 Java 方法的調用機制進行分析。
每當啟動一個新線程的時候,Java 虛擬機會為它分配一個 Java 棧。Java 棧以幀(Frame)為單位保存線程的運行狀態。虛擬機只會對 Java 棧執行兩種操作:以幀為單位的入棧和出棧。
當線程調用一個 Java 方法時,虛擬機會在該線程所在的 Java 棧壓入一個新的棧幀(Stack Frame),用於存儲該 Java 方法的地址;該方法被稱為當前方法,該棧幀被成為當前棧幀。棧幀通常由局部變量區、操作數棧和幀數據區組成。在執行當前方法時,它使用當前棧幀來存儲參數、局部變量、中間運算結果等等數據。棧幀在方法調用的時候被創建,並在方法完成的時候銷毀。
通過對棧幀的進一步研究發現,當一個對象的某個實例方法執行時,Java 虛擬機會隱式地在該方法的當前棧幀的局部變量區加入一個指向該對象的引用。盡管在方法源代碼中並沒有顯式聲明這個參數,但這個參數對於任何一個實例方法都是由 JVM 隱含加入的,而且它始終在局部變量區的首位。局部變量區是根據索引進行尋址的,第一個局部變量的索引是 0,因此可以使用局部變量區索引 0 的方式來訪問這個對象引用,如表 1 所示。
表 1. 局部變量區
索引 參數 0 對象引用 1 局部變量 1 2 局部變量 2 …… ……
如此一來,當被操作線程對象執行某個線程方法的時候,可以通過分析當前操作線程的當前棧幀的本地方法區獲取到被操作線程對象的引用。這樣,就可以完整地確定顯式線程切換的三元組 < 操作線程,動作,被操作線程 > 信息。
需要注意的是,有一些線程方法,例如 sleep() 和 yield() 方法,它們是作為本地方法(Native Method)來實現的,它們在被調用的過程中不會生成 Java 棧中的當前方法幀,而是將信息保存在本地方法棧(Native Stack)內。因此,對這些方法引起的線程切換不能直接采用上述分析方法,而是應該分析本地方法棧。
獲取隱式線程切換的上下文信息
類似的,獲取隱式線程切換上下文信息的關鍵也是確定三元組 < 操作線程,動作,被操作線程 > 中的被操作線程對象標識,而這需要對 Java 線程的同步機制進行分析。
Java 使用名為監視器(Monitor)的同步機制來調節多個線程的活動和共享數據,如圖 4 所示。Java 中,每個對象都對應一個監視器,即使在多線程的情況下,該監視器監視的區域同一時間只會被一個線程執行。一個線程想要執行監視區域的代碼,唯一的途徑就是獲得該區域對應的監視器。當一個線程到達了一個監視區域的開始處(Entry Point),它就會被置入該監視器的請求集(Entry Pool)。如果此時沒有其他線程在請求集中等待,並且也沒有其它線程正持有該監視器(Monitor Hold),那麼該線程就可以獲得監視器,並繼續執行監視區域中的代碼。當這個線程執行完監視區域代碼後,它就會退出並釋放該監視器(Release Monitor)。如果線程在執行監視區域代碼的過程中,執行了 wait() 方法,那麼該線程會暫時釋放該監視器,並被置入等待集(Wait Pool),等待被喚醒;如果線程在這個過程中執行了 notify() 方法,那麼在等待集中的某個線程就會被標記為即將蘇醒,這個即將蘇醒的線程將在某個監視器空閒的時刻獲取該監視器,並執行該監視區域代碼,具體哪個線程被喚醒視虛擬機的實現方式而定。notifyAll() 方法會將等待集中的所有線程標記為即將蘇醒,並接受虛擬機的調度。一個線程只有在它已經持有監視器時才能執行 wait() 方法,並且它只能通過再次成為監視器的持有者才能離開等待集。
圖 4. Java 線程的同步機制
根據上述對 Java 線程同步機制的分析,如果能夠在執行 wait()/notify()/notifyAll() 方法的前後分別獲取其監視器等待集的狀態快照並加以比較,就可以得出是哪個或者是哪些線程被喚醒,而這些線程就是這次隱式線程切換的被操作線程。這樣,就可以確定線程切換的三元組 < 操作線程,動作,被操作線程 > 信息。
此外,由於一個線程可能同時擁有多個對象監視器,因此必須對每個監視器上的等待集進行分析,以確定當前執行的 wait()/notify()/notifyAll() 方法所真正作用的監視器。
實現 Java 線程監控代理
本節介紹了如何依據 Java 線程切換監控模型,實現 Java 線程監控代理(Agent)。
線程監控代理是一個基於 JVMTI 的 C 語言實現,它可以從虛擬機運行時實例中捕獲運行中的 Java 應用程序的線程切換信息,同時不影響該程序的運行。
初始化監控代理
首先,監控代理必須包括一個 JVMTI 的頭文件,主要代碼片段如清單 1 所示。該頭文件包含 JVMTI 必須的一些定義,通常它存在於 JDK 安裝目錄的 include 目錄下。
其次,需要對代理進行初始化,這個工作由 Agent_OnLoad() 方法完成。這個方法主要用於在初始化 Java 虛擬機之前設置所需的功能(Capabilities)、注冊事件通知(Event Notification)和指定事件回調函數(Callback Method)。其中,GetEnv() 方法用於獲取當前的虛擬機運行環境;AddCapabilities() 方法用於設置線程監控代理所需要的功能,包括方法的調用和返回事件、訪問方法局部變量、獲取對象或線程擁有的監視器信息;SetEventNotificationMode() 方法用於捕獲每個實例方法的調用和返回的事件通知;SetEventCallbacks() 方法用於注冊相應的方法進入回調函數和方法退出回調函數。
清單 1. 初始化 Agent
include "jvmti.h"
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *jvm, char *options, void *reserved) {
jvm->GetEnv((void **) &gb_jvmti, JVMTI_VERSION_1_0);
gb_capa.can_generate_method_exit_events = 1;
gb_capa.can_access_local_variables=1;
gb_capa.can_get_monitor_info=1;
gb_jvmti->AddCapabilities(&gb_capa);
callbacks.MethodEntry = &callbackMethodEntry;
callbacks.MethodExit = &callbackMethodExit;
gb_jvmti->SetEventCallbacks(&callbacks, sizeof(callbacks));
gb_jvmti->SetEventNotificationMode(JVMTI_ENABLE,JVMTI_EVENT_METHOD_ENTRY,NULL);
gb_jvmti->SetEventNotificationMode(JVMTI_ENABLE,JVMTI_EVENT_METHOD_EXIT,NULL);
return JNI_OK;
}
監控顯式線程切換
顯式線程切換的監控通過 callbackMethodEntry() 回調方法完成,主要代碼片段如清單 2 所示。該回調方法使用 RawMonitorEntry() 和 RawMonitorExit() 方法設定原始監視器的監視區域,監視引起顯式線程切換的線程方法,例如 start()、interrupt()、join()、resume() 等。
當上述某個線程方法即將被調用時,先用 GetObjectHashCode() 方法計算當前操作線程的哈希值,籍此唯一標識這個線程對象;然後使用 GetLocalObject() 方法獲取操作線程 Java 棧中當前方法幀的對象引用,這個引用指向了被操作線程對象。這樣就可以獲得一個顯式線程切換的上下文信息,可以被描述為:<Thread-A, start, Thread-B>、<Thread-B, resume, Thread-C> 等等。
清單 2. 監控顯式線程切換
static void JNICALL callbackMethodEntry(jvmtiEnv *jvmti_env, JNIEnv* env,
jthread thr, jmethodID method) {
gb_jvmti->RawMonitorEnter(gb_lock);
if (strcmp(name,"start")==0||strcmp(name,"interrupt")==0||
strcmp(name,"join")==0||strcmp(name,"stop")==0||
strcmp(name,"suspend")==0||strcmp(name,"resume")==0){
gb_jvmti->GetLocalObject(thr,0,0,&thd_ptr);
gb_jvmti->GetObjectHashCode(thd_ptr, &hash_code);
}
gb_jvmti->RawMonitorExit(gb_lock);
}
監控隱式線程切換
隱式線程切換的監控通過捕獲 wait()/notify()/notifyAll() 方法調用前和調用後的等待集信息來實現。
以 notify() 方法為例,當 notify() 方法即將被調用時,在 callbackMethodEntry() 方法中首先使用 GetOwnedMonitorInfo() 方法獲取當前操作線程所擁有的監視器,然後用 GetObjectMonitorUsage() 方法獲取每個監視器上等待的線程對象,並將它們保存在隱式線程切換信息鏈表中等待分析,主要代碼片段如清單 3 所示。
清單 3. 獲取 notify 方法調用前的等待集
static void JNICALL callbackMethodEntry(jvmtiEnv *jvmti_env, JNIEnv* env,
jthread thr, jmethodID method){
if(strcmp(name,"notify")==0||strcmp(name,"notifyAll")==0) {
gb_jvmti->GetOwnedMonitorInfo(thr,&owned_monitor_count,&owned_monitors_ptr);
for(int index=0;index<owned_monitor_count;index++){
jvmtiMonitorUsage *info_ptr=NULL;
info_ptr=(jvmtiMonitorUsage*)malloc(sizeof(jvmtiMonitorUsage));
gb_jvmti->GetObjectMonitorUsage(*(owned_monitors_ptr+index),info_ptr);
insertElem(&inf_head,&inf_tail,info_ptr);
}
}
}
當 notify() 方法即將完成調用時,在 callbackMethodExit() 方法中,同樣獲取當前操作線程的等待集(notify_waiters),然後與之前記錄的等待集進行比較,其中有差異的對象即為被操作線程對象。這樣就能確定隱式線程切換的上下文信息,它可以被描述為:<Thread-A, notify, Thread-B>。其主要代碼片段如清單 4 所示。
清單 4. 分析 notify 方法調用前後的等待集
static void JNICALL callbackMethodExit(jvmtiEnv *jvmti_env, JNIEnv* env,
jthread thr, jmethodID method){
//compare the two wait pools
if(info_ptr->notify_waiter_count!=inf_head->info_ptr->notify_waiter_count){
for(int i=0;i<inf_head->info_ptr->notify_waiter_count;i++){
for(int j=0;j<info_ptr->notify_waiter_count;j++){
if(inf_head->info_ptr->notify_waiters+i!=info_ptr->notify_waiters+j){
isObj=false;
break;
}
}
if(isObj==true) {
GetObjectHashCode(*(inf_head->info_ptr->notify_waiters+i), &hash_code);
insertElem(&ctx_head,&ctx_tail,thr_hash_code,hash_code,name);
}
}
}
}
測試監控代理
線程監控代理可以用任何 C 語言編譯器編譯,並以動態連接庫的形式加載到 Java 虛擬機中,監控正在運行的 Java 程序。使用下列命令行加載代理庫並運行目標 Java 應用程序:
java -agentlib:ThreadMonitor JavaThreadPoolApp
或者:
java -agentpath:/<Path>/ThreadMonitor JavaThreadPoolApp
為了演示對線程切換的監控,JavaThreadPoolApp 這個樣例程序實現了一個 Java 線程池,其中涉及到大量的線程狀態的切換。這個應用啟動的時候會初始化一個線程池,池內初始化兩個子線程,並讓它們處於 wait 狀態;當有客戶端程序需要使用線程池中的某個線程時,使用 notify 將池內的某個線程喚醒;使用完畢後,該線程重新進入 wait 狀態等待下一次調度。這些線程切換活動都可以被監控代理所監控,並產生如下輸出。結合應用程序的相關信息,可以進一步得出 JavaThreadPoolApp 客戶端應用程序使用了線程池中的 Thread-60934352 線程並執行了 3068 毫秒。
Thread-32452561, start, Thread-60934352
Thread-32452561, start, Thread-89877242
Thread-60934352, wait, Thread-60934352, active 8 ms
Thread-89877242, wait, Thread-89877242, active 9 ms
Thread-32452561, notify, Thread-60934352
Thread-60934352, wait, Thread-60934352, active 3068 ms
使用線程監控代理可以實時監控 JVM 中線程運行情況,幫助開發人員診斷多線程應用中可能存在的線程調度問題。
總結
多線程應用程序的開發存在著諸多挑戰,例如線程死鎖、資源競爭等。因此,開發人員需要一種運行時線程監控工具來協助診斷和跟蹤 Java 線程狀態的切換。Sun 公司在 JDK 1.5.0 版本中引進了 JVMTI 接口用於實時監控和分析 JVM 運行狀態。
本文首先介紹了 JVMTI接口;然後詳細分析了 Java 線程模型,並提出了 Java 線程的監控模型;最後基於 JVMTI 接口實現了 Java 線程監控代理,以協助診斷多線程應用中可能存在的線程調度問題。
本文配套源碼