深入理解Java调试体系

253

JPDA体系概览

三层结构

JPDA就是Java Platform Debugger Architecture。通过 JPDA 提供的 API,开发人员可以方便灵活的搭建 Java 调试应用程序。

JPDA 主要由三个部分组成:Java 虚拟机工具接口(JVMTI),Java 调试线协议(JDWP),以及 Java 调试接口(JDI)。而它们之间的层次关系如下图:

调试命令和调试结果,都是通过 JDWP 的通讯协议传输的。所有的命令被封装成 JDWP 命令包,通过传输层发送给被调试者,被调试者接收到 JDWP 命令包后,解析这个命令并转化为 JVMTI 的调用,在被调试者上运行。类似的,JVMTI 的运行结果,被格式化成 JDWP 数据包,发送给调试者并返回给 JDI 调用。

img

当然,开发人员完全可以不使用完整的三个层次,而是基于其中的某一个层次开发自己的应用。比如您完全可以仅仅依靠通过 JVMTI 函数开发一个调试工具,而不使用 JDWP 和 JDI,只使用自己的通讯和命令接口。当然,除非是有特殊的需求,利用已有的实现会使您事半功倍,避免重复造轮子。

Java 虚拟机工具接口(JVMTI)

JVMTI(Java Virtual Machine Tool Interface)即指 Java 虚拟机工具接口,它是一套由虚拟机直接提供的 native 接口,它处于整个 JPDA 体系的最底层,所有调试功能本质上都需要通过 JVMTI 来提供。通过这些接口,开发人员不仅调试在该虚拟机上运行的 Java 程序,还能查看它们运行的状态,设置回调函数,控制某些环境变量,从而优化程序性能。

Java 调试线协议(JDWP)

JDWP全称是Java Debug Wire Protocol,中文翻译是一种专门为Java调试而设计的一个通讯交互协议。协议包里的内容包括请求命令、回应数据以及错误代码。Sun公司的实现为jdwp.dll(jdwp.so),它实现了一个代理,负责解析前端向JVM发送的请求或命令,将其转化为JVMTI调用,然后将JVMTI函数的返回值封装成JDWP数据包发给前端。注意:JDWP本身不包括传输层的实现,传输层需要独立实现,但是JDWP包含了和传输层交互的严格定义。Sun公司提供的jdk中,在传输层上它提供了socket方式。

Java 调试接口(JDI)

JDI全称是Java Debug Interface,是三个模块中的最高层,在大多数JDK中,它都是有Java语言来实现的,通过它,开发者可以通过前端JVM上的调试器来调试后端JVM上被调试的程序。

JVMTI 和 Agent 实现

简介

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

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

Agent工作过程

启动

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 环境所提供的功能以及处理方式都可能有所不同,不过它在同一个虚拟机中会保持不变。命令行参数事实上就是上面启动命令行中的 options 部分,在 Agent 实现中需要进行解析并完成后续处理工作。参数传入的字符串仅仅在Agent_OnLoad函数里有效,如果需要长期使用,开发者需要做内存的复制工作,同时在最后还要释放这块存储。需要强调的是,这个时候由于虚拟机并未完成初始化工作,并不是所有的 JVMTI 函数都可以被使用。

Agent 还可以在运行时加载(Java Instrumen)。具体说来,虚拟机会在运行时监听并接受 Agent 的加载,在这个时候,它会使用 Agent 的:

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

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

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

卸载

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

JNIEXPORT void JNICALL Agent_OnUnload(JavaVM *vm)

JVMTI 的环境和错误处理

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

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)才能调用,否则导致一个内部异常。

JVMTI 错误处理

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

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

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

JVMTI 基本功能

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

事件处理和回调函数

从上文我们知道,使用 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 就会被同时调用几次),那么开发人员在开发回调函数时需要处理同步的问题。
内存控制和对象获取

内存控制是一切运行态的基本功能。 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 的回调函数,可能会使整个操作效率下降一个数量级。

线程和锁

线程是 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) 等操作,通过这些简单锁,程序的同步操作可以得到保证。

调试功能

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

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

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

一个简单的 Agent 实现(C++)

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

MethodTraceAgent.h 代码如下:

#include "jvmti.h"
 
class AgentException 
{
 public:
        AgentException(jvmtiError err) {
                m_error = err;
        }
 
        char* what() const throw() { 
                return "AgentException"; 
        }
 
        jvmtiError ErrCode() const throw() {
                return m_error;
        }
 
 private:
        jvmtiError m_error;
};
 
 
class MethodTraceAgent 
{
 public:
 
        MethodTraceAgent() throw(AgentException){}
 
