Java Debug 原理与实践

3

Java Debug 原理与实践

一、JPDA 体系概览

1、JPDA 组成模块

JPDA 定义了一个完整独立的体系,它由三个相对独立的层次共同组成,而且规定了它们三者之间的交互方式,或者说定义了它们通信的接口。这三个层次由低到高分别是 Java 虚拟机工具接口(JVMTI),Java 调试线协议(JDWP)以及 Java 调试接口(JDI)。这三个模块把调试过程分解成几个很自然的概念:调试者(debugger)和被调试者(debuggee),以及他们中间的通信器。被调试者运行于我们想调试的 Java 虚拟机之上,它可以通过 JVMTI 这个标准接口,监控当前虚拟机的信息;调试者定义了用户可使用的调试接口,通过这些接口,用户可以对被调试虚拟机发送调试命令,同时调试者接受并显示调试结果。在调试者和被调试着之间,调试命令和调试结果,都是通过 JDWP 的通讯协议传输的。所有的命令被封装成 JDWP 命令包,通过传输层发送给被调试者,被调试者接收到 JDWP 命令包后,解析这个命令并转化为 JVMTI 的调用,在被调试者上运行。类似的,JVMTI 的运行结果,被格式化成 JDWP 数据包,发送给调试者并返回给 JDI 调用。而调试器开发人员就是通过 JDI 得到数据,发出指令。下图展示了这个过程:

Untitled

2、Java 虚拟机工具接口(JVMTI)

JVMTI(Java Virtual Machine Tool Interface)即指 Java 虚拟机工具接口,它是一套由虚拟机直接提供的 native 接口,它处于整个 JPDA 体系的最底层,所有调试功能本质上都需要通过 JVMTI 来提供。通过这些接口,开发人员不仅调试在该虚拟机上运行的 Java 程序,还能查看它们运行的状态,设置回调函数,控制某些环境变量,从而优化程序性能。我们知道,JVMTI 的前身是 JVMDI 和 JVMPI,它们原来分别被用于提供调试 Java 程序以及 Java 程序调节性能的功能。在 J2SE 5.0 之后 JDK 取代了 JVMDI 和 JVMPI 这两套接口,JVMDI 在最新的 Java SE 6 中已经不提供支持,而 JVMPI 也计划在 Java SE 7 后被彻底取代。

3、Java 调试线协议(JDWP)

JDWP(Java Debug Wire Protocol)是一个为 Java 调试而设计的一个通讯交互协议,它定义了调试器和被调试程序之间传递的信息的格式。在 JPDA 体系中,作为前端(front-end)的调试者(debugger)进程和后端(back-end)的被调试程序(debuggee)进程之间的交互数据的格式就是由 JDWP 来描述的,它详细完整地定义了请求命令、回应数据和错误代码,保证了前端和后端的 JVMTI 和 JDI 的通信通畅。比如在 Sun 公司提供的实现中,它提供了一个名为 jdwp.dll(jdwp.so)的动态链接库文件,这个动态库文件实现了一个 Agent,它会负责解析前端发出的请求或者命令,并将其转化为 JVMTI 调用,然后将 JVMTI 函数的返回值封装成 JDWP 数据发还给后端。

另外,这里需要注意的是 JDWP 本身并不包括传输层的实现,传输层需要独立实现,但是 JDWP 包括了和传输层交互的严格的定义,就是说,JDWP 协议虽然不规定我们是通过 EMS 还是快递运送货物的,但是它规定了我们传送的货物的摆放的方式。在 Sun 公司提供的 JDK 中,在传输层上,它提供了 socket 方式,以及在 Windows 上的 shared memory 方式。当然,传输层本身无非就是本机内进程间通信方式和远端通信方式,用户有兴趣也可以按 JDWP 的标准自己实现。

4、Java 调试接口(JDI)

JDI(Java Debug Interface)是三个模块中最高层的接口,在多数的 JDK 中,它是由 Java 语言实现的。 JDI 由针对前端定义的接口组成,通过它,调试工具开发人员就能通过前端虚拟机上的调试器来远程操控后端虚拟机上被调试程序的运行,JDI 不仅能帮助开发人员格式化 JDWP 数据,而且还能为 JDWP 数据传输提供队列、缓存等优化服务。从理论上说,开发人员只需使用 JDWP 和 JVMTI 即可支持跨平台的远程调试,但是直接编写 JDWP 程序费时费力,而且效率不高。因此基于 Java 的 JDI 层的引入,简化了操作,提高了开发人员开发调试程序的效率。

模块层次编程语言作用
JVMTI底层C获取及控制当前虚拟机状态
JDWP中介层C定义 JVMTI 和 JDI 交互的数据格式
JDI高层Java提供 Java API 来远程控制被调试虚拟机

5、JPDA 实现

关于 Apache Harmony 项目

Apache Harmony 旨在开发出一个独立且与现有 JDK 兼容的 Java SE 实现,它以 Apache 软件许可证 2.0 版发行。它建立了一个开放的模块化运行时架构,包括虚拟机和类库之间及其内部的模块化,通过这个平台,社区能在已有实现的基础上自由定制自己的 Java 实现,或者对某个模块单独进行创新。

每一个虚拟机都应该实现 JVMTI 接口,但是 JDWP 和 JDI 本身与虚拟机并非是不可分的,这三个层之间是通过标准所定义的交互的接口和协议联系起来的,因此它们可以被独立替换或取代,但不会影响到整体调试工具的开发和使用。因此,开发和使用自己的 JDWP 和 JDI 接口实现是可能的。

Java 软件开发包(SDK)标准版里提供了 JPDA 三个层次的标准实现,事实上,调试工具开发人员还有很多其他开源实现可以选择,比如 Apache Harmony 提供了 JDWP 的实现。而 JDI,我们可以在 Eclipse 一个子项目 org.eclipse.jdt.debug 里找到其完整的实现(Harmony 也使用了这套实现,作为其 J2SE 类库的一部分)。通过标准协议,Eclipse IDE 的调试工具就可以完全在 Harmony 的环境上运行。

6、Java 调试接口的特点

Java 语言是第一个使用虚拟机概念的流行的编程语言,正是因为虚拟机的存在,使很多事情变得简单而轻松,掌握了虚拟机,就掌握了内存分配、线程管理、即时优化等等运行态。同样的,Java 调试的本质,就是和虚拟机打交道,通过操作虚拟机来达到观察调试我们自己代码的目的。这个特点决定了 Java 调试接口和以前其他编程语言的巨大区别。

以 C/C++ 的调试为例,目前比较流行的调试工具是 GDB 和微软的 Visual Studio 自带的 debugger,在这种 debugger 中,首先,我们必须编译一个 “debug” 模式的程序,这个会比实际的 release 模式程序大很多。其次,在调试过程中,debugger 将会深层接入程序的运行,掌握和控制运行态的一些信息,并将这些信息及时返回。这种介入对运行的效率和内存占用都有一定的需求。基于这些需求,这些 Debugger 本身事实上是提供了,或者说,创建和管理了一个运行态,因此他们的程序算法比较复杂,个头都比较大。对于远端的调试,GDB 也没有很好的默认实现,当然,C/C++ 在这方面也没有特别大的需求。

而 Java 则不同,由于 Java 的运行态已经被虚拟机所很好地管理,因此作为 Java 的 Debugger 无需再自己创造一个可控的运行态,而仅仅需要去操作虚拟机就可以了。 Java 的 JPDA 就是一套为调试和优化服务的虚拟机的操作工具,其中,JVMTI 是整合在虚拟机中的接口,JDWP 是一个通讯层,而 JDI 是前端为开发人员准备好的工具和运行库。

从构架上说,我们可以把 JPDA 看作成是一个 C/S 体系结构的应用,在这个构架下,我们可以方便地通过网络,在任意的地点调试另外一个虚拟机上的程序,这个就很好地解决了部署和测试的问题,尤其满足解决了很多网络时代中的开发应用的需求。前端和后端的分离,也方便用户开发适合于自己的调试工具。

从效率上看,由于 Java 程序本身就是编译成字节码,运行在虚拟机上的,因此调试前后的程序、内存占用都不会有大变化(仅仅是启动一个 JDWP 所需要的内存),任意程度都可以很好地调试,非常方便。而 JPDA 构架下的几个组成部分,JDWP 和 JDI 都比较小,主要的工作可以让虚拟机自己完成。

从灵活性上,Java 调试工具是建立在强大的虚拟机上的,因此,很多前沿的应用,比如动态编译运行,字节码的实时替换等等,都可以通过对虚拟机的改进而得到实现。随着虚拟机技术的逐步发展和深入,各种不同种类,不同应用领域中虚拟机的出现,各种强大的功能的加入,给我们的调试工具也带来很多新的应用。

总而言之,一个先天的,可控的运行态给 Java 的调试工作,给 Java 调试接口带来了极大的优势和便利。通过 JPDA 这个标准,我们可以从虚拟机中得到我们所需要的信息,完成我们所希望的操作,更好地开发我们的程序。


二、JVMTI和Agent实现

1、Java 程序的诊断和调试

