Java调试原理初探

4,015 阅读10分钟
原文链接: rdc.hundsun.com

对于所有程序员,程序调试是一项必备的技能。在java程序中,最简单的就是通过 System.out.println()来打印输出各种变量来发现问题,而用的最多的莫过于通过各种调试器来进行调试,如图一所示的eclipse调试器,甚至还可以进行远程调试。对于这些调试器是如何实现的,这就需要了解本文的重点——JPDA(Java Platform Debugger Architecture)Java平台调试体系结构。

图1 eclipse调试器


JPDA体系概述

总所周知Java程序是运行在JVM上的,调试java程序,事实上就是向虚拟机请求其当前的运行状态等,JPDA就是虚拟机提供的一整套用于java调试的工具和接口。

JPDA可以分为三个部分,分别是Java虚拟机工具接口(JVMTI),Java调试线协议(JDWP)以及Java调试接口(JDI)。这三个层次把调试过程分解成三个不同的概念:调试者(debugger)和被调试者(Target JVM),以及它们的交互通道。图2展示了三者的相互关系。

图2 JPDA层次关系


JVMTI概述及使用

JVMTI其实就是一套由虚拟机直接提供的本地代码接口,包含了调试、监听、线程分析及覆盖率分析等接口(接口定义可以参考jdk中的jvmti.h文件),它处于整个JPDA体系的最底层。基于JVMTI这些强大的接口,可以实现java调试器、java运行态测试以及分析工具等。对于主流的java虚拟机,都有提供标准的JVMTI实现,实现过程比较复杂,这里就不赘述它的实现。

一般我们可以通过采用建立一个Agent的方式来使用JVMTI,其显著的特征就是通过设置回调函数的方式,从java虚拟机上得到当前运行态信息,并做出自己的相应的操作,抑或操作虚拟机的运行态,以达到一些特定的目的(如优化程序性能)。把Agent编译成一个动态链接库后,就可以在java程序启动的时候(增加启动参数agentlib/ agentpath)来加载它(java5之后可以使用运行时加载)。

以启动时加载为例,动态库加载后,虚拟机会寻找Agent的入口函数,定义如下:

在该函数中,虚拟机传入了一个JavaVM指针和命令行参数options,由javaVM,可以获得jvmtiEnv指针,而通过 jvmtiEnv可以获取所有的JVMTI函数。通过options可以做一些初始化操作(如初始化连接方式等)。

假如我们需要某个类的字节码文件读取之后,类定义之前能修改相关的字节码,从而使创建的class对象是我们修改之后的字节码内容,就需要写一个HandleClassFileLoadHook函数,然后在Agent_OnLoad函数里为jvmtiEventCallbacks设定对应的函数指针。

这样在接下来的虚拟机运行中,所有类的字节码文件在读取时都会进入HandleClassFileLoadHook函数,可以在HandleClassFileLoadHook函数中进行修改字节码等操作。


JDWP协议

JDWP(Java Debug Wire Protocol)定义了调试者和被调试者之间的通讯协议。在java程序中,调试者和被调试者运行在各自的java虚拟机上,被调试者(Target JVM)在启动时会加载一个Agent,该Agent里实现了各种方法,使用JVMTI函数,从而具备了调试功能,调试者(Debugger)与被调试者(Target JVM)建立连接后,向其发送命令来获取其运行时的状态和控制java程序的运行,发送命令和获取应答用的就是JDWP协议。

JDWP通讯可以分为两个阶段:握手和应答。握手是在传输层建立连接后做的第一件事,Debugger发送字符串“JDWP-Handshake”到Target JVM,后者同样回复“JDWP-Handshake”则表示握手完成,通信正常。握手完成后,Debugger就可以向Target JVM发送命令了。


Packet结构

由上面可以知道JDWP是通过command和reply进行通信,因此对应两种packet类型:命令包(command packet)和回复包(reply packet),Packet分为包头(header)和数据(data)两部分组成。包头部分的结构和长度是固定的,而数据部分的长度是可变的。command packet和reply packet的包头长度相同,都是11个bytes。

command packet包头结构如图三所示,Length 表示整个packet的长度(含 length 本身)。Id是一个唯一值,用来标记和识别reply所属的command。Reply packet与它对应的command packet具有相同的Id,异步的消息就是通过Id来识别的。Flags对于 command packet 是固定值 0。Command Set相当于一个command的分组,一些功能相近的command被分在同一个Command Set中。

图三command packet包头结构

reply packet包头结构如图四所示,Length和Id作用与command packet中的一样。Flags对于reply packet值是固定值 0x80。Error Code用来表示被回复的命令是否被正确执行了。0表示正确,非0表示执行错误。

图四reply packet包头结构

Debugger和Target JVM都可以发送命令包。Debugger通过发送命令包获取Target JVM的信息以及控制程序的执行。Target JVM通过发送命令包通知Debugger诸如断点或者异常等事件的发生。


JDWP传输接口

JDWP本身是与传输层独立的,但JDWP提供了一套传输接口,它定义了一系列的方法用来定义JDWP与传输层之间的交互方式。传输层必须以动态链接库的方式实现,并且暴露一系列的标准接口供JDWP使用。