        ~MethodTraceAgent() throw(AgentException);
 
        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);
    
        static void JNICALL HandleMethodEntry(jvmtiEnv* jvmti, JNIEnv* jni, jthread thread, jmethodID method);
 
 private:
        static void CheckException(jvmtiError error) throw(AgentException)
        {
                // 可以根据错误类型扩展对应的异常,这里只做简单处理
                if (error != JVMTI_ERROR_NONE) {
                        throw AgentException(error);
                }
        }
    
        static jvmtiEnv * m_jvmti;
        static char* m_filter;
};

MethodTraceAgent.cpp 代码如下

#include <iostream>
#include <cstring>
#include "MethodTraceAgent.h"
#include "jvmti.h"
 
using namespace std;
 
jvmtiEnv* MethodTraceAgent::m_jvmti = 0;
char* MethodTraceAgent::m_filter = 0;
MethodTraceAgent::~MethodTraceAgent() throw(AgentException)
{
    // 必须释放内存,防止内存泄露
    m_jvmti->Deallocate(reinterpret_cast<unsigned char*>(m_filter));
}
 
void MethodTraceAgent::Init(JavaVM *vm) const throw(AgentException){
    jvmtiEnv *jvmti = 0;
        jint ret = (vm)->GetEnv(reinterpret_cast<void**>(&jvmti), JVMTI_VERSION_1_0);
        if (ret != JNI_OK || jvmti == 0) {
                throw AgentException(JVMTI_ERROR_INTERNAL);
        }
        m_jvmti = jvmti;
}
 
void MethodTraceAgent::ParseOptions(const char* str) const throw(AgentException)
{
    if (str == 0)
        return;
        const size_t len = strlen(str);
        if (len == 0) 
                return;
 
          // 必须做好内存复制工作
        jvmtiError error;
    error = m_jvmti->Allocate(len + 1,reinterpret_cast<unsigned char**>(&m_filter));
        CheckException(error);
    strcpy(m_filter, str);
 
    // 可以在这里进行参数解析的工作
        // ...
}
 
void MethodTraceAgent::AddCapability() const throw(AgentException)
{
    // 创建一个新的环境
    jvmtiCapabilities caps;
    memset(&caps, 0, sizeof(caps));
    caps.can_generate_method_entry_events = 1;
    
    // 设置当前环境
    jvmtiError error = m_jvmti->AddCapabilities(&caps);
        CheckException(error);
}
  
void MethodTraceAgent::RegisterEvent() const throw(AgentException)
{
    // 创建一个新的回调函数
    jvmtiEventCallbacks callbacks;
    memset(&callbacks, 0, sizeof(callbacks));
    callbacks.MethodEntry = &MethodTraceAgent::HandleMethodEntry;
    
    // 设置回调函数
    jvmtiError error;
    error = m_jvmti->SetEventCallbacks(&callbacks, static_cast<jint>(sizeof(callbacks)));
        CheckException(error);
 
        // 开启事件监听
        error = m_jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_METHOD_ENTRY, 0);
        CheckException(error);
}
 
void JNICALL MethodTraceAgent::HandleMethodEntry(jvmtiEnv* jvmti, JNIEnv* jni, jthread thread, jmethodID method)
{
        try {
        jvmtiError error;
        jclass clazz;
        char* name;
                char* signature;
        
        // 获得方法对应的类
        error = m_jvmti->GetMethodDeclaringClass(method, &clazz);
        CheckException(error);
        // 获得类的签名
        error = m_jvmti->GetClassSignature(clazz, &signature, 0);
        CheckException(error);
        // 获得方法名字
        error = m_jvmti->GetMethodName(method, &name, NULL, NULL);
        CheckException(error);
        
        // 根据参数过滤不必要的方法
                if(m_filter != 0){
                        if (strcmp(m_filter, name) != 0)
                                return;
                }                        
                cout << signature<< " -> " << name << "(..)"<< endl;
 
        // 必须释放内存,避免内存泄露
        error = m_jvmti->Deallocate(reinterpret_cast<unsigned char*>(name));
                CheckException(error);
        error = m_jvmti->Deallocate(reinterpret_cast<unsigned char*>(signature));
                CheckException(error);
 
        } catch (AgentException& e) {
                cout << "Error when enter HandleMethodEntry: " << e.what() << " [" << e.ErrCode() << "]";
    }
}

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

#include <iostream>
 
#include "MethodTraceAgent.h"
#include "jvmti.h"
 
