浅谈JPDA中JVMTI模块

3,537 阅读17分钟

0 前言

上一节《Java Instrument 功能使用及原理》文章中,讲解Instrument使用时,简单提了一句JVMTI的概念,可能有很多小伙伴感到很陌生,虽然Java Instrument的使用基本没什么问题,但对于Instrument基于JVMTI的实现原理还是处于混沌状态。所以本节的重点就在于讲解JVMTI,正如标题。

但由于JVMTI在整个JVM JPDA体系中只是其中的一个小模块,为了使大家在整体上能有个清晰的认识,那我们先从JPDA体系开始吧。

1 JPDA 介绍

所有的程序员都会遇到 bug,对于运行态的错误,我们往往需要一些方法来观察和测试运行态中的环境。作为一个合格的Developer,最基本的技能就是要掌握语言在不同IDE的Debug技能。

Intellij IDEA 就提供一个功能非常全面,操作非常简单的调试器,如下图:

Intellij IDEA 调试界面

有时甚至不用 IDE 提供的图形界面,使用 JDK 自带的 jdb 工具,以文本命令的形式来调试您的 Java 程序。这些形形色色的调试器都 支持本地和远程的程序调试,那么它们是如何被开发的?它们之间存在着什么样的联系呢?

我们不得不提及 Java 的调试体系—— JPDA,它是我们通向虚拟机,考察虚拟机运行态的一个通道,一套工具

Java 程序都是运行在 Java 虚拟机上的,我们要调试 Java 程序,事实上就需要向 Java 虚拟机请求当前运行态的状态,并对虚拟机发出一定的指令,设置一些回调等等,那么 Java 的调试体系——JPDA,就是虚拟机的一整套用于调试的工具和接口

顾名思义,这个体系为开发人员提供了 一整套用于调试 Java 程序的 API,是一套用于开发 Java 调试工具的接口和协议

通过这些 JPDA 提供的接口和协议,调试器开发人员就能根据特定开发者的需求,扩展定制 Java 调试应用程序,开发出吸引开发人员使用的调试工具。

但我们要注意的是,JPDA 是一套标准,任何的 JDK 实现都必须完成这个标准,因此,通过 JPDA 开发出来的调试工具先天 具有跨平台、不依赖虚拟机实现、JDK 版本无关等移植优点,因此大部分的调试工具都是基于这个体系。

1.1 JPDA 模块

JPDA 定义了一个完整独立的体系,它由三个相对独立的层次共同组成,而且规定了它们三者之间的交互方式,或者说定义了它们通信的接口。

三个层次由低到高分别是 Java 虚拟机工具接口(JVMTI),Java 调试协议(JDWP)以及 Java 调试接口(JDI)。这三个模块把调试过程分解成几个很自然的概念:调试者(debugger)和被调试者(debuggee),以及他们中间的通信器

被调试者(JVMTI):运行于我们想调试的 Java 虚拟机之上,它可以通过 JVMTI 这个标准接口,监控当前虚拟机的信息

调试者(JDI):定义了用户可使用的调试接口,通过这些接口,用户可以对被调试虚拟机发送调试命令,同时调试者接受并显示调试结果

中间通信器(JDWP)在调试者和被调试者之间,调试命令和调试结果,都是通过 JDWP 的通讯协议传输的;所有的命令被封装成 JDWP 命令包,通过传输层发送给被调试者,被调试者接收到 JDWP 命令包后,解析这个命令并转化为 JVMTI 的调用,在被调试者上运行;类似的,JVMTI 的运行结果,被格式化成 JDWP 数据包,发送给调试者并返回给 JDI 调用。而调试器开发人员就是通过 JDI 得到数据,发出指令;

上述整个过程,如下图所示:

JPDA 模块层次

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

下面,我们就分别讲解下JPDA的三种组成模块:

  1. Java 虚拟机工具接口(JVMTI)

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

  2. 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 的标准自己实现

  3. Java 调试接口(JDI)

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

JPDA 层次比较

1.2 JPDA 实现

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

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

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 中,JVMTI 接口也增加了 监听(Monitoring),线程分析(Thread analysis)以及覆盖率分析(Coverage Analysis) 等功能。正是由于 JVMTI 的强大功能,它是实现 Java 调试器,以及其它 Java 运行态测试与分析工具的基础。

JVMTI 是一套本地代码接口,可以使开发者直接与 C/C++ 以及 JNI 打交道

那么,开发者是如何来使用JVMTI所提供的接口呢?事实上,一般采用建立一个 Agent 的方式来使用 JVMTI,这个Agent的表现形式是一个以c/c++语言编写的动态链接库

把 Agent 编译成一个动态链接库,Java启动或运行时,动态加载一个外部基于JVMTI 编写的dynamic module到Java进程内,然后触发 JVM源生线程Attach Listener来执行这个dynamic module的回调函数

在回调函数体内,可以 获取各种各样的VM级信息,注册感兴趣的VM事件,甚至控制VM行为

2.1 Agent 工作过程

2.1.1 启动

JVMTI有两种启动方式,第一种是随Java进程启动时,自动载入共享库,下文简称 启动时载入。另一种方式是,Java运行时,通过attach api动态载入,下文简称 运行时载入

