Java除錯那點事

肥俠發表於2015-12-09

該文章來自於阿里巴巴技術協會(ATA)精選文章。

Java除錯概述

程式猿都調式或者debug過Java程式碼吧?都體會過被PM,PD,測試,業務同學們圍觀debug吧?說除錯,先看看除錯嚴格定義是什麼。引用Wikipedia定義

除錯(De-bug),又稱除錯,是發現和減少計算機程式或電子儀器裝置中程式錯誤的一個過程。除錯的基本步驟:
1. 發現程式錯誤的存在
2. 以隔離、消除的方式對錯誤進行定位
3. 確定錯誤產生的原因
4. 提出糾正錯誤的解決辦法
5. 對程式錯誤予以改正,重新測試

用除錯的好處是我們就無需每次新測試都要重新編譯了,不用copy-paste一堆的System.out.println(很low但很多時候很管用有沒有?)。

更多時候我們除錯最直接簡單的辦法就是IDE,Java程式設計師用的最多的必然是Eclipse,Netbeans和IntelliJ也有各自忠實的粉絲,各有優劣。關於用IDE如何除錯可以另起一個話題再討論。
ide

除了IDE之外,JDK也自帶了一些命令列除錯工具也很方便。大家用的比較多的如下表所示:

命令 描述
jdb 命令列除錯工具
jps 列出所有Java程式的PID
jstack 列出虛擬機器程式的所有執行緒執行狀態
jmap 列出堆記憶體上的物件狀態
jstat 記錄虛擬機器執行的狀態,監控效能
jconsole 虛擬機器效能/狀態檢查視覺化工具

具體用法可以參考JDK文件,這些大家線上上除錯應用的時候用的也不少,比如一般線上load高的問題排查步驟是

  1. 先用top找到耗資源的程式
  2. ps+grep找到對應的java程式/執行緒
  3. jstack分析哪些執行緒阻塞了,阻塞在哪裡
  4. jstat看看FullGC頻率
  5. jmap看看有沒有記憶體洩露

但這個也不是今天的重點,那麼問題來了(blue fly is the strongest):這些工具如何能獲取遠端Java程式的資訊的?又是如何遠端控制Java程式的執行的? 相信有不少人和我一樣對這些工具的 實現原理 很好奇,本文就嘗試介紹下各中緣由。

Java除錯體系JPDA簡介

Java虛擬機器設計了專門的API介面供除錯和監控虛擬機器使用,被稱為Java平臺除錯體系即Java Platform Debugger Architecture(JPDA)。JPDA按照抽象層次,又分為三層,分別是

  • JVM TI – Java VM Tool Interface
    • 虛擬機器對外暴露的介面,包括debug和profile
  • JDWP – Java Debug Wire Protocol
    • 偵錯程式和應用之間通訊的協議
  • JDI – Java Debug Interface
    • Java庫介面,實現了JDWP協議的客戶端,偵錯程式可以用來和遠端被除錯應用通訊

jpda

用一個不是特別準確但是比較容易理解的類比,大家可以和HTTP做比較,可以推斷他就是一個典型的C/S應用,所以也可以很自然的想到,JDI是用TCP Socket和虛擬機器通訊的,後面會詳細再介紹。

  • IDE+JDI = 瀏覽器
  • JDWP = HTTP
  • JVMTI = RESTful介面
  • Debugee虛擬機器= REST服務端

和其他的Java模組一樣,Java只定義了Spec規範,也提供了參考實現(Reference Implementation),但是第三方完全可以參照這個規範,按照自己的需要去實現其中任意一個元件,原則上除了規範上沒有定義的功能,他們應該能正常的互動,比如Eclipse就沒有用Sun/Oracle的JDI,而是自己實現了一套(由於開源license的相容原因),因為直接用JDWP協議呼叫JVMTI是不會受GPL“汙染”的。的確有第三方除錯工具基於JVMTI做了一套除錯工具,這樣效率更高,功能更豐富,因為JDI出於遠端呼叫的安全考慮,做了一些功能的限制。使用者還可以不用JDI,用自己熟悉的C或者指令碼語言開發客戶端,遠端除錯Java虛擬機器,所以JPDA真個架構是非常靈活的。