开发人员对 Java 程序的诊断和调试有许多不同种类、不同层次的需求,这就使得开发人员需要使用不同的工具来解决问题。比如,在 Java 程序运行的过程中,程序员希望掌握它总体的运行状况,这个时候程序员可以直接使用 JDK 提供的 jconsole 程序。如果希望提高程序的执行效率,开发人员可以使用各种 Java Profiler。这种类型的工具非常多,各有优点,能够帮助开发人员找到程序的瓶颈,从而提高程序的运行速度。开发人员还会遇到一些与内存相关的问题,比如内存占用过多,大量内存不能得到释放,甚至导致内存溢出错误(OutOfMemoryError)等等,这时可以把当前的内存输出到 Dump 文件,再使用堆分析器或者 Dump 文件分析器等工具进行研究,查看当前运行态堆(Heap)中存在的实例整体状况来诊断问题。所有这些工具都有一个共同的特点,就是最终他们都需要通过和虚拟机进行交互,来发现 Java 程序运行的问题。

已有的这些工具虽然强大易用,但是在一些高级的应用环境中,开发者常常会有一些特殊的需求,这个时候就需要定制工具来达成目标。 JDK 本身定义了目标明确并功能完善的 API 来与虚拟机直接交互,而且这些 API 能很方便的进行扩展,从而满足开发者各式的需求。在本文中,将比较详细地介绍 JVMTI,以及如何使用 JVMTI 编写一个定制的 Agent 。

Agent

Agent 即 JVMTI 的客户端,它和执行 Java 程序的虚拟机运行在同一个进程上,因此通常他们的实现都很紧凑,他们通常由另一个独立的进程控制,充当这个独立进程和当前虚拟机之间的中介,通过调用 JVMTI 提供的接口和虚拟机交互,负责获取并返回当前虚拟机的状态或者转发控制命令。

2、JVMTI 的简介

JVMTI(JVM Tool Interface)是 Java 虚拟机所提供的 native 编程接口,是 JVMPI(Java Virtual Machine Profiler Interface)和 JVMDI(Java Virtual Machine Debug Interface)的更新版本。从这个 API 的发展历史轨迹中我们就可以知道,JVMTI 提供了可用于 debug 和 profiler 的接口;同时,在 Java 5/6 中,虚拟机接口也增加了监听(Monitoring),线程分析(Thread analysis)以及覆盖率分析(Coverage Analysis)等功能。正是由于 JVMTI 的强大功能,它是实现 Java 调试器,以及其它 Java 运行态测试与分析工具的基础。

JVMTI 并不一定在所有的 Java 虚拟机上都有实现,不同的虚拟机的实现也不尽相同。不过在一些主流的虚拟机中,比如 Sun 和 IBM,以及一些开源的如 Apache Harmony DRLVM 中,都提供了标准 JVMTI 实现。

JVMTI 是一套本地代码接口,因此使用 JVMTI 需要我们与 C/C++ 以及 JNI 打交道。事实上,开发时一般采用建立一个 Agent 的方式来使用 JVMTI,它使用 JVMTI 函数,设置一些回调函数,并从 Java 虚拟机中得到当前的运行态信息,并作出自己的判断,最后还可能操作虚拟机的运行态。把 Agent 编译成一个动态链接库之后,我们就可以在 Java 程序启动的时候来加载它(启动加载模式),也可以在 Java 5 之后使用运行时加载(活动加载模式)。

  • agentlib:agent-lib-name=options
  • agentpath:path-to-agent=options

3、Agent 的工作过程

3.1 启动

Agent 是在 Java 虚拟机启动之时加载的,这个加载处于虚拟机初始化的早期,在这个时间点上:

  • 所有的 Java 类都未被初始化;
  • 所有的对象实例都未被创建;
  • 因而,没有任何 Java 代码被执行;

但在这个时候,我们已经可以:

  • 操作 JVMTI 的 Capability 参数;
  • 使用系统参数;

动态库被加载之后,虚拟机会先寻找一个 Agent 入口函数:

JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved)

在这个函数中,虚拟机传入了一个 JavaVM 指针,以及命令行的参数。通过 JavaVM,我们可以获得 JVMTI 的指针,并获得 JVMTI 函数的使用能力,所有的 JVMTI 函数都通过这个 jvmtiEnv 获取,不同的虚拟机实现提供的函数细节可能不一样,但是使用的方式是统一的。

jvmtiEnv *jvmti; 
(*jvm)->GetEnv(jvm, &jvmti, JVMTI_VERSION_1_0);

这里传入的版本信息参数很重要,不同的 JVMTI 环境所提供的功能以及处理方式都可能有所不同,不过它在同一个虚拟机中会保持不变(有心的读者可以去比较一下 JNI 环境)。命令行参数事实上就是上面启动命令行中的 options 部分,在 Agent 实现中需要进行解析并完成后续处理工作。参数传入的字符串仅仅在 Agent_OnLoad 函数里有效,如果需要长期使用,开发者需要做内存的复制工作,同时在最后还要释放这块存储。另外,有些 JDK 的实现会使用 JAVA_TOOL_OPTIONS 所提供的参数,这个常见于一些嵌入式的 Java 虚拟机(不使用命令行)。需要强调的是,这个时候由于虚拟机并未完成初始化工作,并不是所有的 JVMTI 函数都可以被使用。

Agent 还可以在运行时加载,如果您了解 Java Instrument 模块(可以参考这篇文章 ),您一定对它的运行态加载有印象,这个新功能事实上也是 Java Agent 的一个实现。具体说来,虚拟机会在运行时监听并接受 Agent 的加载,在这个时候,它会使用 Agent 的:

JNIEXPORT jint JNICALL Agent_OnAttach(JavaVM* vm, char *options, void *reserved);

同样的在这个初始化阶段,不是所有的 JVMTI 的 Capability 参数都处于可操作状态,而且 options 这个 char 数组在这个函数运行之后就会被丢弃,如果需要,需要做好保留工作。

Agent 的主要功能是通过一系列的在虚拟机上设置的回调(callback)函数完成的,一旦某些事件发生,Agent 所设置的回调函数就会被调用,来完成特定的需求。

3.2 卸载

最后,Agent 完成任务,或者虚拟机关闭的时候,虚拟机都会调用一个类似于类析构函数的方法来完成最后的清理任务,注意这个函数和虚拟机自己的 VM_DEATH 事件是不同的。

JNIEXPORT void JNICALL Agent_OnUnload(JavaVM *vm)

4、JVMTI 的环境和错误处理

我们使用 JVMTI 的过程,主要是设置 JVMTI 环境,监听虚拟机所产生的事件,以及在某些事件上加上我们所希望的回调函数。

4.1 JVMTI 环境

我们可以通过操作 jvmtiCapabilities 来查询、增加、修改 JVMTI 的环境参数。当然,对于每一个不同的虚拟机来说,基于他们的实现不尽相同,导致了 JVMTI 的环境也不一定一致。标准的 jvmtiCapabilities 定义了一系列虚拟机的功能,比如 can_redefine_any_class 定义了虚拟机是否支持重定义类,can_retransform_classes 定义了是否支持在运行的时候改变类定义等等。如果熟悉 Java Instrumentation,一定不会对此感到陌生,因为 Instrumentation 就是对这些在 Java 层上的包装。对用户来说,这块最主要的是查看当前 JVMTI 环境,了解虚拟机具有的功能。要了解这个,其实很简单,只需通过对 jvmtiCapabilities 的一系列变量的考察就可以。

err = (*jvmti)->GetCapabilities(jvmti, &capa); // 取得 jvmtiCapabilities 指针。
if (err == JVMTI_ERROR_NONE) { 
    if (capa.can_redefine_any_class) { ... } 
} // 查看是否支持重定义类

另外,虚拟机有自己的一些功能,一开始并未被启动,那么增加或修改 jvmtiCapabilities 也是可能的,但不同的虚拟机对这个功能的处理也不太一样,多数的虚拟机允许增改,但是有一定的限制,比如仅支持在 Agent_OnLoad 时,即虚拟机启动时作出,它某种程度上反映了虚拟机本身的构架。开发人员无需要考虑 Agent 的性能和内存占用,就可以在 Agent 被加载的时候启用所有功能:

err = (*jvmti)->GetPotentialCapabilities(jvmti, &capa); // 取得所有可用的功能
if (err == JVMTI_ERROR_NONE) { 
    err = (*jvmti)->AddCapabilities(jvmti, &capa); 
    ... 
}

最后我们要注意的是,JVMTI 的函数调用都有其时间性,即特定的函数只能在特定的虚拟机状态下才能调用,比如 SuspendThread(挂起线程)这个动作,仅在 Java 虚拟机处于运行状态(live phase)才能调用,否则导致一个内部异常。

4.2 JVMTI 错误处理

JVMTI 沿用了基本的错误处理方式,即使用返回的错误代码通知当前的错误,几乎所有的 JVMTI 函数调用都具有以下模式:

jvmtiError err = jvmti->someJVMTImethod (somePara … );

其中 err 就是返回的错误代码,不同函数的错误信息可以在 Java 规范里查到。

5、JVMTI 基本功能

JVMTI 的功能非常丰富,包含了虚拟机中线程、内存 / 堆 / 栈,类 / 方法 / 变量,事件 / 定时器处理等等 20 多类功能,下面我们介绍一下,并举一些简单列子。

5.1 事件处理和回调函数