启动时载入,通过在Java命令行启动时传递一个特殊的option,如下:

  1. java -agentlib:= Sample 注意,这里的共享库路径是环境变量路径,例如 java -agentlib:foo=opt1,opt2,java启动时会从linux的LD_LIBRARY_PATH或windows的PATH环境变量定义的路径处装载foo.so或foo.dll,找不到则抛异常

  2. java -agentpath:= Sample 这是 以绝对路径的方式装载共享库,例如 java -agentpath:/home/admin/agentlib/foo.so=opt1,opt2

启动时载入,处于虚拟机初始化的早期,在这个时间点上:

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

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

  1. 操作 JVMTI 的 Capability 参数;
  2. 使用系统参数;

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

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

在这个Agent_OnLoad函数中,虚拟机传入了三个参数:

  1. JavaVM *vmJVM上下文通过 JavaVM,可以获得 JVMTI 的指针,并获得 JVMTI 函数的使用能力;
  2. char *options外部传入的参数,比如上面例子中给的 opt1, opt2,它仅仅是一个字符串
  3. void *reserved:一个预留参数,不必关心它;

运行时载入,通过attach api,这是一套纯Java的API,它负责动态地将dynamic module attach到指定进程id的Java进程内并触发回调。例子如下:

import java.io.IOException;
import com.sun.tools.attach.VirtualMachine;

public class VMAttacher {
    public static void main(String[] args) throws Exception {
         // args[0]为java进程id
         VirtualMachine virtualMachine = com.sun.tools.attach.VirtualMachine.attach(args[0]);
         // args[1]为共享库路径,args[2]为传递给agent的参数
         virtualMachine.loadAgentPath(args[1], args[2]);
         virtualMachine.detach();
    }
}

Attach API位于$JAVA_HOME/lib/tools.jar,所以在编译时,需要将这个jar放入classpath。例如:

javac -cp $JAVA_HOME/lib/tools.jar VMAttacher.java pid /home/admin/agentlib/foo.so opt1,opt2

运行时载入,虚拟机会在运行时监听并接受 Agent 的加载,在这个时候,它会使用 Agent 的:

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

2.1.2 卸载

最后,Agent 完成任务,或者虚拟机关闭的时候,虚拟机都会调用一个类似于类析构函数的方法来完成最后的清理任务

JNIEXPORT void JNICALL Agent_OnUnload(JavaVM *vm)

2.2 JVMTI 环境和错误处理

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

2.2.1 JVMTI 环境

通过操作 jvmtiCapabilities 来查询、增加、修改 JVMTI 的环境参数。标准的 jvmtiCapabilities 定义了一系列虚拟机的功能,比如:

  1. can_redefine_any_class:定义了虚拟机是否支持重定义类;
  2. can_retransform_classes:定义了虚拟机是否支持运行的时候改变类定义;

如果熟悉 Java Instrumentation,一定不会对此感到陌生,因为 Instrumentation 就是对这些在 Java 层上的包装。对用户来说,只需获得 jvmtiCapabilities指针,就可以查看当前 JVMTI 环境,了解虚拟机具有的一系列变量功能

// 取得 jvmtiCapabilities 指针
err = (*jvmti)->GetCapabilities(jvmti, &capa);
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)才能调用,否则导致一个内部异常。

2.2.2 JVMTI 错误处理

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

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

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

2.3 JVMTI 基本功能

JVMTI 的功能非常丰富,包含了 虚拟机中线程、内存 / 堆 / 栈,类 / 方法 / 变量,事件 / 定时器处理等等 20 多类功能,从功能上大致可以分为4类,如下:

  1. Heap:获取所有类的信息,对象信息,对象引用关系,Full GC开始/结束,对象回收事件等;
  2. 线程与堆栈:获取所有线程的信息,线程组信息,控制线程(start,suspend,resume,interrupt…), Thread Monitor(Lock),得到线程堆栈,控制出栈,方法强制返回,方法栈本地变量等;
  3. Class & Object & Method & Field 元信息:class信息,符号表,方法表,redefine class(hotswap), retransform class,object信息,fields信息,method信息等;
  4. 工具类:线程cpu消耗,classloader路径修改,系统属性获取等;

2.3.1 事件处理和回调函数

从上文我们知道,使用 JVMTI 一个基本的方式就是设置回调函数,在某些事件发生的时候触发并作出相应的动作

因此这一部分的功能非常基本,当前版本的 JVMTI 提供了许多事件(Event)的回调,包括 虚拟机初始化、开始运行、结束,类的加载,方法出入,线程始末等等。如果想对这些事件进行处理,需要首先为该事件写一个函数,然后在 jvmtiEventCallbacks 这个结构中指定相应的函数指针

比如:我们对线程启动感兴趣,并写了一个 HandleThreadStart 函数,那么我们需要在 Agent_OnLoad 函数里加入:

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

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

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

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

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

2.3.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 的回调函数,可能会使整个操作效率下降一个数量级。

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

2.3.4 调试功能

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

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

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

2.3.5 JVMTI 数据结构

JVMTI 中使用的数据结构,首先也是一些标准的 JNI 数据结构,比如:jint,jlong ;其次,JVMTI 也定义了一些基本类型,比如:jthread,表示一个 thread,jvmtiEvent,表示 jvmti 所定义的事件;更复杂的有 JVMTI 的一些需要用结构体表示的数据结构,比如:堆的信息(jvmtiStackInfo)。