Java 平臺調試體系原理和實踐
一、原理分析
1.1 介紹
JPDA(Java Platform Debugger Architecture)是 Java 平臺調試體系結構的縮寫。通過 JPDA 提供的 API,開發人員可以方便靈活地搭建 Java 調試應用程序。JPDA 主要由三個部分組成:Java 虛擬機工具接口(JVMTI),Java 調試線協議(JDWP),以及 Java 調試接口(JDI)。
Java 程序都是運行在 Java 虛擬機上的。我們要調試 Java 程序,事實上就需要向 Java 虛擬機請求當前運行態的狀態,並對虛擬機發出一定的指令,設置一些回調等等。因此,Java 的調試體系是虛擬機的一整套用於調試的工具和接口。
1.2 IDEA 和 Eclipse 調試原理
-
編輯器作爲客戶端和服務器程序通過暴露的監聽端口建立 socket 連接。
-
IDE 客戶端將斷點位置創建了斷點事件,通過 JDI 接口傳給了服務端(程序端)的 VM,VM 調用 suspend 將 VM 掛起。
-
VM 掛起之後,將客戶端需要獲取的 VM 信息返回給客戶端,返回之後 VM resume 恢復其運行狀態。
-
客戶端獲取到 VM 返回的信息之後,可以通過不同的方式進行展示。
1.3 架構體系
JPDA 定義了一個完整獨立的體系,它由三個相對獨立的層次共同組成,而且規定了它們三者之間的交互方式,或者說定義了它們通信的接口。這三個層次由低到高分別是 Java 虛擬機工具接口(JVMTI),Java 調試線協議(JDWP)以及 Java 調試接口(JDI)。
這三個模塊把調試過程分解成幾個很自然的概念:調試者(debugger)和被調試者(debuggee),以及他們中間的通信器。被調試者運行於我們想調試的 Java 虛擬機之上,它可以通過 JVMTI 這個標準接口,監控當前虛擬機的信息;調試者定義了用戶可使用的調試接口,通過這些接口,用戶可以對被調試虛擬機發送調試命令,同時調試者接受並顯示調試結果。
在調試者和被調試着之間,調試命令和調試結果,都是通過 JDWP 的通訊協議傳輸的。所有的命令被封裝成 JDWP 命令包,通過傳輸層發送給被調試者,被調試者接收到 JDWP 命令包後,解析這個命令並轉化爲 JVMTI 的調用,在被調試者上運行。類似的,JVMTI 的運行結果,被格式化成 JDWP 數據包,發送給調試者並返回給 JDI 調用。而調試器開發人員就是通過 JDI 得到數據,發出指令。
如上圖所示 JPDA 由三層組成:
-
JVM TI - Java VM 工具接口。定義 VM 提供的調試服務。
-
JDWP - Java 調試通信協議。定義被調試者和調試器進程之間的通信。
-
JDI - Java 調試接口。定義一個高級 Java 語言接口,工具開發人員可以輕鬆地使用它來編寫遠程調試器應用程序。
通過 JPDA 這套接口,我們就可以開發自己的調試工具。通過這些 JPDA 提供的接口和協議,調試器開發人員就能根據特定開發者的需求,擴展定製 Java 調試應用程序。
前面我們提到的 IDE 調試工具都是基於 JPDA 體系開發的,區別僅僅在於它們可能提供了不同的圖形界面、具有一些不同的自定義功能。
另外,我們要注意的是,JPDA 是一套標準,任何的 JDK 實現都必須完成這個標準。因此,通過 JPDA 開發出來的調試工具先天具有跨平臺、不依賴虛擬機實現、JDK 版本無關等移植優點。因此大部分的調試工具都是基於這個體系的。
二、遠程調試實例
1、構建一個 Spring Boot 的 WEB 項目。當前所選擇的 Spring Boot 版本是 2.3.0.RELEASE,對應的 Tomcat 版本是 9.X。
2、打包該 Spring Boot 項目。開發應用程序端口爲 9999。將該程序部署到 Linux 服務器上,可以是 JAR 包方式也可以 Docker 的方式,遠程調試和這個沒有關係。
3、部署程序的代碼。參考如下,就是一個簡單的請求處理打印輸出信息。
/**
* 測試程序
* @author zhangyu
* @date 2022/2/17
*/
@SpringBootApplication
@RestController
public class DebuggerApplication {
public static void main(String[] args) {
SpringApplication.run(DebuggerApplication.class, args);
}
@GetMapping("/test")
public String test(){
System.out.println(111);
System.out.println(222);
return "OK";
}
}
4、部署程序啓動參數如下
java -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=8888 -jar debugger-0.0.1-SNAPSHOT.jar
其中 address=8888 表示開啓 8888 端口作爲遠程調試的 Socket 通信端口。
如果是部署在 Tomcat 下的普通 Web 項目,參考如下:
1)小於 Tomcat 9 版本
Tomcat 中 bin/catalina.sh 中增加
CATALINA_OPTS='-server -Xdebug -Xnoagent -Djava.compiler=NONE -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=18006'
如下圖所示:
2)大於等於 Tomcat 9 版本
Tomcat 中 bin/catalina.sh 中的 JPDA_ADDRESS="localhost:8000" 這一句中的 localhost 修改爲 0.0.0.0(允許所有 IP 連接到 8000 端口,而不僅是本地)。8000 是端口,端口號可以任意修改成沒有佔用的即可,如下圖所示:
5、測試部署的程序正常後,下面構建客戶端遠程調試,當前以 IDEA 工具作爲客戶端
參考:
-
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8888
-
Host:遠程服務器地址
-
Port:遠程服務器開放的調試通信端口,非應用端口
測試接口:http://XXX:9999/test。注意本地代碼需要和遠程部署程序一致。通過上圖可以看到客戶端設置斷點已經生效,其中在客戶端執行了一個調試輸出,這個是自定義輸出的內容服務器程序並沒有,在執行後右側的服務器控制檯日誌輸出了該信息,因此遠程 Debug 是正常通信和處理的。
三、調試參數詳解
-
-Xdebug :啓用調試特性
-
-Xrunjdwp: 在目標 VM 中加載 JDWP 實現。它通過傳輸和 JDWP 協議與獨立的調試器應用程序通信。下面介紹一些特定的子選項
-
-Djava.compiler=NONE:禁止 JIT 編譯器的加載
-
transport :傳輸方式,有 socket 和 shared memory 兩種,我們通常使用 socket(套接字)傳輸,但是在 Windows 平臺上也可以使用 shared memory(共享內存)傳輸。
-
server(y/n):VM 是否需要作爲調試服務器執行
-
address:調試服務器的端口號,客戶端用來連接服務器的端口號
-
suspend(y/n):值是 y 或者 n,若爲 y,啓動時候自己程序的 VM 將會暫停(掛起),直到客戶端進行連接,若爲 n,自己程序的 VM 不會掛起
從 Java V5 開始,您可以使用 -agentlib:jdwp 選項,而不是 -Xdebug 和 -Xrunjdwp。但如果連接到 V5 以前的 VM,只能選擇 -Xdebug 和 -Xrunjdwp。下面簡單描述 -Xrunjdwp 子選項。
四、JDI 工具代碼實踐
4.1 JDI 技術架構
參考:https://segmentfault.com/a/1190000040469952/en
4.2 實踐案例
1) 被調試程序
創建一個 Spring Boot 的 Web 項目,提供一個簡單的測試接口,並在測試方法中提供一些方法參數變量和局部變量作爲後面的調試測試用。
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@RestController
public class DebuggerApplication {
public static void main(String[] args) {
SpringApplication.run(DebuggerApplication.class, args);
}
@GetMapping("/test")
public String test(String name){
System.out.println("進入方法");
int var=100;
System.out.println(name);
System.out.println(var);
System.out.println("方法結束");
return "OK";
}
}
項目啓動配置參考,需要啓用 Debug 配置:
2) 自定義調試器代碼
開發調試器需要 JNI 工具支持,JDI 操作的 API 工具在 tools.jar 中,需要在 CLASSPATH 中添加 /lib/tools.jar。
import com.sun.jdi.*;
import com.sun.jdi.connect.AttachingConnector;
import com.sun.jdi.connect.Connector;
import com.sun.jdi.event.*;
import com.sun.jdi.request.BreakpointRequest;
import com.sun.jdi.request.EventRequest;
import com.sun.jdi.request.EventRequestManager;
import com.sun.tools.jdi.SocketAttachingConnector;
import java.util.List;
import java.util.Map;
/**
* 通過JNI工具測試Debug
* @author zhangyu
* @date 2022/2/20
*/
public class TestDebugVirtualMachine {
private static VirtualMachine vm;
public static void main(String[] args) throws Exception {
//獲取SocketAttachingConnector,連接其它JVM稱之爲附加(attach)操作
VirtualMachineManager vmm = Bootstrap.virtualMachineManager();
List<AttachingConnector> connectors = vmm.attachingConnectors();
SocketAttachingConnector sac = null;
for(AttachingConnector ac : connectors) {
if(ac instanceof SocketAttachingConnector) {
sac = (SocketAttachingConnector) ac;
}
}
assert sac != null;
//設置好主機地址,端口信息
Map<String, Connector.Argument> arguments = sac.defaultArguments();
Connector.Argument hostArg = arguments.get("hostname");
Connector.Argument portArg = arguments.get("port");
hostArg.setValue("127.0.0.1");
portArg.setValue(String.valueOf(8800));
//進行連接
vm = sac.attach(arguments);
//相應的請求調用通過requestManager來完成
EventRequestManager eventRequestManager = vm.eventRequestManager();
//創建一個代碼判斷,因此需要獲取相應的類,以及具體的斷點位置,即相應的代碼行。
ClassType clazz = (ClassType) vm.classesByName("com.zy.debugger.DebuggerApplication").get(0);
//設置斷點代碼位置
Location location = clazz.locationsOfLine(22).get(0);
//創建新斷點並設置阻塞模式爲線程阻塞,即只有當前線程被阻塞
BreakpointRequest breakpointRequest = eventRequestManager.createBreakpointRequest(location);
//設置阻塞並啓動
breakpointRequest.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD);
breakpointRequest.enable();
//獲取vm的事件隊列
EventQueue eventQueue = vm.eventQueue();
while(true) {
//不斷地讀取事件並處理斷點記錄事件
EventSet eventSet = eventQueue.remove();
EventIterator eventIterator = eventSet.eventIterator();
while(eventIterator.hasNext()) {
Event event = eventIterator.next();
execute(event);
}
//將相應線程resume,表示繼續運行
eventSet.resume();
}
}
/**
* 處理監聽到事件
* @author zhangyu
* @date 2022/2/20
*/
public static void execute(Event event) throws Exception {
//獲取的event爲一個抽象的事件記錄,可以通過類型判斷轉型爲具體的事件,這裏我們轉型爲BreakpointEvent,即斷點記錄,
BreakpointEvent breakpointEvent = (BreakpointEvent) event;
//並通過斷點處的線程拿到線程幀,進而獲取相應的變量信息,並打印記錄。
ThreadReference threadReference = breakpointEvent.thread();
StackFrame stackFrame = threadReference.frame(0);
List<LocalVariable> localVariables = stackFrame.visibleVariables();
//輸出當前線程棧幀保存的變量數據
localVariables.forEach(t -> {
Value value = stackFrame.getValue(t);
System.out.println("local->" + value.type() + "," + value.getClass() + "," + value);
});
}
}
3) 代碼分析
-
通過 Bootstrap.virtualMachineManager() 獲取連接器。客戶端即通過相應的 connector 進行連接,配置服務器程序 IP 地址和端口,連接後獲取對應服務器的 VM 信息。
-
通過 VirtualMachine 獲取類信息,通過遍歷獲取的類集合定位到目標 debug 的類文件上。
-
對目標類代碼特定位置設置並啓用斷點。
-
記錄斷點信息,阻塞服務器線程,並根據對應事件獲取相應的信息。
-
執行 event.resume 釋放斷點,服務器程序繼續運行。
4) 運行測試
啓動服務器程序,即上面的 Spring Boot 的 web 項目。本地以 debug 方式啓動調試器代碼,待會在這個位置看看獲取的信息,同時避免直接釋放斷點。
設置斷點位置爲 DebuggerApplication 類的第 22 行。
啓動後測試該接口,可以發現服務器程序控制臺打印瞭如下結果。第 22 行還沒有執行。
此時,在觀察調試器程序。可以看到獲取到了服務器程序棧幀的數據:
釋放斷點,服務器正常運行完本次請求處理流程:
轉自:ZWZhangYu,
鏈接:blog.csdn.net/Octopus21/article/details/123049808
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/0WcJ9isFo7fmqXeHrF_k3w