从上文我们知道,使用 JVMTI 一个基本的方式就是设置回调函数,在某些事件发生的时候触发并作出相应的动作。因此这一部分的功能非常基本,当前版本的 JVMTI 提供了许多事件(Event)的回调,包括虚拟机初始化、开始运行、结束,类的加载,方法出入,线程始末等等。如果想对这些事件进行处理,我们需要首先为该事件写一个函数,然后在 jvmtiEventCallbacks 这个结构中指定相应的函数指针。比如,我们对线程启动感兴趣,并写了一个 HandleThreadStart 函数,那么我们需要在 Agent_OnLoad 函数里加入:

jvmtiEventCallbacks eventCallBacks; 
memset(&ecbs, 0, sizeof(ecbs)); // 初始化
eventCallBacks.ThreadStart = &HandleThreadStart; // 设置函数指针
...

在设置了这些回调之后,就可以调用下述方法,来最终完成设置。在接下来的虚拟机运行过程中,一旦有线程开始运行发生,虚拟机就会回调 HandleThreadStart 方法。

jvmti->SetEventCallbacks(eventCallBacks, sizeof(eventCallBacks));

设置回调函数的时候,开发者需要注意以下几点:

  • 如同 Java 异常机制一样,如果在回调函数中自己抛出一个异常(Exception),或者在调用 JNI 函数的时候制造了一些麻烦,让 JNI 丢出了一个异常,那么任何在回调之前发生的异常就会丢失,这就要求开发人员要在处理错误的时候需要当心。
  • 虚拟机不保证回调函数会被同步,换句话说,程序有可能同时运行同一个回调函数(比如,好几个线程同时开始运行了,这个 HandleThreadStart 就会被同时调用几次),那么开发人员在开发回调函数时需要处理同步的问题。

5.2 内存控制和对象获取

内存控制是一切运行态的基本功能。 JVMTI 除了提供最简单的内存申请和撤销之外(这块内存不受 Java 堆管理,开发人员需要自行进行清理工作,不然会造成内存泄漏),也提供了对 Java 堆的操作。众所周知,Java 堆中存储了 Java 的类、对象和基本类型(Primitive),通过对堆的操作,开发人员可以很容易的查找任意的类、对象,甚至可以强行执行垃圾收集工作。 JVMTI 中对 Java 堆的操作与众不同,它没有提供一个直接获取的方式(由此可见,虚拟机对对象的管理并非是哈希表,而是某种树 / 图方式),而是使用一个迭代器(iterater)的方式遍历:

jvmtiError FollowReferences(jvmtiEnv* env, 
    jint heap_filter, 
    jclass klass, 
    jobject initial_object,// 该方式可以指定根节点
    const jvmtiHeapCallbacks* callbacks,// 设置回调函数
    const void* user_data)

或者

jvmtiError IterateThroughHeap(jvmtiEnv* env, 
    jint heap_filter, 
    jclass klass, 
    const jvmtiHeapCallbacks* callbacks, 
    const void* user_data)// 遍历整个 heap

在遍历的过程中,开发者可以设定一定的条件,比如,指定是某一个类的对象,并设置一个回调函数,如果条件被满足,回调函数就会被执行。开发者可以在回调函数中对当前传回的指针进行打标记(tag)操作 —— 这又是一个特殊之处,在第一遍遍历中,只能对满足条件的对象进行 tag ;然后再使用 GetObjectsWithTags 函数,获取需要的对象。

jvmtiError GetObjectsWithTags(jvmtiEnv* env, 
    jint tag_count, 
    const jlong* tags, // 设定特定的 tag,即我们上面所设置的
    jint* count_ptr, 
    jobject** object_result_ptr, 
    jlong** tag_result_ptr)

如果你仅仅想对特定 Java 对象操作,应该避免设置其他类型的回调函数,否则会影响效率,举例来说,多增加一个 primitive 的回调函数,可能会使整个操作效率下降一个数量级。

5.3 线程和锁

线程是 Java 运行态中非常重要的一个部分,在 JVMTI 中也提供了很多 API 进行相应的操作,包括查询当前线程状态,暂停,恢复或者终端线程,还可以对线程锁进行操作。开发者可以获得特定线程所拥有的锁:

jvmtiError GetOwnedMonitorInfo(jvmtiEnv* env, 
    jthread thread, 
    jint* owned_monitor_count_ptr, 
    jobject** owned_monitors_ptr)

也可以获得当前线程正在等待的锁:

jvmtiError GetCurrentContendedMonitor(jvmtiEnv* env, 
    jthread thread, 
    jobject* monitor_ptr)

知道这些信息,事实上我们也可以设计自己的算法来判断是否死锁。更重要的是,JVMTI 提供了一系列的监视器(Monitor)操作,来帮助我们在 native 环境中实现同步。主要的操作是构建监视器(CreateRawMonitor),获取监视器(RawMonitorEnter),释放监视器(RawMonitorExit),等待和唤醒监视器 (RawMonitorWait,RawMonitorNotify) 等操作,通过这些简单锁,程序的同步操作可以得到保证。

5.4 调试功能

调试功能是 JVMTI 的基本功能之一,这主要包括了设置断点、调试(step)等,在 JVMTI 里面,设置断点的 API 本身很简单:

jvmtiError SetBreakpoint(jvmtiEnv* env, 
    jmethodID method, 
    jlocation location)

jlocation 这个数据结构在这里代表的是对应方法方法中一个可执行代码的行数。在断点发生的时候,虚拟机会触发一个事件,开发者可以使用在上文中介绍过的方式对事件进行处理。

6、JVMTI 数据结构

JVMTI 中使用的数据结构,首先也是一些标准的 JNI 数据结构,比如 jint,jlong ;其次,JVMTI 也定义了一些基本类型,比如 jthread,表示一个 thread,jvmtiEvent,表示 jvmti 所定义的事件;更复杂的有 JVMTI 的一些需要用结构体表示的数据结构,比如堆的信息(jvmtiStackInfo)。这些数据结构在文档中都有清楚的定义,本文就不再详细解释。

7、一个简单的 Agent 实现

下面将通过一个具体的例子,来阐述如何开发一个简单的 Agent 。这个 Agent 是通过 C++ 编写的(读者可以在最后下载到完整的代码),他通过监听 JVMTI_EVENT_METHOD_ENTRY 事件,注册对应的回调函数来响应这个事件,来输出所有被调用函数名。有兴趣的读者还可以参照这个基本流程,通过 JVMTI 提供的丰富的函数来进行扩展和定制。

7.1 Agent 的设计

具体实现都在 MethodTraceAgent 这个类里提供。按照顺序,他会处理环境初始化、参数解析、注册功能、注册事件响应,每个功能都被抽象在一个具体的函数里。

class MethodTraceAgent 
{ 
    public: 
        void Init(JavaVM *vm) const throw(AgentException); 
        void ParseOptions(const char* str) const throw(AgentException); 
        void AddCapability() const throw(AgentException); 
        void RegisterEvent() const throw(AgentException); 
        ... 
     
    private: 
        ... 
        static jvmtiEnv * m_jvmti; 
        static char* m_filter; 
 };

Agent_OnLoad 函数会在 Agent 被加载的时候创建这个类,并依次调用上述各个方法,从而实现这个 Agent 的功能。

JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved) 
{ 
    ... 
    MethodTraceAgent* agent = new MethodTraceAgent(); 
    agent->Init(vm); 
    agent->ParseOptions(options); 
    agent->AddCapability(); 
    agent->RegisterEvent(); 
    ... 
}

运行过程如下图所示:

Untitled

7.2 Agent 编译和运行

Agent 的编译非常简单,他和编译普通的动态链接库没有本质区别,只是需要将 JDK 提供的一些头文件包含进来。

Windows:

cl /EHsc -I${JAVA_HOME}\include\ -I${JAVA_HOME}\include\win32 
-LD MethodTraceAgent.cpp Main.cpp -FeAgent.dll

Linux:

g++ -I${JAVA_HOME}/include/ -I${JAVA_HOME}/include/linux 
MethodTraceAgent.cpp Main.cpp -fPIC -shared -o libagent.so

在附带的代码文件里提供了一个可运行的 Java 类,默认情况下运行的结果如下图所示:

Untitled

现在,我们运行程序前告诉 Java 先加载编译出来的 Agent:

java -agentlib:Agent=first MethodTraceTest

这次的输出如图 3. 所示:

Untitled

可以当程序运行到 MethodTraceTest 的 first 方法时,Agent 会输出这个事件。“first” 是 Agent 运行的参数,如果不指定的话,所有的进入方法的触发的事件都会被输出,如果读者把这个参数去掉再运行的话,会发现在运行 main 函数前,已经有非常多基本的类库函数被调用了。


三、JDWP 协议及实现

JDWP 是 Java Debug Wire Protocol 的缩写,它定义了调试器(debugger)和被调试的 Java 虚拟机(target vm)之间的通信协议。

1、JDWP 协议介绍

这里首先要说明一下 debugger 和 target vm。Target vm 中运行着我们希望要调试的程序,它与一般运行的 Java 虚拟机没有什么区别,只是在启动时加载了 Agent JDWP 从而具备了调试功能。而 debugger 就是我们熟知的调试器,它向运行中的 target vm 发送命令来获取 target vm 运行时的状态和控制 Java 程序的执行。Debugger 和 target vm 分别在各自的进程中运行,他们之间的通信协议就是 JDWP。

JDWP 与其他许多协议不同,它仅仅定义了数据传输的格式,但并没有指定具体的传输方式。这就意味着一个 JDWP 的实现可以不需要做任何修改就正常工作在不同的传输方式上(在 JDWP 传输接口中会做详细介绍)。