JVMTI

JVMTI是整個JPDA中最中要的API,也是虛擬機器對外暴露的介面,掌握了JVMTI,你就可以真正完全掌控你的虛擬機器,因為必須通過本地載入,所以暴露的豐富功能在安全上也沒有太大問題。更完整的API內容可以參考JVMTI SPEC:

  • 虛擬機器資訊
    • 堆上的物件
    • 執行緒和棧資訊
    • 所有的類資訊
    • 系統屬性,執行狀態
  • 除錯行為
    • 設定斷點
    • 掛起現場
    • 呼叫方法
  • 事件通知
    • 斷點發生
    • 非同步呼叫

在JPDA的這個圖裡,agent是其中很重要的一個模組,正是他把JDI,JDWP,JVMTI三部分串聯成了一個整體。簡單來說agent的特性有

  • C/C++實現的
  • 被虛擬機器以動態庫的方式載入
  • 能呼叫本地JVMTI提供的除錯能力
  • 實現JDWP協議伺服器端
  • 與JDI(作為客戶端)通訊(socket/shmem等方式)

Code speak louder than words. 上個程式碼加註釋來解釋:

// Agent_OnLoad必須是入口函式,類似於main函式,規範規定
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved)
{
        ....
        MethodTraceAgent* agent = new MethodTraceAgent();
        agent->Init(vm);
        agent->AddCapability();
        agent->RegisterEvent();
        ...
}

   /****** AddCapability():   init():  初始化jvmti函式指標,所有功能的函式入口 *****/
    jvmtiEnv* MethodTraceAgent::m_jvmti = 0;
    jint ret = (vm)->GetEnv(reinterpret_cast<void**>(&jvmti), JVMTI_VERSION_1_0);

    /****** AddCability(): 確認agent能訪問的虛擬機器介面  *****/
    jvmtiCapabilities caps;
    memset(&caps, 0, sizeof(caps));
    caps.can_generate_method_entry_events = 1;
    // 設定當前環境
    m_jvmti->AddCapabilities(&caps);

     /******  RegisterEvent(): 建立一個新的回撥函式   *****/ 
    jvmtiEventCallbacks callbacks;
    memset(&callbacks, 0, sizeof(callbacks));
    callbacks.MethodEntry = &MethodTraceAgent::HandleMethodEntry;
    // 設定回撥函式
    m_jvmti->SetEventCallbacks(&callbacks, static_cast<jint>(sizeof(callbacks)));
    // 開啟事件監聽
    m_jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_METHOD_ENTRY, 0);

     /******  HandleMethodEntry: 註冊的回撥,獲取對應的資訊   *****/
     // 獲得方法對應的類
     m_jvmti->GetMethodDeclaringClass(method, &clazz);
     // 獲得類的簽名
     m_jvmti->GetClassSignature(clazz, &signature, 0);
     // 獲得方法名字
     m_jvmti->GetMethodName(method, &name, NULL, NULL);

寫好agent後,需要編譯,並在啟動Java程式時指定載入路徑

// 編譯動態連結庫
g++ -w -I${JAVA_HOME}/include/ -I${JAVA_HOME}/include/linux MethodTraceAgent.cpp Main.cpp -fPIC -shared -o libAgent.so

// 拷貝到 LD_LIBRARY_PATH
export LD_LIBRARY_PATH=/home/xiaoxia/lib
cp libAgent.so ~/lib

// 執行測試效果,記得load編譯的動態庫
javac MethodTraceTest.java
java -agentlib:Agent=first MethodTraceTest

