Java平台调试体系原理分析和实践整理 远程Debug
作者:ZWZhangYu
一、原理分析
(一)介绍
JPDA(Java Platform Debugger Architecture) 是 Java 平台调试体系结构的缩写,通过 JPDA 提供的 API,开发人员可以方便灵活的搭建 Java 调试应用程序。
JPDA 主要由三个部分组成:Java 虚拟机工具接口(JVMTI),Java 调试线协议(JDWP),以及 Java 调试接口(JDI)。
Java 程序都是运行在 Java 虚拟机上的,我们要调试 Java 程序,事实上就需要向 Java 虚拟机请求当前运行态的状态,并对虚拟机发出一定的指令,设置一些回调等等,那么 Java 的调试体系,就是虚拟机的一整套用于调试的工具和接口。
(二)IDEA和eclipse 调试原理为
1:编辑器作为客户端和服务器程序通过暴露的监听端口建立socket连接
2:IDE客户端将断点位置创建了断点事件通过 JDI 接口传给了 服务端(程序端)的 VM,VM 调用 suspend 将 VM 挂起
3:VM 挂起之后将客户端需要获取的 VM 信息返回给客户端,返回之后 VM resume 恢复其运行状态
4:客户端获取到 VM 返回的信息之后可以通过不同的方式进行展示
(三)架构体系
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】构建一个SpringBoot的WEB项目。当前所选择的SpringBoot版本是2.3.0.RELEASE。对应的tomcat版本是9.X。
【2】打包该SpringBoot项目,开发应用程序端口为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项目,参考如下:
小于 tomcat9 版本
tomcat 中 bin/catalina.sh 中增加 CATALINA_OPTS=‘-server -Xdebug -Xnoagent -Djava.compiler=NONE -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=18006’
如下图所示:
大于等于 tomcat9 版本
tomcat 中 bin/catalina.sh 中的 JPDA_ADDRESS=“localhost:8000” 这一句中的localhost修改为0.0.0.0(允许所有ip连接到8000端口,而不仅是本地)8000是端口,端口号可以任意修改成没有占用的即可
如下图所示:
【5】测试部署的程序正常后,下面构建客户端远程调试,当前以IDEA工具作为客户端
参考:
【1】-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8888
【2】Host:远程服务器地址
【3】Port:远程服务器开放的调试通信端口,非应用端口
测试接口:http://XXX:9999/test。注意本地代码需要和远程部署程序一致。
通过上图可以看到客户端设置断点已经生效,其中在客户端执行了一个调试输出,这个是自定义输出的内容服务器程序并没有,在执行后右侧的服务器控制台日志输出了该信息,因此远程Debug是正常通信和处理的。
(一)调试参数详解
-Xdebug
:启用调试特性-Xrunjdwp
:在目标 VM 中加载 JDWP 实现。它通过传输和 JDWP 协议与独立的调试器应用程序通信。下面介绍一些特定的子选项
从 Java V5 开始,您可以使用 -agentlib:jdwp 选项,而不是 -Xdebug 和 -Xrunjdwp。但如果连接到 V5 以前的 VM,只能选择 -Xdebug 和 -Xrunjdwp。下面简单描述 -Xrunjdwp 子选项。
-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 不会挂起
三、JDI工具代码实践
(一)JDI技术架构
(二)实践案例
(1)被调试程序
创建一个SpringBoot的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)代码分析
【1】通过Bootstrap.virtualMachineManager();获取连接器,客户端即通过相应的connector进行连接,配置服务器程序ip地址和端口,连接后获取对应服务器的VM信息。
【2】通过VirtualMachine获取类信息,通过遍历获取的类集合定位到目标debug的类文件上
【3】对目标类代码特定位置设置并启用断点
【4】记录断点信息,阻塞服务器线程,并根据对应事件获取相应的信息
【5】执行event.resume释放断点,服务器程序继续运行
(4)运行测试
【1】启动服务器程序,即上面的SpringBoot的web项目。本地以debug方式启动调试器代码,待会在这个位置看看获取的信息,同时避免直接释放了断点。
【2】设置断点位置为DebuggerApplication类的第22行
【3】启动后测试该接口,可以发现服务器程序控制台打印了如下结果。第22行还没有执行。
【4】此时,在观察调试器程序。可以看到获取到了服务器程序栈帧的数据
【5】释放断点,服务器正常运行完本次请求处理流程
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。