JDWP 是语言无关的。理论上我们可以选用任意语言实现 JDWP。然而我们注意到,在 JDWP 的两端分别是 target vm 和 debugger。Target vm 端,JDWP 模块必须以 Agent library 的形式在 Java 虚拟机启动时加载,并且它必须通过 Java 虚拟机提供的 JVMTI 接口实现各种 debug 的功能,所以必须使用 C/C++ 语言编写。而 debugger 端就没有这样的限制,可以使用任意语言编写,只要遵守 JDWP 规范即可。JDI(Java Debug Interface)就包含了一个 Java 的 JDWP debugger 端的实现(JDI 将在该系列的下一篇文章中介绍),JDK 中调试工具 jdb 也是使用 JDI 完成其调试功能的。

Untitled

2、协议分析

JDWP 大致分为两个阶段:握手和应答。握手是在传输层连接建立完成后,做的第一件事:

Debugger 发送 14 bytes 的字符串 “JDWP-Handshake” 到 target Java 虚拟机

Target Java 虚拟机回复 “JDWP-Handshake”

Untitled

握手完成,debugger 就可以向 target Java 虚拟机发送命令了。JDWP 是通过命令(command)和回复(reply)进行通信的,这与 HTTP 有些相似。JDWP 本身是无状态的,因此对 command 出现的顺序并不受限制。

JDWP 有两种基本的包(packet)类型:

命令包(command packet)

回复包(reply packet)

Debugger 和 target Java 虚拟机都有可能发送 command packet。Debugger 通过发送 command packet 获取 target Java 虚拟机的信息以及控制程序的执行。Target Java 虚拟机通过发送 command packet 通知 debugger 某些事件的发生,如到达断点或是产生异常。

Reply packet 是用来回复 command packet 该命令是否执行成功,如果成功 reply packet 还有可能包含 command packet 请求的数据,比如当前的线程信息或者变量的值。从 target Java 虚拟机发送的事件消息是不需要回复的。

还有一点需要注意的是,JDWP 是异步 的:command packet 的发送方不需要等待接收到 reply packet 就可以继续发送下一个 command packet。

3、Packet 的结构

Packet 分为包头(header)和数据(data)两部分组成。包头部分的结构和长度是固定,而数据部分的长度是可变的,具体内容视 packet 的内容而定。Command packet 和 reply packet 的包头长度相同,都是 11 个 bytes,这样更有利于传输层的抽象和实现。

Command packet 的 header 的结构 :

Untitled

  • Length 是整个 packet 的长度,包括 length 部分。因为包头的长度是固定的 11 bytes,所以如果一个 command packet 没有数据部分,则 length 的值就是 11。
  • Id 是一个唯一值,用来标记和识别 reply 所属的 command。Reply packet 与它所回复的 command packet 具有相同的 Id,异步的消息就是通过 Id 来配对识别的。
  • Flags 目前对于 command packet 值始终是 0。
  • Command Set 相当于一个 command 的分组,一些功能相近的 command 被分在同一个 Command Set 中。Command Set 的值被划分为 3 个部分:
    • 0-63: 从 debugger 发往 target Java 虚拟机的命令
    • 64 – 127: 从 target Java 虚拟机发往 debugger 的命令
    • 128 – 256: 预留的自定义和扩展命令

Reply packet 的 header 的结构:

Untitled

  • Length、Id 作用与 command packet 中的一样。
  • Flags 目前对于 reply packet 值始终是 0x80。我们可以通过 Flags 的值来判断接收到的 packet 是 command 还是 reply。
  • Error Code 用来表示被回复的命令是否被正确执行了。零表示正确,非零表示执行错误。

Data 的内容和结构依据不同的 command 和 reply 都有所不同。比如请求一个对象成员变量值的 command,它的 data 中就包含该对象的 id 和成员变量的 id。而 reply 中则包含该成员变量的值。

JDWP 还定义了一些数据类型专门用来传递 Java 相关的数据信息。下面列举了一些数据类型:

名称长度说明
byte1 bytebyte 值。
boolean1 byte布尔值,0 表示假,非零表示真。
int4 byte4 字节有符号整数。
long8 byte8 字节有符号整数。
objectID依据 target Java 虚拟机而定,最大 8 byteTarget Java 虚拟机中对象(object)的唯一 ID。这个值在整个 JDWP 的会话中不会被重用,始终指向同一个对象,即使该对象已经被 GC 回收(引用被回收的对象将返回 INVALID_OBJECT 错误。
Tagged-objectIDobjectID 的长度加 1第一个 byte 表示对象的类型,比如,整型,字符串,类等等。紧接着是一个 objectID。
threadID同 objectID 的长度表示 Target Java 虚拟机中的一个线程对象
stringID同 objectID 的长度表示 Target Java 虚拟机中的一字符串对象
referenceTypeID同 objectID 的长度表示 Target Java 虚拟机中的一个引用类型对象,即类(class)的唯一 ID。
classID同 objectID 的长度表示 Target Java 虚拟机中的一个类对象。
methodID依据 target Java 虚拟机而定,最大 8 byteTarget Java 虚拟机某个类中的方法的唯一 ID。methodID 必须在他所属类和所属类的所有子类中保持唯一。从整个 Java 虚拟机来看它并不是唯一的。methodID 与它所属类的 referenceTypeID 一起在整个 Java 虚拟机中是唯一的。
fieldID依据 target Java 虚拟机而定,最大 8 byte与 methodID 类似,Target Java 虚拟机某个类中的成员的唯一 ID。
frameID依据 target Java 虚拟机而定,最大 8 byteJava 中栈中的每一层方法调用都会生成一个 frame。frameID 在整个 target Java 虚拟机中是唯一的,并且只在线程挂起(suspended)的时候有效。
location依据 target Java 虚拟机而定,最大 8 byte一个可执行的位置。Debugger 用它来定位 stepping 时在源代码中的位置。

4、JDWP 传输接口(Java Debug Wire Protocol Transport Interface)

前面提到 JDWP 的定义是与传输层独立的,但如何使 JDWP 能够无缝的使用不同的传输实现,而又无需修改 JDWP 本身的代码? JDWP 传输接口(Java Debug Wire Protocol Transport Interface)为我们解决了这个问题。

JDWP 传输接口定义了一系列的方法用来定义 JDWP 与传输层实现之间的交互方式。首先传输层的必须以动态链接库的方式实现,并且暴露一系列的标准接口供 JDWP 使用。与 JNI 和 JVMTI 类似,访问传输层也需要一个环境指针(jdwpTransport),通过这个指针可以访问传输层提供的所有方法。

当 JDWP agent 被 Java 虚拟机加载后,JDWP 会根据参数去加载指定的传输层实现(Sun 的 JDK 在 Windows 提供 socket 和 share memory 两种传输方式,而在 Linux 上只有 socket 方式)。传输层实现的动态链接库实现必须暴露 jdwpTransport_OnLoad 接口,JDWP agent 在加载传输层动态链接库后会调用该接口进行传输层的初始化。接口定义如下:

JNIEXPORT jint JNICALL 
jdwpTransport_OnLoad(JavaVM *jvm, 
    jdwpTransportCallback *callback, 
    jint version, 
    jdwpTransportEnv** env);

callback 参数指向一个内存管理的函数表,传输层用它来进行内存的分配和释放,结构定义如下:

typedef struct jdwpTransportCallback { 
    void* (*alloc)(jint numBytes); 
    void (*free)(void *buffer); 
} jdwpTransportCallback;

env 参数是环境指针,指向的函数表由传输层初始化。

JDWP 传输层定义的接口主要分为两类:连接管理和 I/O 操作。

5、连接管理

连接管理接口主要负责连接的建立和关闭。一个连接为 JDWP 和 debugger 提供了可靠的数据流。Packet 被接收的顺序严格的按照被写入连接的顺序。

连接的建立是双向的,即 JDWP 可以主动去连接 debugger 或者 JDWP 等待 debugger 的连接。对于主动去连接 debugger,需要调用方法 Attach,定义如下:

jdwpTransportError 
Attach(jdwpTransportEnv* env, const char* address, 
    jlong attachTimeout, jlong handshakeTimeout)

在连接建立后,会立即进行握手操作,确保对方也在使用 JDWP。因此方法参数中分别指定了 attch 和握手的超时时间。

address 参数因传输层的实现不同而有不同的格式。对于 socket,address 是主机地址;对于 share memory 则是共享内存的名称。

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

jdwpTransportError 
StartListening(jdwpTransportEnv* env, const char* address, char** actualAddress)

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

jdwpTransportError 
Accept(jdwpTransportEnv* env, jlong acceptTimeout, jlong handshakeTimeout)

与 Attach 方法类似,在连接建立后,会立即进行握手操作。

6、I/O 操作

I/O 操作接口主要是负责从传输层读写 packet。有 ReadPacket 和 WritePacket 两个方法:

jdwpTransportError 
ReadPacket(jdwpTransportEnv* env, jdwpPacket* packet) 
 
jdwpTransportError 
WritePacket(jdwpTransportEnv* env, const jdwpPacket* packet)

参数 packet 是要被读写的 packet,其结构 jdwpPacket 与我们开始提到的 JDWP packet 结构一致,定义如下:

typedef struct { 
    jint len;        // packet length 
    jint id;         // packet id 
    jbyte flags;     // value is 0 
    jbyte cmdSet;    // command set 
    jbyte cmd;       // command in specific command set 
    jbyte *data;     // data carried by packet 
} jdwpCmdPacket; 
 
typedef struct { 
    jint len;        // packet length 
    jint id;         // packet id 
    jbyte flags;     // value 0x80 
    jshort errorCode;    // error code 
    jbyte *data;     // data carried by packet 
} jdwpReplyPacket; 
 
typedef struct jdwpPacket { 
    union { 
        jdwpCmdPacket cmd; 
        jdwpReplyPacket reply; 
    } type; 
} jdwpPacket;

7、JDWP 的命令实现机制

下面将通过讲解一个 JDWP 命令的实例来介绍 JDWP 命令的实现机制。JDWP 作为一种协议,它的作用就在于充当了调试器与 Java 虚拟机的沟通桥梁。通俗点讲,调试器在调试过程中需要不断向 Java 虚拟机查询各种信息,那么 JDWP 就规定了查询的具体方式。

在 Java 6.0 中,JDWP 包含了 18 组命令集合,其中每个命令集合又包含了若干条命令。那么这些命令是如何实现的呢?下面我们先来看一个最简单的 VirtualMachine(命令集合 1)的 Version 命令,以此来剖析其中的实现细节。

因为 JDWP 在整个 JPDA 框架中处于相对底层的位置,我们无法在现实应用中来为大家演示 JDWP 的单个命令的执行过程。在这里我们通过一个针对该命令的 Java 测试用例来说明。

CommandPacket packet = new CommandPacket( 
    JDWPCommands.VirtualMachineCommandSet.CommandSetID, 
    JDWPCommands.VirtualMachineCommandSet.VersionCommand); 
         
ReplyPacket reply = debuggeeWrapper.vmMirror.performCommand(packet); 
 
String description = reply.getNextValueAsString(); 
int    jdwpMajor   = reply.getNextValueAsInt(); 
int    jdwpMinor   = reply.getNextValueAsInt(); 
String vmVersion   = reply.getNextValueAsString(); 
String vmName      = reply.getNextValueAsString(); 
 
logWriter.println("description\t= " + description); 
logWriter.println("jdwpMajor\t= " + jdwpMajor); 
logWriter.println("jdwpMinor\t= " + jdwpMinor); 
logWriter.println("vmVersion\t= " + vmVersion); 
logWriter.println("vmName\t\t= " + vmName);

这里先简单介绍一下这段代码的作用。

首先,我们会创建一个 VirtualMachine 的 Version 命令的命令包实例 packet。你可能已经注意到,该命令包主要就是配置了两个参数 : CommandSetID 和 VersionComamnd,它们的值均为 1。表明我们想执行的命令是属于命令集合 1 的命令 1,即 VirtualMachine 的 Version 命令。

然后在 performCommand 方法中我们发送了该命令并收到了 JDWP 的回复包 reply。通过解析 reply,我们得到了该命令的回复信息。

description = Java 虚拟机 version 1.6.0 (IBM J9 VM, J2RE 1.6.0 IBM J9 2.4 Windows XP x86-32 
jvmwi3260sr5-20090519_35743 (JIT enabled, AOT enabled) 
J9VM - 20090519_035743_lHdSMr 
JIT  - r9_20090518_2017 
GC   - 20090417_AA, 2.4) 
jdwpMajor    = 1 
jdwpMinor    = 6 
vmVersion    = 1.6.0 
vmName       = IBM J9 VM

测试用例的执行结果显示,我们通过该命令获得了 Java 虚拟机的版本信息,这正是 VirtualMachine 的 Version 命令的作用。

前面已经提到,JDWP 接收到的是调试器发送的命令包,返回的就是反馈信息的回复包。在这个例子中,我们模拟的调试器会发送 VirtualMachine 的 Version 命令。JDWP 在执行完该命令后就向调试器返回 Java 虚拟机的版本信息。

返回信息的包内容同样是在 JDWP Spec 里面规定的。比如本例中的回复包,Spec 中的描述如下(测试用例中的回复包解析就是参照这个规定的 ):

类型名称说明
stringdescriptionVM version 的文字描述信息。
intjdwpMajorJDWP 主版本号。
intjdwpMinorJDWP 次版本号。
stringvmVersionVM JRE 版本,也就是 java.version 属性值。
stringvmNameVM 的名称,也就是 java.vm.name 属性值。

通过这个简单的例子,相信大家对 JDWP 的命令已经有了一个大体的了解。 那么在 JDWP 内部是如何处理接收到的命令并返回回复包的呢?下面以 Apache Harmony 的 JDWP 为例,为大家介绍其内部的实现架构。

Untitled

如图所示,JDWP 接收和发送的包都会经过 TransportManager 进行处理。JDWP 的应用层与传输层是独立的,就在于 TransportManager 调用的是 JDWP 传输接口 (Java Debug Wire Protocol Transport Interface),所以无需关心底层网络的具体传输实现。TransportManager 的主要作用就是充当 JDWP 与外界通讯的数据包的中转站,负责将 JDWP 的命令包在接收后进行解析或是对回复包在发送前进行打包,从而使 JDWP 能够专注于应用层的实现。

对于收到的命令包,TransportManager 处理后会转给 PacketDispatcher,进一步封装后会继续转到 CommandDispatcher。然后,CommandDispatcher 会根据命令中提供的命令组号(CommandSet)和命令号(Command)创建一个具体的 CommandHandler 来处理 JDWP 命令。

其中,CommandHandler 才是真正执行 JDWP 命令的类。我们会为每个 JDWP 命令都定义一个相对应的 CommandHandler 的子类,当接收到某个命令时,就会创建处理该命令的 CommandHandler 的子类的实例来作具体的处理。

Untitled

8、单线程执行的命令

上图就是一个命令的处理流程图。可以看到,对于一个可以直接在该线程中完成的命令(我们称为单线程执行的命令),一般其内部会调用 JVMTI 方法和 JNI 方法来真正对 Java 虚拟机进行操作。

例如,VirtualMachine 的 Version 命令中,对于 vmVersion 和 vmName 属性,我们可以通过 JNI 来调用 Java 方法 System.getProperty 来获取。然后,JDWP 将回复包中所需要的结果封装到包中后交由 TransportManager 来进行后续操作。

9、多线程执行的命令

对于一些较为复杂的命令,是无法在 CommandHandler 子类的处理线程中完成的。例如,ClassType 的 InvokeMethod 命令,它会要求在指定的某个线程中执行一个静态方法。显然,CommandHandler 子类的当前线程并不是所要求的线程。

这时,JDWP 线程会先把这个请求先放到一个列表中,然后等待,直到所要求的线程执行完那个静态方法后,再把结果返回给调试器

10、JDWP 的事件处理机制

前面介绍的 VirtualMachine 的 Version 命令过程非常简单,就是一个查询和信息返回的过程。在实际调试过程中,一个 JDI 的命令往往会有数条这类简单的查询命令参与,而且会涉及到很多更为复杂的命令。要了解更为复杂的 JDWP 命令实现机制,就必须介绍 JDWP 的事件处理机制。

在 Java 虚拟机中,我们会接触到许多事件,例如 VM 的初始化,类的装载,异常的发生,断点的触发等等。那么这些事件调试器是如何通过 JDWP 来获知的呢?下面,我们通过介绍在调试过程中断点的触发是如何实现的,来为大家揭示其中的实现机制。

在这里,我们任意调试一段 Java 程序,并在某一行中加入断点。然后,我们执行到该断点,此时所有 Java 线程都处于 suspend 状态。这是很常见的断点触发过程。为了记录在此过程中 JDWP 的行为,我们使用了一个开启了 trace 信息的 JDWP。虽然这并不是一个复杂的操作,但整个 trace 信息也有几千行。

可见,作为相对底层的 JDWP,其实际处理的命令要比想象的多许多。为了介绍 JDWP 的事件处理机制,我们挑选了其中比较重要的一些 trace 信息来说明:

[RequestManager.cpp:601] AddRequest: event=BREAKPOINT[2], req=48, modCount=1, policy=1 
[RequestManager.cpp:791] GenerateEvents: event #0: kind=BREAKPOINT, req=48 
[RequestManager.cpp:1543] HandleBreakpoint: BREAKPOINT events: count=1, suspendPolicy=1, 
                          location=0 
[RequestManager.cpp:1575] HandleBreakpoint: post set of 1 
[EventDispatcher.cpp:415] PostEventSet -- wait for release on event: thread=4185A5A0, 
                          name=(null), eventKind=2 
 
[EventDispatcher.cpp:309] SuspendOnEvent -- send event set: id=3, policy=1 
[EventDispatcher.cpp:334] SuspendOnEvent -- wait for thread on event: thread=4185A5A0, 
                          name=(null) 
[EventDispatcher.cpp:349] SuspendOnEvent -- suspend thread on event: thread=4185A5A0, 
                          name=(null) 
[EventDispatcher.cpp:360] SuspendOnEvent -- release thread on event: thread=4185A5A0, 
                          name=(null)

首先,调试器需要发起一个断点的请求,这是通过 JDWP 的 Set 命令完成的。在 trace 中,我们看到 AddRequest 就是做了这件事。可以清楚的发现,调试器请求的是一个断点信息(event=BREAKPOINT [2])。

在 JDWP 的实现中,这一过程表现为:在 Set 命令中会生成一个具体的 request, JDWP 的 RequestManager 会记录这个 request(request 中会包含一些过滤条件,当事件发生时 RequestManager 会过滤掉不符合预先设定条件的事件),并通过 JVMTI 的 SetEventNotificationMode 方法使这个事件触发生效(否则事件发生时 Java 虚拟机不会报告)。

Untitled

当断点发生时,Java 虚拟机就会调用 JDWP 中预先定义好的处理该事件的回调函数。在 trace 中,HandleBreakpoint 就是我们在 JDWP 中定义好的处理断点信息的回调函数。它的作用就是要生成一个 JDWP 端所描述的断点事件来告知调试器(Java 虚拟机只是触发了一个 JVMTI 的消息)。

由于断点的事件在调试器申请时就要求所有 Java 线程在断点触发时被 suspend,那这一步由谁来完成呢?这里要谈到一个细节问题,HandleBreakpoint 作为一个回调函数,其执行线程其实就是断点触发的 Java 线程。

显然,我们不应该由它来负责 suspend 所有 Java 线程。

原因很简单,我们还有一步工作要做,就是要把该断点触发信息返回给调试器。如果我们先返回信息,然后 suspend 所有 Java 线程,这就无法保证在调试器收到信息时所有 Java 线程已经被 suspend。

反之,先 Suspend 了所有 Java 线程,谁来负责发送信息给调试器呢?

为了解决这个问题,我们通过 JDWP 的 EventDispatcher 线程来帮我们 suspend 线程和发送信息。实现的过程是,我们让触发断点的 Java 线程来 PostEventSet(trace 中可以看到),把生成的 JDWP 事件放到一个队列中,然后就开始等待。由 EventDispatcher 线程来负责从队列中取出 JDWP 事件,并根据事件中的设定,来 suspend 所要求的 Java 线程并发送出该事件。

在这里,我们在事件触发的 Java 线程和 EventDispatcher 线程之间添加了一个同步机制,当事件发送出去后,事件触发的 Java 线程会把 JDWP 中的该事件删除,到这里,整个 JDWP 事件处理就完成了。

四、Java 调试接口(JDI)

1、JDI 简介

JDI(Java Debug Interface)是 JPDA 三层模块中最高层的接口,定义了调试器(Debugger)所需要的一些调试接口。基于这些接口,调试器可以及时地了解目标虚拟机的状态,例如查看目标虚拟机上有哪些类和实例等。另外,调试者还可以控制目标虚拟机的执行,例如挂起和恢复目标虚拟机上的线程,设置断点等。

目前,大多数的 JDI 实现都是通过 Java 语言编写的。比如,Java 开发者再熟悉不过的 Eclipse IDE,它的调试工具相信大家都使用过。它的两个插件 org.eclipse.jdt.debug.ui 和 org.eclipse.jdt.debug 与其强大的调试功能密切相关,其中 org.eclipse.jdt.debug.ui 是 Eclipse 调试工具界面的实现,而 org.eclipse.jdt.debug 则是 JDI 的一个完整实现。

2、JDI 工作方式

首先,调试器(Debugger)通过 Bootstrap 获取唯一的虚拟机管理器:

VirtualMachineManager virtualMachineManager = Bootstrap.virtualMachineManager();

虚拟机管理器将在第一次被调用时初始化可用的链接器。一般地,调试器会默认地采用启动型链接器进行链接:

LaunchingConnector defaultConnector = virtualMachineManager.defaultConnector();

然后,调试器调用链接器的 launch () 来启动目标程序,并完成调试器与目标虚拟机的链接:

VirtualMachine targetVM = defaultConnector.launch(arguments);

当链接完成后,调试器与目标虚拟机便可以进行双向通信了。调试器将用户的操作转化为调试命令,命令通过链接被发送到前端运行目标程序的虚拟机上;然后,目标虚拟机根据接受的命令做出相应的操作,将调试的结果发回给后端的调试器;最后,调试器可视化数据信息反馈给用户。

从功能上,可以将 JDI 分成三个部分:

  • 数据模块
  • 链接模块
  • 事件请求与处理模块

数据模块负责调试器和目标虚拟机上的数据建模,链接模块建立调试器与目标虚拟机的沟通渠道,事件请求与处理模块提供调试器与目标虚拟机交互方式,下面将逐一地介绍它们

3、JDI 数据模块

3.1 Mirror

Mirror 接口是 JDI 最底层的接口,JDI 中几乎所有其他接口都继承于它。镜像机制是将目标虚拟机上的所有数据、类型、域、方法、事件、状态和资源,以及调试器发向目标虚拟机的事件请求等都映射成 Mirror 对象。例如,在目标虚拟机上,已装载的类被映射成 ReferenceType 镜像,对象实例被映射成 ObjectReference 镜像,基本类型的值(如 float 等)被映射成 PrimitiveValue(如 FloatValue 等)。被调试的目标程序的运行状态信息被映射到 StackFrame 镜像中,在调试过程中所触发的事件被映射成 Event 镜像(如 StepEvent 等),调试器发出的事件请求被映射成 EventRequest 镜像(如 StepRequest 等),被调试的目标虚拟机则被映射成 VirtualMachine 镜像。但是,JDI 并不保证目标虚拟机上的每份信息和资源都只有唯一的镜像与之对应,这是由 JDI 的具体实现所决定的。例如,目标虚拟机上的某个事件有可能存在多个 Event 镜像与之对应,例如 BreakpointEvent 等。

Mirror 实例或是由调试器创建,或是由目标虚拟机创建,调用 Mirror 实例 virtualMachine () 可以获取其虚拟机信息,如下所示。

VirtualMachine virtualMachine = mirror.virtualMachine();

返回的目标虚拟机对象实现了 VirtualMachine 接口,该接口提供了一套方法,可以用来直接或间接地获取目标虚拟机上所有的数据和状态信息,也可以挂起、恢复、终止目标虚拟机

Untitled

这样,调试器便可以获取目标虚拟机上的信息,维持与目标虚拟机间的通信,并且检查,修改和控制目标虚拟机上资源等。

3.2 Value 和 Type

Value 和 Type 接口分别代表着目标虚拟机中对象、实例变量和方法变量的值和类型。通过 Value 接口的 type (),可以获取该值对应的类型。JDI 中定义了两种基本的数据类型:原始类型(PrimitiveType)和引用类型(ReferenceType)。与其对应的数值类型分别是原始值(PrimtiveValue)和对象引用(ObjectReference)。Value 和 Type 的具体对应关系,请参见下表:

(Value, Type)说明
(ByteValue, ByteType)表示一个字节
(CharValue, CharType)表示一个字符
(ShortValue, ShortType)表示一个短整型数据
(IntegerValue, IntegerType)表示一个整型数据
(LongValue, LongType)表示一个长整型数据
(FloatValue, FloatType)表示一个浮点型数据
(DoubleValue, DoubleType)表示一个双精度浮点型数据
(BooleanValue, BooleanType)表示一个布尔型数据
(ObjectReference, ReferenceType)表示目标虚拟机上的一个对象
(ArrayReference, ArrayType)表示目标虚拟机上的一个数组
(StringReference, ClassType)表示目标虚拟机上的一个字符串对象
(ThreadReference, ClassType)表示目标虚拟机上的一个线程对象,有一套方法可以获得当前设置的断点,堆栈,也能挂起和恢复该线程等
(ThreadGroupReference, ClassType)表示目标虚拟机上的一个线程组对象
(ClassObjectReference, ClassType)表示目标虚拟机上的一个类的 java.lang.Class 实例
(ClassLoaderReference, ClassType)表示目标虚拟机上的一个 ClassLoader 对象
(VoidValue, VoidType)表示 void 类型

PrimitiveType 包括 Java 的 8 种基本类型,ReferenceType 包括目标虚拟机中装载的类,接口和数组的类型(数组也是一种对象,有自己的对象类型)。ReferenceType 有三种子接口:ClassType 对应于加载的类,InterfaceType 对应于接口,ArrayType 对应于数组。另外,ReferenceType 还提供了一组方法,可以用来获取该类型中声明的所有变量、方法、静态变量的取值、内嵌类、运行实例、行号等信息。

PrimtiveValue 封装了 PrimitiveType 的值,它提供一组方法可将 PrimtiveValue 转化为 Java 原始数据。例如,IntegerValue 的 value () 将返回一个 int 型数据。对应地,VirtualMachine 也提供了一组方法,用以将 Java 原始数据转化为 PrimtiveValue 型数据。例如 mirrorOf (float value) 将给定的 float 数据转化为 FloatValue 型数据。

ObjectReference 封装了目标虚拟机中的对象,通过 getValue () 和 setValue () 方法可以访问和修改对象中变量的值,通过 invokeMethod () 可以调用该对象中的指定方法,通过 referringObjects () 可以获得直接引用该对象的其他对象,通过 enableCollection () 和 disableCollection () 可以允许和禁止 GC 回收该对象。

3.3 TypeComponent

TypeComponent 接口表示 Class 或者 Interface 所声明的实体(Entity),它是 Field 和 Method 接口的基类。Field 表示一个类或者实例的变量,调用其 type () 可返回域的类型。Method 表示一个方法。TypeComponent 通过方法 declaredType () 获得声明该变量或方法的类或接口,通过 name () 获得该变量或者方法的名字(对于 Field 返回域名,对于一般方法返回方法名,对于类构造函数返回 ,对于静态初始化构造函数返回 )。

4、JDI 的链接模块

链接是调试器与目标虚拟机之间交互的渠道,一次链接可以由调试器发起,也可以由被调试的目标虚拟机发起。一个调试器可以链接多个目标虚拟机,但一个目标虚拟机最多只能链接一个调试器。链接是由链接器(Connector)生成的,不同的链接器封装着不同的链接方式。JDI 中定义三种链接器接口,分别是

依附型链接器(AttachingConnector)

监听型链接器(ListeningConnector)

启动型链接器(LaunchingConnector)

在调试过程中,实际使用的链接器必须实现其中一种接口。

根据调试器在链接过程中扮演的角色,可以将链接方式划分为主动链接和被动链接。主动链接是较常见一种链接方式,表示调试器主动地向目标虚拟机发起链接。下面将举两个主动链接的例子:

由调试器启动目标虚拟机的链接方式:这是最常见、最简单的一种链接方式。

  • 调试器调用 VirtualMachineManager 的 launchingConnectors () 方法获取所有的启动型链接器实例;
  • 根据传输方式或其他特征选择一个启动型链接器,调用其 launch () 方法启动和链接目标虚拟机;
  • 启动后,返回目标虚拟机的实例。

更高级的,当目标虚拟机已处于运行状态时,可以采用调试器 attach 到目标虚拟机的链接方式:

  • 目标虚拟机必须以 -agentlib:jdwp=transport=xxx,server=y 参数启动,并根据传输方式生成监听地址;(其中,xxx 是传输方式,可以是 dt_socket 和 share_memory)
  • 调试器启动,调用 VirtualMachineManager 的 attachingConnectors () 方法获取所有的依附型链接器实例;
  • 根据目标虚拟机采用的传输方式选择一个依附型链接器,调用其 attach () 方法依附到目标虚拟机上;
  • 完成链接后,返回目标虚拟机的实例。

被动链接表示调试器将被动地等待或者监听由目标虚拟机发起的链接,同样也举两个被动链接的例子:

目标虚拟机 attach 到已运行的调试器上的链接方式:

  • 调试器通过 VirtualMachineManager 的 listeningConnectors () 方法获取所有的监听型链接器实例;
  • 为每种传输类型分别选定一个链接器,然后调用链接器的 startListening () 方法让链接器进入监听状态;
  • 通过 accept () 方法通知链接器开始等待正确的入站链接,该方法将返回调试器正在监听的地址描述符;
  • 终端用户以 -agentlib:jdwp=transport=xxx,address=yyy 参数启动目标虚拟机(其中,yyy 是调试器的监听地址);
  • 目标虚拟机会自动地 attach 到调试器上建立链接,然后返回目标虚拟机的实例。

即时(Just-In-Time)链接方式:

  • 以 -agentlib:jdwp=launch=cmdline,onuncaught=y,transport=xxx,server=y 参数启动目标虚拟机;
  • 虚拟机将抛出一个未捕获的异常,同时生成特定于 xxx 传输方式的监听地址,用于确立一次链接;
  • 目标虚拟机启动调试器,并告知调试器传输方式和监听地址;
  • 启动后,调试器调用 VirtualMachineManager 的 attachingConnectors () 方法获取所有依附型链接器实例;
  • 根据指定的 xxx 传输方式,选择一个链接器;
  • 调用链接器的 attach 方法依附到对应地址的目标虚拟机上;
  • 完成链接后,返回目标虚拟机的实例。

Connector.Argument 是 Connector 的内嵌接口,表示链接器的一个参数,不同类型的链接器支持不同的链接器参数,LaunchingConnector 支持 home,main,suspend 等,AttachingConnector 和 ListeningConnector 支持 timeout,hostname,port 等参数。

Untitled

下面将举一个简单例子,描述如何设置 main 链接参数,并启动目标虚拟机。首先,调用链接器的 defaultArguments () 获取该链接器所支持的一组默认参数:

Map<String,Connector.Argument> defaultArguments = connector.defaultArguments();

默认参数存储在一个 Key-Value 对的 Map 中,Key 是该链接器参数的唯一标识符(对终端用户不可见),Value 是对应的 Connector.Argument 实例(包括具体参数的信息和默认值)。返回的 Map 不能再新增或者删除元素,只能修改已有元素的值。

然后,从返回的 Map 中获取标识符为 main 的链接器参数:

Connector.Argument mainArgument = defaultArguments.get(“main”);

最后,将 main 参数值设置为 com.ibm.jdi.test.HelloWorld,以修改后的参数启动目标虚拟机

mainArgument.setValue(“com.ibm.jdi.test.HelloWorld”);
VirtualMachine targetVM = connector.launch(defaultArguments);

5、JDI 事件请求和处理模块

JDI 的 com.sun.jdi.event 包定义了如下事件类型:

Untitled

其中,与 Class 相关的有 ClassPrepareEvent 和 ClassUnloadEvent;与 Method 相关的有 MethodEntryEvent 和 MethodExitEvent;与 Field 相关的有 AccessWatchpointEvent 和 ModificationWatchpointEvent;与虚拟机相关的有 VMDeathEvent,VMDisconnectEvent 和 VMStartEvent 等。

事件类型描述
ClassPrepareEvent装载某个指定的类所引发的事件
ClassUnloadEvent卸载某个指定的类所引发的事件
BreakingpointEvent设置断点所引发的事件
ExceptionEvent目标虚拟机运行中抛出指定异常所引发的事件
MethodEntryEvent进入某个指定方法体时引发的事件
MethodExitEvent某个指定方法执行完成后引发的事件
MonitorContendedEnteredEvent线程已经进入某个指定 Monitor 资源所引发的事件
MonitorContendedEnterEvent线程将要进入某个指定 Monitor 资源所引发的事件
MonitorWaitedEvent线程完成对某个指定 Monitor 资源等待所引发的事件
MonitorWaitEvent线程开始等待对某个指定 Monitor 资源所引发的事件
StepEvent目标应用程序执行下一条指令或者代码行所引发的事件
AccessWatchpointEvent查看类的某个指定 Field 所引发的事件
ModificationWatchpointEvent修改类的某个指定 Field 值所引发的事件
ThreadDeathEvent某个指定线程运行完成所引发的事件
ThreadStartEvent某个指定线程开始运行所引发的事件
VMDeathEvent目标虚拟机停止运行所以的事件
VMDisconnectEvent目标虚拟机与调试器断开链接所引发的事件
VMStartEvent目标虚拟机初始化时所引发的事件

不同的事件需要被分类地添加到不同的事件集合(EventSet)中,事件集是事件发送的最小单位 。事件集一旦创建出来,便不可再被修改。JDI 定义了一些规则,用以规定应该如何将事件分别加入到不同的事件集中:

  • 每个 VMStartEvent 事件应该分别加入到单独的一个事件集中;
  • 每个 VMDisconnectEvent 事件应该分别加入到单独的一个事件集中;
  • 所有的 VMDeathEvent 事件应该加入到同一个事件集中;
  • 同一线程的 ThreadStartEvent 事件应该加入到同一事件集中;
  • 同一线程的 ThreadDeathEvent 事件应该加入到同一事件集中;
  • 同一类型的 ClassPrepareEvent 事件应该加入到同一个事件集中;
  • 同一类型的 ClassUnloadEvent 事件应该加入到同一个事件集中;
  • 同一 Field 的 AccessWatchpointEvent 事件应该加入到同一个事件集中;
  • 同一 Field 的 ModificationWatchpointEvent 事件应该加入到同一个事件集中;
  • 同一异常的 ExceptionEvent 事件应该加入到同一个事件集中;
  • 同一方法的 MethodExitEvents 事件应该加入到同一个事件集中;
  • 同一 Monitor 的 MonitorContendedEnterEvent 事件应该加入到同一个事件集中;
  • 同一 Monitor 的 MonitorContendedEnteredEvent 事件应该加入到同一个事件集中;
  • 同一 Monitor 的 MonitorWaitEvent 事件应该加入到同一个事件集中
  • 同一 Monitor 上的 MonitorWaitedEvent 事件应该加入到同一个事件集中
  • 在同一线程执行过程中,具有相同行号信息的 BreakpointEvent、StepEvent 和 MethodEntryEvent 事件应该加入到同一个事件集合中。

生成的事件集将被依次地加入到目标虚拟机的事件队列(EventQueue)中。然后,EventQueue 将这些事件集以 “先进先出” 策略依次地发送到调试器端。EventQueue 负责管理来自目标虚拟机的事件,一个被调试的目标虚拟机上有且仅有一个 EventQueue 实例。特别地,随着一次事件集的发送,目标虚拟机上可能会有一部分的线程因此而被挂起。如果一直不恢复这些线程,有可能会导致目标虚拟机挂机。因此,在处理好一个事件集中的事件后,建议调用事件集的 resume () 方法,恢复所有可能被挂起的线程。

6、JDI 事件请求

Event 是 JDI 中所有事件接口的父接口,它只定义了一个 request () 方法,用以返回由调试器发出的针对该事件的事件请求(EventRequest)。事件请求是由调试器向目标虚拟机发出的,目的是请求目标虚拟机在发生指定的事件后通知调试器。只有当调试器发出的请求与目标虚拟机上发生的事件契合时,这些事件才会被分发到各个事件集,进而等待发送至调试器端。在 JDI 中,每一种事件类型都对应着一种事件请求类型。一次事件请求可能对应有多个事件实例,但不是每个事件实例都存在与之对应的事件请求。例如,对于某些事件(如 VMDeathEvent,VMDisconnectEvent 等),即使没有对应的事件请求,这些事件也必定会被发送给调试器端。

另外,事件请求还支持过滤功能。通过给 EventRequest 实例添加过滤器(Filter),可以进一步筛选出调试器真正感兴趣的事件实例。事件请求支持多重过滤,通过 EventRequest 的 add*Filter () 方法可以添加多个过滤器。多个过滤器将共同作用,最终只有满足所有过滤条件的事件实例才会被发给调试器。常用的过滤器有:

  • 线程过滤器:用以过滤出指定线程中发生的事件;
  • 类型过滤器:用以过滤出指定类型中发生的事件;
  • 实例过滤器:用以过滤出指定实例中发生的事件;
  • 计数过滤器:用以过滤出发生一定次数的事件;

过滤器提供了一些附加的限制条件,减少了最终加入到事件队列的事件数量,从而提高了调试性能。除了过滤功能,还可以通过它的 setSuspendPolicy (int) 设置是否需要在事件发生后挂起目标虚拟机。

事件请求是由事件请求管理器(EventRequestManager)进行统一管理的,包括对请求的创建和删除。一个目标虚拟机中有且仅有一个 EventRequestManager 实例。通常,一个事件请求实例有两种状态:激活态和非激活态。 非激活态的事件请求将不起任何作用,即使目标虚拟机上有满足此请求的事件发生,目标虚拟机将不做停留,继续执行下一条指令。由 EventRequestManager 新建的事件请求都是非激活的,需要调用 setEnable (true) 方法激活该请求,而通过 setEnable (false) 则可废除该请求,使其转化为非激活态。

7、JDI 事件处理

下面将介绍 JDI 中调试器与目标虚拟机事件交互的方式。首先,调试器调用目标虚拟机的 eventQueue () 和 eventRequestManager () 分别获取唯一的 EventQueue 实例和 EventRequestManager 实例。然后,通过 EventRequestManager 的 createXxxRequest () 创建需要的事件请求,并添加过滤器和设置挂起策略。接着,调试器将从 EventQueue 获取来自目标虚拟机的事件实例。

一个事件实例中包含着事件发生时目标虚拟机的一些状态信息。以 BreakpointEvent 为例:

调用 BreakpointEvent 的 thread () 可以获取产生事件的线程镜像(ThreadReference),调用 ThreadReference 的 frame (int) 可获得当前代码行所在的堆栈(StackFrame),调用 StackFrame 的 visibleVariables () 可获取当前堆栈中的所有本地变量(LocaleVariable)。通过调用 BreakpointEvent 的 location () 可获得断点所在的代码行号(Location),调用 Location 的 method () 可获得当前代码行所归属的方法。通过以上调用,调试器便可获得了目标虚拟机上线程、对象、变量等镜像信息。

另外,根据从事件实例中获取的以上信息,调试器还可以进一步控制目标虚拟机。例如,可以调用 ObjectReference 的 getValue () 和 setValue () 访问和修改对象中封装的 Field 或者 LocalVariable 等,进而影响虚拟机的行为。

Untitled

8、一个 JDI 的简单实例

下面给出一个简单例子,说明如何实现 JDI 的部分接口来提供一个简易的调试客户端。首先是被调试的 Java 类,这里给出一个简单的 Hello World 程序,main 方法第一行声明一个 “Hello World!” 的字符串变量,第二行打印出这个字符串的内容:

package com.ibm.jdi.test;
 
public class HelloWorld {
    public static void main(String[] args) {
        String str = "Hello world!";
        System.out.println(str);
    }
}

接着是一个简单的调试器实现 SimpleDebugger,清单 9 列出了实现该调试器所需要导入的类库和变量。简单起见,所有的变量都声明为静态全局变量。这些变量分别代表了目标虚拟机镜像,目标虚拟机所在的进程,目标虚拟机的事件请求管理器和事件对列。变量 vmExit 标志目标虚拟机是否中止。

package com.ibm.jdi.test;
 
import java.util.List;
import java.util.Map;
import com.sun.jdi.Bootstrap;
import com.sun.jdi.LocalVariable;
import com.sun.jdi.Location;
import com.sun.jdi.ReferenceType;
import com.sun.jdi.StackFrame;
import com.sun.jdi.StringReference;
import com.sun.jdi.ThreadReference;
import com.sun.jdi.Value;
import com.sun.jdi.VirtualMachine;
import com.sun.jdi.connect.Connector;
import com.sun.jdi.connect.LaunchingConnector;
import com.sun.jdi.connect.Connector.Argument;
import com.sun.jdi.event.BreakpointEvent;
import com.sun.jdi.event.ClassPrepareEvent;
import com.sun.jdi.event.Event;
import com.sun.jdi.event.EventIterator;
import com.sun.jdi.event.EventQueue;
import com.sun.jdi.event.EventSet;
import com.sun.jdi.event.VMDisconnectEvent;
import com.sun.jdi.event.VMStartEvent;
import com.sun.jdi.request.BreakpointRequest;
import com.sun.jdi.request.ClassPrepareRequest;
import com.sun.jdi.request.EventRequest;
import com.sun.jdi.request.EventRequestManager;
 
public class SimpleDebugger {
    static VirtualMachine vm;
    static Process process;
    static EventRequestManager eventRequestManager;
    static EventQueue eventQueue;
    static EventSet eventSet;
    static boolean vmExit = false;

随后是 SimpleDebugger 的 main () 方法,首先从 VirtualMachineManager 获取默认的 LaunchingConnector,然后从该 Connector 取得默认的参数。接着,设置 main 和 suspend 参数,使得目标虚拟机运行 com.ibm.jdi.test.HelloWorld 类,并随后进入挂起状态。下一步,调用 LaunchingConnector.launch () 启动目标虚拟机,返回目标虚拟机的镜像实例,并且获取运行目标虚拟机的进程( Process)。

然后,创建一个 ClassPrepareRequest 事件请求。当 com.ibm.jdi.test.HelloWorld 被装载时,目标虚拟机将发送对应的 ClassPrepareEvent 事件。事件处理完成后,通过 process 的 destroy () 方法销毁目标虚拟机进程,结束调试工作。

public static void main(String[] args) throws Exception{
    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("com.ibm.jdi.test.HelloWorld");
    suspendArg.setValue("true");
    vm = launchingConnector.launch(defaultArguments);
 
    process = vm.process()
 
    // Register ClassPrepareRequest
    eventRequestManager = vm.eventRequestManager();
    ClassPrepareRequest classPrepareRequest 
        = eventRequestManager.createClassPrepareRequest();
    classPrepareRequest.addClassFilter("com.ibm.jdi.test.HelloWorld");
    classPrepareRequest.addCountFilter(1);
    classPrepareRequest.setSuspendPolicy(EventRequest.SUSPEND_ALL);
    classPrepareRequest.enable();
 
    // Enter event loop 
    eventLoop();
 
    process.destroy();
}

下面是 eventLoop () 函数的实现:首先获取目标虚拟机的事件队列,然后依次处理队列中的每个事件。当 vmExit(初始值为 false)标志为 true 时,结束循环。

private static 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);
        }
    }
}