Agent實現的動態連結庫其實有兩種載入方式:

  • 虛擬機器啟動初期載入 這個連結庫必須實現Agent_OnLoad作為函式入口。這種方式可以利用的介面和功能更多,因為他在被調式虛擬機器執行的應用初始化之前就被呼叫了,但是限制是必須以顯示的引數指定啟動方式,這線上上環境上是不大現實的。
 java -agentlib:<agent-lib-name>=<options> JavaClass
 //Linux從LD_LIBRARY_PATH找so檔案, Windows從PATH找該DLL檔案。
 java -agentpath:<path-to-agent>=<options> JavaClass
 //直接從絕對路徑查詢
  • 動態載入 這是更靈活的方式,Java程式可以正常啟動,如果需要,通過Sun/Orale提供的私有Attach API可以連上對應的虛擬機器,再通過JPDA方式控制,不過因為虛擬機器已經開始執行了,所以功能上會有限制。我們比較熟悉的jstack等jdk工具就是通過這種方式做的,動態庫必須實現Agent_OnAttach作為函式入口。如果有興趣理解Attach機制細節的話,可以參考這個blog,簡單來說,就是虛擬機器預設起了一個執行緒(沒錯,就是jstack時看到Signal Dispatcher這貨),專門接受處理程式間singal通知,當他收到SIGQUIT時,就會啟動一個新的socket監聽執行緒(就是jstack看到的Attach Listener執行緒)來接收命令,Attach Listener就是一個agent實現,他能處理很多dump命令,更重要的是他能再載入其他agent,比如jdwp agent。

通過Attach機制,我們能自己非常方便的實現一個jinfo或者其他jdk tools,只需通過JPS獲取pid,在通過attach api去load我們提供的agent,完整的jinfo例子也在附件裡。
jinfo

import java.io.IOException;
import com.sun.tools.attach.VirtualMachine;

public class JInfo {

    public static void main(String[] args) throws Exception {
         String pid = args[0]; 
         String agentName = "JInfoAgent"; 

         System.out.printf("Atach to Pid %s, dynamic load agent %s 
", pid, agentName);
         VirtualMachine virtualMachine = com.sun.tools.attach.VirtualMachine.attach(pid);
         virtualMachine.loadAgentLibrary(agentName, null);
         virtualMachine.detach();
    }
}

JDWP

JDWP 是 Java Debug Wire Protocol 的縮寫,它定義了偵錯程式(debugger)和被除錯的 Java 虛擬機器(debugee)之間的通訊協議。他就是同過JVMTI Agent實現的,簡單來說,他就是對JVMTI呼叫(輸入和輸出,事件)的通訊定義。

JDWP 有兩種基本的包(packet)型別:命令包(command packet)和回覆包(reply packet)。JDWP 本身是無狀態的,因此對 命令出現的順序並不受限制。而且,JDWP 可以是非同步的,所以命令的傳送方不需要等待接收到回覆就可以繼續傳送下一個命令。Debugger 和 Debugee 虛擬機器都有可能傳送命令:

  • Debugger 通過傳送命令獲取Debugee虛擬機器的資訊以及控制程式的執行。Debugger虛擬機器通過傳送 命令通知 Debugger 某些事件的發生,如到達斷點或是產生異常。
  • 回覆是用來確認對應的命令是否執行成功(在包定義有一個flag欄位對應),如果成功,回覆還有可能包含命令請求的資料,比如當前的執行緒資訊或者變數的值。從 Debugee虛擬機器傳送的事件訊息是不需要回復的。

下圖展示了一個可能的實現方式,再次強調下,Java的世界裡只定義了規範(Spec),很多實現細節可以自己提供,比如虛擬機器就有很多中實現(Sun HotSpot,IBM J9,Google Davik)。
jdwpagent

一般我們啟動遠端除錯時,都會看到如下引數,其實表面了JDWP Agent就是通過啟動一個socket監聽來接受JDWP命令和傳送事件資訊的,而且,這個TCP連線可以是雙向的:

// debugge是server先啟動監聽,ide是client發起連線
agentlib:jdwp=transport=dt_socket,server=y,address=8000

// debugger ide是server,通過JDI監聽,JDWP Agent作為客戶端發起連線
agentlib:jdwp=transport=dt_socket,address=myhost:8000

JDI

JDI屬於JPDA中最上層介面,也是Java程式設計師接觸的比較多的。他用起來也比較簡單,參考JDI的API Doc即可。所有的功能都和JVMTI提供的除錯功能一一對應的(JVMTI還包括很多非調式介面,JDK5以前JVMTI是分為JVMDI和JVMPI的,分別對應除錯debug和調優profile)。

jdi

還是用一個例子來解釋最直接,大家可以看到基本的流程都是類似的,真個JPDA除錯的核心就是通過JVMTI的 呼叫 和事件 兩個方向的溝通實現的。

import java.util.List;
import java.util.Map;
import com.sun.jdi.*;
import com.sun.jdi.connect.*;
import com.sun.jdi.event.*;
import com.sun.jdi.request.*;

public class MethodTrace {
    private VirtualMachine vm;
    private Process process;
    private EventRequestManager eventRequestManager;
    private EventQueue eventQueue;
    private EventSet eventSet;
    private boolean vmExit = false;
    //write your own testclass
    private String className = "MethodTraceTest";