using namespace std;
 
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved)
{
    cout << "Agent_OnLoad(" << vm << ")" << endl;
    try{
        
        MethodTraceAgent* agent = new MethodTraceAgent();
                agent->Init(vm);
        agent->ParseOptions(options);
        agent->AddCapability();
        agent->RegisterEvent();
        
    } catch (AgentException& e) {
        cout << "Error when enter HandleMethodEntry: " << e.what() << " [" << e.ErrCode() << "]";
                return JNI_ERR;
        }
    
        return JNI_OK;
}
 
JNIEXPORT void JNICALL Agent_OnUnload(JavaVM *vm)
{
    cout << "Agent_OnUnload(" << vm << ")" << endl;
}

Agent运行过程时序图,如图所示:

img

Agent 编译和运行

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

原先输出:

img

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

img

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

结语

Java 虚拟机通过 JVMTI 提供了一整套函数来帮助用户检测管理虚拟机运行态,它主要通过 Agent 的方式实现与用户的互操作。本文简单介绍了 Agent 的实现方式和 JVMTI 的使用。通过 Agent 这种方式不仅仅用户可以使用,事实上,JDK 里面的很多工具,比如 Instrumentation 和 JDI, 都采用了这种方式。这种方式无需把这些工具绑定在虚拟机上,减少了虚拟机的负荷和内存占用。在下一篇中,我们将介绍 JDWP 如何采用 Agent 的方式,定义自己的一套通信原语,并通过多种通信方式,对外提供基本调试功能。

JDWP 协议及实现

JDWP 协议介绍

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

JDWP agent在调试中扮演的角色

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 完成其调试功能的。

协议分析

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

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

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

img

握手完成,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。

Packet 的结构

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

Command packet 的 header 的结构 :

img

  • 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 的结构:

img

  • Length、Id 作用与 command packet 中的一样。
  • Flags 目前对于 reply packet 值始终是 0x80。我们可以通过 Flags 的值来判断接收到的 packet 是 command 还是 reply。
  • Error Code 用来表示被回复的命令是否被正确执行了。零表示正确,非零表示执行错误。
  • Data 的内容和结构依据不同的 command 和 reply 都有所不同。比如请求一个对象成员变量值的 command,它的 data 中就包含该对象的 id 和成员变量的 id。而 reply 中则包含该成员变量的值。

JDWP 还定义了一些数据类型专门用来传递 Java 相关的数据信息。下面列举了一些数据类型,详细的说明参见

JDWP 传输接口

前面提到 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 操作:

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

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

    • jdwpTransportError 
      Attach(jdwpTransportEnv* env, const char* address, 
          jlong attachTimeout, jlong handshakeTimeout) 
      
    • 该方法将使 JDWP 处于监听状态,随后调用 Accept 方法接收连接:

    • jdwpTransportError 
      Accept(jdwpTransportEnv* env, jlong acceptTimeout, jlong 
          handshakeTimeout) 
      
    • 与 Attach 方法类似,在连接建立后,会立即进行握手操作。

  • 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; 
      

JDWP 的命令实现机制

JDWP 作为一种协议,它的作用就在于充当了调试器与 Java 虚拟机的沟通桥梁。通俗点讲,调试器在调试过程中需要不断向 Java 虚拟机查询各种信息,那么 JDWP 就规定了查询的具体方式。

在 Java 6.0 中,JDWP 包含了 18 组命令集合,其中每个命令集合又包含了若干条命令。

img

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

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

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

img

  • 单线程执行的命令:
    • 上图就是一个命令的处理流程图。可以看到,对于一个可以直接在该线程中完成的命令(我们称为单线程执行的命令),一般其内部会调用 JVMTI 方法和 JNI 方法来真正对 Java 虚拟机进行操作。
    • 例如,VirtualMachine 的 Version 命令中,对于 vmVersion 和 vmName 属性,我们可以通过 JNI 来调用 Java 方法 System.getProperty 来获取。然后,JDWP 将回复包中所需要的结果封装到包中后交由 TransportManager 来进行后续操作。
  • 多线程执行的命令:
    • 对于一些较为复杂的命令,是无法在 CommandHandler 子类的处理线程中完成的。例如,ClassType 的 InvokeMethod 命令,它会要求在指定的某个线程中执行一个静态方法。显然,CommandHandler 子类的当前线程并不是所要求的线程。
    • 这时,JDWP 线程会先把这个请求先放到一个列表中,然后等待,直到所要求的线程执行完那个静态方法后,再把结果返回给调试器。

JDWP 的事件处理机制

在实际调试过程中,一个 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 虚拟机不会报告)。

img

  • 当断点发生时,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 程序的时候,往往需要对虚拟机内部的运行状态进行观察和调试,JDWP Agent 就充当了调试器与 Java 虚拟机的沟通桥梁。它的工作原理简单来说就是对于 JDWP 命令的处理和事件的管理。由于 JDWP 在 JPDA 中处于相对底层的位置,调试器发出一个 JDI 指令,往往要通过很多 JDWP 命令来完成。