具体事件的处理是由 execute (Event) 实现的,这里主要列举出 ClassPreparEvent 和 BreakpointEvent 事件的处理用法。

private static void execute(Event event) throws Exception {
    if (event instanceof VMStartEvent) {
        System.out.println("VM started");
        eventSet.resume();
    } else if (event instanceof ClassPrepareEvent) {
        ClassPrepareEvent classPrepareEvent = (ClassPrepareEvent) event;
        String mainClassName = classPrepareEvent.referenceType().name();
        if (mainClassName.equals("com.ibm.jdi.test.HelloWorld")) {
            System.out.println("Class " + mainClassName
                    + " is already prepared");
        }
        if (true) {
            // Get location
            ReferenceType referenceType = prepareEvent.referenceType();
            List locations = referenceType.locationsOfLine(10);
            Location location = (Location) locations.get(0);
 
            // Create BreakpointEvent
            BreakpointRequest breakpointRequest = eventRequestManager
                    .createBreakpointRequest(location);
            breakpointRequest.setSuspendPolicy(EventRequest.SUSPEND_ALL);
            breakpointRequest.enable();
        }
        eventSet.resume();
    } else if (event instanceof BreakpointEvent) {
        System.out.println("Reach line 10 of com.ibm.jdi.test.HelloWorld");
        BreakpointEvent breakpointEvent = (BreakpointEvent) event;
        ThreadReference threadReference = breakpointEvent.thread();
        StackFrame stackFrame = threadReference.frame(0);
        LocalVariable localVariable = stackFrame
                .visibleVariableByName("str");
        Value value = stackFrame.getValue(localVariable);
        String str = ((StringReference) value).value();
        System.out.println("The local variable str at line 10 is " + str
                + " of " + value.type().name());
        eventSet.resume();
    } else if (event instanceof VMDisconnectEvent) {
        vmExit = true;
    } else {
        eventSet.resume();
    }
}

最后列出了以上程序的运行结果:

VM started
Class com.ibm.jdi.test.HelloWorld is already prepared
Reach line 10 of com.ibm.jdi.test.HelloWorld
The local variable str at line 10 is Hello world! of java.lang.String