    public static void main(String[] args) throws Exception {

        MethodTrace trace = new MethodTrace();
        trace.launchDebugee();
        trace.registerEvent();

        trace.processDebuggeeVM();

        // Enter event loop
        trace.eventLoop();

        trace.destroyDebuggeeVM();

    }

    public void launchDebugee() {
        LaunchingConnector launchingConnector = Bootstrap
                .virtualMachineManager().defaultConnector();

        // Get arguments of the launching connector
        Map<String, Connector.Argument> defaultArguments = launchingConnector
                .defaultArguments();
        Connector.Argument mainArg = defaultArguments.get("main");
        Connector.Argument suspendArg = defaultArguments.get("suspend");

        // Set class of main method
        mainArg.setValue(className);
        suspendArg.setValue("true");
        try {
            vm = launchingConnector.launch(defaultArguments);
        } catch (Exception e) {
            // ignore
        }
    }

    public void processDebuggeeVM() {
        process = vm.process();
    }

    public void destroyDebuggeeVM() {
        process.destroy();
    }

    public void registerEvent() {
        // Register ClassPrepareRequest
        eventRequestManager = vm.eventRequestManager();
        MethodEntryRequest entryReq = eventRequestManager.createMethodEntryRequest();

        entryReq.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD);
        entryReq.addClassFilter(className);
        entryReq.enable();

        MethodExitRequest exitReq = eventRequestManager.createMethodExitRequest();
        exitReq.addClassFilter(className);
        exitReq.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD);
        exitReq.enable();
    }

    private void eventLoop() throws Exception {
        eventQueue = vm.eventQueue();
        while (true) {
            if (vmExit == true) {
                break;
            }
            eventSet = eventQueue.remove();
            EventIterator eventIterator = eventSet.eventIterator();
            while (eventIterator.hasNext()) {
                Event event = (Event) eventIterator.next();
                execute(event);
                if (!vmExit) {
                    eventSet.resume();
                }
            }
        }
    }

    private void execute(Event event) throws Exception {
        if (event instanceof VMStartEvent) {
            System.out.println("VM started");
        } else if (event instanceof MethodEntryEvent) {
            Method method = ((MethodEntryEvent) event).method();
            System.out.printf("Enter -> Method: %s, Signature:%s
",method.name(),method.signature());
            System.out.printf("	 ReturnType:%s
", method.returnTypeName());
        } else if (event instanceof MethodExitEvent) {
            Method method = ((MethodExitEvent) event).method();
            System.out.printf("Exit -> method: %s
",method.name());
        } else if (event instanceof VMDisconnectEvent) {
            vmExit = true;
        }
    }
}

總結

整個JDPA有非常清晰的分層,各司其職,讓整個調式過程簡單可以擴充套件,而這一切其實都是構建在高司令巨牛逼的Java虛擬機器抽象之上的,通過JVMTI將抽象良好的虛擬機器控制暴露出來,讓開發者可以自由的掌控被除錯的虛擬機器。有興趣的同學可以執行下附近中的幾個例子,應該會有更充分的瞭解。

而且由於規範的靈活性,如果有特殊需求,完全可以自己去重新實現和擴充套件,而且不限於Java,舉個例子,我們可以通過agent去加密解密載入的類,保護智慧財產權;我們可以記錄虛擬機器執行過程,作為自動化測試用例; 我們還可以把線上問題的診斷實踐自動化下來,做一個快速預判 ,爭取最寶貴的時間。

參考文件


相關文章