多線程環境下的程序調試是讓開發者頭痛的問題。在 IDE 中通過添加斷點的 方式調試程序,往往會因為停在某一條線程的某個斷點上而錯失了其他線程的執 行,線程之間的調度往往無法預期,並且會因為斷點影響了實際的線程執行順序 。因此,在調試多線程程序時,開發者往往會選擇打印 Trace Log 的方式來幫 助調試。
使用 Log 來幫助調試的問題在於,開發者往往無法預期哪些關鍵點需要記錄 ,於是在整個程序的調試過程中,需要不斷的加入 Log 調用,編譯生成可執行 程序並部署,這對於大尺寸的軟件開發項目無疑是噩夢,會直接影響到開發效率 。
有沒有一種辦法,可以獨立於程序代碼,能在運行期間綁定到程序上並獲取 程序運行過程當中的關鍵信息呢?更重要的,這種方法應該是可定制的,開發者 可以通過少量的努力,就可以達到特定的調試目的。答案是肯定的。通過使用 java Debug Interface(JDI),開發者可以快速開發定制出適用於自己的線程 Profiling 工具。這樣的工具獨立於主程序,並且可高度定制。在接下來的文章 中,我們將介紹如何實現該工具。
認識 JPDA 和 JDI
從 J2SE 1.3 開始,Java 開始提供了一套叫做 Java Platform Debugger Architecture(JPDA)的架構,開發者可以通過這套架構來開發調試用程序。這 套架構被主流的 Java IDE(如 Eclipse、NetBeans 等)廣泛地采用。
具體來說,JPDA 不僅僅是一套 API 的組合,也不只是一個具體的工具。這 套架構提供了從目標程序、調試雙方的信息協議,到供開發者使用的結構調用, 都一一做出了定義。在 J2SE 5.0 中,它由三個部分組成:
Java Virtual Machine Tools Interface(JVMTI),是一套低級別的 native 接口。它定義了 Java 虛擬機所必需為調試提供的服務接口。JVMTI 在 Java 5.0 之前的前身是 JVMDI(Jave Virtual Machine Debug Interface)。
Java Debug Wire Protocol(JDWP),定義了調試雙方信息和請求的文本格 式。
Java Debuger Interface(JDI),定義了代碼級別的調試接口。
從開發者的角度來看,調試工具的開發既可以基於 JVMTI 也可以基於 JDI。 JVMTI 是 native 接口,使用起來相對復雜,並且需要 C 語言的基礎,因此, 在本文中,我們將介紹如何使用 JDI 這種最上層的方式來開發 Java 調試程序 。
需求分析
在接下的部分,我們將介紹如何使用 JDI 來開發一個用來調試多線程程序的 工具。在開始前,讓我們先列出這個工具需要滿足的功能:
獨立於目標應用程序的。
應該足夠簡單,並且能在通過少量的代碼修改就能完成集中配置,這樣是幫 助開發者不需要付出太多的努力就能開始調試自己的多線程程序。
能夠抓取足夠的信息,比如說異常的信息,程序調用過程中的變量值等等。
所生成的 Log 應該足夠清晰,能夠按不同的線程來分離記錄,而不是按照時 間的順序來生成每一條記錄,否則會給調試帶來不便。
實現
在文章最後的 示例代碼 中,我們展示了一個典型的基於 JDI 的調試工具邏 輯,並且用它來 Profile 一個簡單的多線程程序的執行。根據前面所提到的需 求,代碼展示了線程運行棧快照、方法調用的入口參數值收集、異常過濾定制、 類過濾配置、線程 Log 記錄等功能。具體來說:
獨立於目標程序
分析工具可以通過如下方式啟動:
java Trace options class args
支持的 options 參數:
-output 文件名:工具生成的 Log 的路徑
class 是目標程序的入口類,args 為目標程序的輸入參數
簡潔配置
異常過濾配置:
您可以在 ExceptionConfig.properties 屬性文件中配置所需記錄異常類型 。在 Demo 代碼中配置了對於 NullPointerException 和 UserDefinedException 兩種異常,分析工具將追蹤這兩種異常情況。
ExceptionName = exceptions.UserDefinedException;java.lang.NullPointerException
類過濾配置:
您可以在 ClassExcludeConfig.properties 屬性文件中配置被過濾的類模式 ,分析工具將不會處理被過濾類的任何事件。
ExcludedClassPattern=java.*;javax.*;sun.*;com.sun.*;com.ibm.*
運行
在目標的主程序的生命周期中,分析器完成以下操作:
綁定,分析工具和目標調試程序的虛擬機實例綁定;
事件注冊,分析工具向虛擬機實例注冊相關事件請求,整個分析過程采取基 於事件驅動的模式。
線程運行時信息挖掘。
分類信息生成。
以上四點操作滿足了需求:通過采用綁定機制實現調試程序和工具程序的獨 立,分析工具和目標程序以監聽端口、共享內存等方式進行通信,無須目標程序 進行任何代碼修改即可實現調試。采用基於事件的機制可以幫助開發者依據實際 需要集中注冊和處理事件。作為基礎框架,分析工具注冊了支持異常、執行流程 等事件,並提供了異常時運行棧快照,方法進出參數記錄等功能實現信息抓取。 支持單線程為單位的 Log 記錄,將開發者從無序不可預測的多線程執行中擺脫 出來,對調試程序提供幫助。
下面將詳細闡述實現步驟:
綁定
JDI 支持四種對目標程序的綁定方式,分別為:
分析器啟動目標程序虛擬機實例
分析器綁定到已運行的目標程序虛擬機實例
目標程序虛擬機實例綁定到已運行的分析器
目標程序虛擬機實例啟動分析器
JDI 支持一個分析器綁定多個目標程序,但一個目標程序只能綁定一個分析 器。為支持以上綁定,JDI 對應有 LaunchingConnector,AttachingConnector 和 ListeningConnector,具體類介紹可以參照 文檔。
本文采用第一種綁定方式闡述如何開發定制的多線程分析器,其它綁定方式 可以參照 文檔。
綁定過程分為三個步驟:
獲取連接實例
清單 1. 獲取連接實例
LaunchingConnector findLaunchingConnector() {
List connectors = Bootstrap.virtualMachineManager ().allConnectors();
Iterator iter = connectors.iterator();
while (iter.hasNext()) {
Connector connector = (Connector) iter.next();
if ("com.sun.jdi.CommandLineLaunch".equals(connector.name ())) {
return (LaunchingConnector) connector;
}
}
}
Bootstrap.virtualMachineManager().allConnectors() 返回所有已知的 Connector 對象實例。選擇返回 com.sun.jdi.CommandLineLaunch 連接實例, 表示使用第一種綁定方式。
設置連接參數
清單 2. 設置連接參數
/**參數:
* connector為清單1.中獲取的Connector連接實例
* mainArgs為目標程序main函數所在的類
**/
Map connectorArguments(LaunchingConnector connector, String mainArgs) {
Map arguments = connector.defaultArguments();
Connector.Argument mainArg = (Connector.Argument) arguments.get("main");
if (mainArg == null) {
throw new Error("Bad launching connector");
}
mainArg.setValue(mainArgs);
return arguments;
}
每個連接實例都有對應的默認參數,啟動連接之前需要設置必須的參數,對 於 CommandLineLaunch 連接實例需要設置主程序啟動目標程序虛擬機實例所需 的參數。
啟動連接,獲取目標程序虛擬機實例
清單 3. 啟動連接
/**參數:
* mainArgs為目標程序main函數所在的類
**/
VirtualMachine launchTarget(String mainArgs) {
//findLaunchingConnector:獲取連接
LaunchingConnector connector = findLaunchingConnector();
//connectorArguments:設置連接參數
Map arguments = connectorArguments(connector, mainArgs);
try {
return connector.launch(arguments);//啟動連接
} catch (IOException exc) {
throw new Error("Unable to launch target VM: " + exc);
} catch (IllegalConnectorArgumentsException exc) {
throw new Error("Internal error: " + exc);
} catch (VMStartException exc) {
throw new Error("Target VM failed to initialize: " + exc.getMessage());
}
}
清單 1 和清單 2 分別獲取連接實例和啟動所需的變量,通過調用 connector.launch(arguments) 啟動連接,實現了分析器和目標程序的綁定。
注冊事件
分析器和目標程序之間采用基於事件的模式進行通信。分析器向虛擬機實例 注冊所關注的事件。事件發生時,虛擬機將相關事件信息放入事件隊列中,采用 生產者 - 消費者 的模式與分析器同步。
注冊事件
EventRequestManager 管理事件請求,它支持創建、刪除和查詢事件請求。 EventRequest 支持三種掛起策略:
EventRequest.SUSPEND_ALL : 事件發生時,掛起所有線程
EventRequest.SUSPEND_EVENT_THREAD : 事件發生時,掛起事件源線程
EventRequest.SUSPEND_NONE : 事件發生時,不掛起任何線程
JDI 支持多種類型的 EventRequest,如 ExceptionRequest, MethodEntryRequest,MethodExitRequest,ThreadStartRequest 等,可以參考 文檔。
清單 4. 注冊事件
EventRequestManager mgr = vm.eventRequestManager();
// 注冊異常事件
ExceptionRequest excReq = mgr.createExceptionRequest(null, true, true);
excReq.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD);
excReq.enable();
// 注冊進方法事件
MethodEntryRequest menr = mgr.createMethodEntryRequest();
menr.setSuspendPolicy(EventRequest.SUSPEND_NONE);
menr.enable();
// 注冊出方法事件
MethodExitRequest mexr = mgr.createMethodExitRequest();
mexr.setSuspendPolicy(EventRequest.SUSPEND_NONE);
mexr.enable();
// 注冊線程啟動事件
ThreadStartRequest tsr = mgr.createThreadStartRequest();
tsr.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD);
tsr.enable();
// 注冊線程結束事件
ThreadDeathRequest tdr = mgr.createThreadDeathRequest();
tdr.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD);
tdr.enable();
分析器從事件隊列中獲取事件
EventQueue 用來管理目標虛擬機實例的事件,事件會被加入 EventQueue 中 。分析器調用 EventQueue.remove(),如果事件隊列中存在事件,則返回不可修 改的 EventSet 實例,否則分析器會被掛起直到有新的事件發生。處理完 EventSet 中的事件後,調用其 resume() 方法喚醒 EventSet 中所有事件發生 時可能掛起的線程。
清單 5. 獲取事件
public void run() {
EventQueue queue = vm.eventQueue();
while (connected) {
try {
EventSet eventSet = queue.remove();
EventIterator it = eventSet.eventIterator();
while (it.hasNext()) {
handleEvent(it.nextEvent());
}
eventSet.resume();
} catch (InterruptedException exc) {// Ignore
} catch (VMDisconnectedException discExc) {
handleDisconnectedException();
break;
}
}
}
獲取多線程執行信息
執行流程和變量信息是調試程序最重要的兩方面。無論是通過 IDE 設置斷點 的調試方式,還是通過在程序中記 Log 的調試方式,它們的主要目的是向開發 者提供以上兩方面信息。本文分析器以單個線程為單位,來記錄線程運行信息:
執行流程。分析器以方法作為最小顆粒度單位。分析器按照實際的線程執行 順序記錄方法進出。
變量值。對於單個方法而言,其程序邏輯固定,方法的輸入值決定了方法內 部執行流程。分析器將在方法入口和出口分別記錄該方法作用域內可見變量,便 於開發者調試。
執行棧信息記錄。當異常發生時,執行棧中完好地保存了調用幀信息。分析 器獲取線程棧中的所有幀,並記錄每個幀記錄的信息,其中包含可見變量值、幀 調用名稱等信息。StackFrame 中變量信息的獲取也是 JDI 所提供的特殊能力之 一。
與 IDE 設置斷點的方法相比,提供的數據信息量相當,但分析器提供執行流 程信息更加的清晰;與在程序中記錄 Log 的方式相比,分析器在執行流程和信 息量兩方面都勝出。
以下將詳細介紹上面三方面信息抓取:
線程執行流程
線程執行流程可劃分:線程啟動→ run() →進入方法→ ... →退出方法→ 線程結束。通過向虛擬機實例注冊 ThreadStartRequest,MethodEntryRequest ,MethodExitRequest 和 ThreadDeathRequest 事件的方式記錄執行過程。事件 注冊詳細見清單 4,清單 6 列出分析器對於以上事件的處理方法。
清單 6. 獲取執行流程
void threadStartEvent(ThreadStartEvent event) {
println("Thread " + event.thread().name() + " Start");
}
void methodEntryEvent(MethodEntryEvent event) {
println("Enter Method:" + event.method().name() + " -- "
+ event.method().declaringType().name());
// 進入方法記錄可見變量值
this.printVisiableVariables();
}
void methodExitEvent(MethodExitEvent event) {
println("Exit Method:" + event.method().name() + " -- "
+ event.method().declaringType().name());
// 退出方法記錄可見變量值
this.printVisiableVariables();
}
void threadDeathEvent(ThreadDeathEvent event) {
println("Thread " + event.thread().name() + " Dead");
}
可見變量信息抓取
清單 7. 可見變量信息抓取
private void printVisiableVariables()
{
try{
this.thread.suspend();
if(this.thread.frameCount()>0) {
//獲取當前方法所在的幀
StackFrame frame = this.thread.frame(0);
List<LocalVariable> lvs = frame.visibleVariables();
for (LocalVariable lv : lvs) {
println("Name:" + lv.name() + "\t" + "Type:"
+ lv.typeName() + "\t" + "Value:"
+ frame.getValue(lv));
}
}
} catch(Exception e){//ignore}
finally{this.thread.resume();}
}
通過 this.thread.frame(0) 獲取當前方法對應的幀,調用 frame.visibleVariables() 取出當前方法幀的所有可見變量。
異常時線程棧快照
清單 8. 異常事件線程棧快照
private void printStackSnapShot() {
try {
this.thread.suspend();
//獲取線程棧
List<StackFrame> frames = this.thread.frames ();
//獲取線程棧信息
for (StackFrame frame : frames) {
if (frame.thisObject() != null) {
//獲取當前對象應該的所有字段信息
List<Field> fields = frame.thisObject ().referenceType().allFields();
for (Field field : fields) {
println(field.name() + "\t" + field.typeName()+ "\t"
+ frame.thisObject().getValue(field));
}
}
//獲取幀的可見變量信息
List<LocalVariable> lvs = frame.visibleVariables();
for (LocalVariable lv : lvs) {
println(lv.name() + "\t" + lv.typeName() + "\t"
+ frame.getValue(lv));
}
}
} catch (Exception e) {}
finally { this.thread.resume();}
}
通過 this.thread.frames() 獲取異常發生時線程棧中所有幀信息,調用 frame.thisObject() 獲取對 this 指針的引用,進而獲取對象字段信息;對於 幀信息的抓取與清單 7 類似。
分類信息生成 Log
以單線程為記錄單元是分析器的特點,下面將從分析器 Log 實現結構、目標 程序所模擬的場景及分析結果三方面對示例代碼進行介紹。
分析器 Log 實現結構
Trace 為分析器入口類,它負責創建綁定連接,生成目標程序虛擬機實例; EventThread 負責從虛擬機實例的事件隊列中獲取事件,交由對應的 ThreadTrace 處理,它同時維護著一張 ThreadReference 和 ThreadTrace 一一 對應關系的映射表;ThreadTrace 負責分析 ThreadReference 信息,並將結果 記錄在 logRecord 的緩存中,每個 ThreadTrace 實現了單個線程信息的追蹤, 詳見圖 1。
圖 1. 分析器類圖 :
目標程序
目標程序由兩個核心類組成:MainThread 和 CounterThread。MainThread 是程序的主類,它負責啟動兩個 CounterThread 線程實例並拋出兩類異常:用 戶自定義異常 UserDefinedException 和運行時異常 NullPointerException; CounterThread 是一個簡單的計數線程。整個目標程序模擬的是多線程和異常的 環境。
分析結果
Log 依照目標程序的調用層次進行縮進,清晰地展現每個線程的執行邏輯和 變量信息,詳見清單 9。為了方便理解,我們在 log 中加入了注釋。
清單 9. Log
-- VM Started --
====== main ======
Enter Method:main//
Enter Method:<init>//MainThread 構造函數
a int 0
b int 0
c int 0
Exit Method:<init>
Enter Method:makeABusinessException//makeABusinessException 方法調用
a int 0
b int 1
c int 2
Enter Method:<init>//UserDefinedException 構造函數
...
Exit Method:<init>
//UserDefinedException 異常發生,抓取線程棧中所有幀信息
exceptions.UserDefinedException(id=62) catch: MainThread:30
Frame(MainThread:44)
a int 0
b int 1
c int 2
i int 0
d int 4
Frame(MainThread:23)
e int 4
g int 5
mt MainThread instance of MainThread(id=59)
i int 0
// NullPointerException 異常發生,抓取線程棧信 息
java.lang.NullPointerException(id=70) catch: MainThread:30
...
// 以下是兩個 CounterThread 線程的構造
Enter Method:<init>
name java.lang.String null
index int 0
Exit Method:<init>
Enter Method:<init>
name java.lang.String null
index int 0
Exit Method:<init>
Exit Method:main
====== main end ======
====== Thread-1 ======
Enter Method:run//run 方法調用
name java.lang.String "thread1"
index int 0
// 以下是 3 次 updateIndex 方法調用
Enter Method:updateIndex
name java.lang.String "thread1"
index int 0
Exit Method:updateIndex
Enter Method:updateIndex
name java.lang.String "thread1"
index int 2
Exit Method:updateIndex
Enter Method:updateIndex
name java.lang.String "thread1"
index int 4
Exit Method:updateIndex
Exit Method:run
====== Thread-1 end ======
====== Thread-2 ======
Enter Method:run//run 方法調用
name java.lang.String "thread2"
index int 0
// 以下是 3 次 updateIndex 方法調用
Enter Method:updateIndex
name java.lang.String "thread2"
index int 1
Exit Method:updateIndex
Enter Method:updateIndex
name java.lang.String "thread2"
index int 3
Exit Method:updateIndex
Enter Method:updateIndex
name java.lang.String "thread2"
index int 5
Exit Method:updateIndex
Exit Method:run
====== Thread-2 end ======
結語
當開發多線程程序時,至少有兩個理由讓你選擇 JDI 來協助調試:
線程執行的時序變得越來越不可預測,在 IDE 中通過添加斷點來調試的方法 已經不能正確地反映程序運行狀況。
程序規模大,每一次 trace 語句的添加都會造成程序的再編譯,而這樣的編 譯需要花上很多時間。
因此,使用 JDI 開發自己的調試程序,有時會為開發者節省更多的時間。通 過本文的介紹和示例代碼的解讀,讀者可以著手開發自己的多線程調試程序了。
本文配套源碼