Java 调试接口JDI

JDI 简介

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

JDI 工作方式

依赖jar包是tools.jar,在java的home目录的lib文件夹下

首先,调试器(Debuuger)通过 Bootstrap 获取唯一的虚拟机管理器,见 清单 1。

清单 1. 获取虚拟机管理器(VirtualMachineManager)

VirtualMachineManager virtualMachineManager 
    = Bootstrap.virtualMachineManager();

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

清单 2. 获取默认的链接器(Connector)

LaunchingConnector defaultConnector = virtualMachineManager.defaultConnector();

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

清单 3. 启动目标程序,连接调试器(Debuuger)与目标虚拟机(VirtualMachine)

VirtualMachine targetVM = defaultConnector.launch(arguments);

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

从功能上,可以将 JDI 分成三个部分:数据模块,链接模块,以及事件请求与处理模块。数据模块负责调试器和目标虚拟机上的数据建模,链接模块建立调试器与目标虚拟机的沟通渠道,事件请求与处理模块提供调试器与目标虚拟机交互方式,下面将逐一地介绍它们

数据模块

Mirror

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

清单 4.获取 Mirror 对象实例的****虚拟机

VirtualMachine virtualMachine = mirror.virtualMachine();

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

img

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

Value 和 Type

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

  • 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 回收该对象。

TypeComponent

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

链接模块

链接是调试器与目标虚拟机之间交互的渠道,一次链接可以由调试器发起,也可以由被调试的目标虚拟机发起。一个调试器可以链接多个目标虚拟机,但一个目标虚拟机最多只能链接一个调试器。链接是由链接器(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 等参数,见表

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

清单 5. 获取链接器的默认参数

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

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

然后,从返回的 Map 中获取标识符为 main 的链接器参数,如 清单 6。

清单 6. 返回链接器的 main 参数

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

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

清单 7. 设置 main 参数的值并启动****虚拟机

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

JDI 事件请求

事件类型描述
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() 方法,恢复所有可能被挂起的线程

事件请求

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

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

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

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

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

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 等,进而影响虚拟机的行为。更多的 JDI 的事件处理的详情,参见下图

img

一个 JDI 的简单实例

下面给出一个简单例子,说明如何实现 JDI 的部分接口来提供一个简易的调试客户端。

package com.jdi.test;

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

接下来我们实现一个简单的调试器来打印指定段点的变量,直接上代码

package com.jdi.test;

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.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;
import java.util.List;
import java.util.Map;

public class SimpleDebugger {
    static VirtualMachine vm;
    static Process process;
    static EventRequestManager eventRequestManager;
    static EventQueue eventQueue;
    static EventSet eventSet;
    static boolean vmExit = false;

    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.jdi.test.HelloWorld");
        suspendArg.setValue("true");

        vm = launchingConnector.launch(defaultArguments);

        process = vm.process();

        // Register ClassPrepareRequest 创建一个 ClassPrepareRequest 事件请求。
        // 当 HelloWorld 被装载时,目标虚拟机将发送对应的 ClassPrepareEvent 事件。
        eventRequestManager = vm.eventRequestManager();
        ClassPrepareRequest classPrepareRequest
                = eventRequestManager.createClassPrepareRequest();
        classPrepareRequest.addClassFilter("com.jdi.test.HelloWorld");
        classPrepareRequest.addCountFilter(1);
        classPrepareRequest.setSuspendPolicy(EventRequest.SUSPEND_ALL);
        classPrepareRequest.enable();

        // Enter event loop
        eventLoop();

        process.destroy();
    }

    // 首先获取目标虚拟机的事件队列,然后依次处理队列中的每个事件。当 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);
            }
        }
    }

    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.jdi.test.HelloWorld")) {
                System.out.println("Class " + mainClassName
                        + " is already prepared");
            }
            if (true) {
                // Get location
                ReferenceType referenceType = classPrepareEvent.referenceType();
                List locations = referenceType.locationsOfLine(6);
                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 6 of com.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 6 is " + str
                    + " of " + value.type().name());
            eventSet.resume();
        } else if (event instanceof VMDisconnectEvent) {
            vmExit = true;
        } else {
            eventSet.resume();
        }
    }
}

运行结果

img

结语

本文介绍了 Java 调试接口(JDI)的体系结构和工作方式,JDI 是 JPDA 体系结构的调试器后端接口,开发人员通过使用它所提供的接口与 JDWP(Java 调试线协议)前端 Agent 通信,以这种方式访问和控制被调试的目标虚拟机。