当JDWP agent被Java虚拟机加载后,JDWP会根据参数去加载指定的传输层实现(例如我们最常见的Sun的JDK提供了socket方式)。传输层的实现必须暴露jdwpTransport_OnLoad接口,JDWP agent在加载传输层动态链接库后会调用该接口进行传输层的初始化。接口定义如下:

和一般的连接方式类似,JDWP可以主动去连接debugger,也可以等待debugger的连接。对于主动去连接debugger,需要调用Attach方法,其定义如下:

对于JDWP等待debugger连接的方式,首先要调用其StartListening方法,定义如下:

该方法将使JDWP处于监听状态,随后调用Accept方法接收连接:

根据传入参数确定采用主动连接方式还是等待连接方式,无论是哪种连接方式,在连接建立后,会立即进行握手操作。握手完成后可以通过IO操作进行应答。


JDWP命令机制

JDWP内部命令实现机制如图五所示,JDWP接收和发送的包都会在 TransportManager进行处理。TransportManager的主要作用就是负责解析接收到的 JDWP的command packet以及将reply packet在发送前进行打包。对于接收到的command packet,TransportManager处理后转给PacketDispatcher,进一步封装后会继续转到CommandDispatcher。CommandDispatcher会根据命令中提供的命令组号和命令号创建一个CommandHandler来处理JDWP命令。CommandHandler才是真正执行JDWP命令的类,每个JDWP命令都有一个相对应的CommandHandler的子类,当接收到某个命令时,就会创建对应子类的实例,调用对应的JVMTI方法进行处理。

图五JDWP命令机制


Java调试接口(JDI)

JDI主要包含了一套针对前端定义的接口,通过这套接口,开发人员就能通过前端虚拟机上的调试器(如eclipse调试器)来远程监控/控制后端虚拟机上被调试程序的运行,它处于JPDA体系的最高层,eclipse的jdt.debug就是一个完整的JDI实现。


JDI工作方式

首先,Debugger通过Bootstrap获取唯一的虚拟机管理器。

链接是Debugger与Target JVM之间交互的渠道,一个调试器可以链接多个目标虚拟机,但一个目标虚拟机最多只能链接一个调试器。链接是由链接器(Connector)生成的,不同的链接器有着不同的实现方式。JDI中定义了三种链接器接口,分别是依附型链接器(AttachingConnector)、监听型链接器(ListeningConnector)和启动型链接器(LaunchingConnector)。在调试过程中,实际使用的链接器必须实现其中一种接口,而在虚拟机管理器中就提供了各种连接器的实现。

根据调试器在链接过程中扮演的角色,也可以将链接方式划分为主动链接和被动链接。主动链接表示调试器主动地向目标虚拟机发起链接。被动链接表示调试器将被动地等待或者监听由目标虚拟机发起的链接。以我们调试一个Test类的main方法为例,这是一个典型的被动链接。

首先调试器调用虚拟管理器的listeningConnectors ()方法获取所有的监听型链接器实例connector;根据socket传输方式选择对应的socket链接器,调用链接器的startListening() 方法让链接器进入监听状态;终端用户以-agentlib:jdwp=transport=dt_socket,suspend=y,address=localhost:port参数启动target JVM(即启动时加载jdwp Agent,transport=dt_socket 表示采用socket连接方式, suspend=y参数表示虚拟机启动后挂起,等待连接建立后再执行,address是socket连接地址),然后调用链接器的accept()方法等待接受目标虚拟机的链接,链接成功后进行握手,确保通信正常,该方法返回目标虚拟机的实例。

自此Debugger和Target JVM便可以进行双向通信了。通过VirtualMachine负责发送命令和接收回应,Debugger将用户的操作按JDWP协议转化为调试命令发送到前端Target JVM上,经由JDWP的调试机制,将调试的结果按JDWP协议发回给Debugger;最后,Debugger解析后将可视化数据信息反馈给用户。

对于其他的链接方式,虚拟机管理器都提供了相对应的链接器,按照指定的链接方式完成链接后均可获得目标虚拟机实例。


JDI数据模块

上面介绍到的VirtualMachine接口如图六所示,该接口提供了方法可以用来直接或间接地获取target JVM上所有的数据和状态信息,也可以挂起、恢复、终止目标虚拟机。

图六 VirtualMachine接口


VirtualMachine接口继承自Mirror接口,JDI中几乎所有其他接口都继承于它。镜像机制是将目标虚拟机上的所有数据、类型、域、方法、事件、状态和资源,以及调试器发向目标虚拟机的事件请求等都映射成Mirror对象。例如,在目标虚拟机上,对象实例被映射成ObjectReference镜像,基本类型的值(如float等)被映射成PrimitiveValue(如FloatValue等)。被调试的目标程序的运行状态信息被映射到StackFrame镜像中,被调试的目标虚拟机则被映射成VirtualMachine镜像等。调用Mirror实例virtualMachine()可以获取其虚拟机信息

这样,通过virtualMachine实例可以方便的获取JVM当前状态或者控制JVM。


结束语

本文较详细的介绍了JPDA的整个体系,分别介绍了其三个模块之间的层次关系,让大家对调试器的整个工作原理,从调试器到目标虚拟机如何进行通信有了一个直观的了解。在本文基础上,大家可以进一步理解JPDA相关细节,最终能够自己编写出实用、高效的Java调试器程序。