声明:本文首发于京东零售技术公众号,为博主本人撰写投稿。
前言:
在平时的开发中,我们不可避免的会使用到Debug工具,JVM作为一个单独的进程,我们使用的Debug工具可以获取JVM运行时的相关的信息,查看变量值,甚至加入断点控制,还有我们平时使用JDK自带的JMAP、JSTACK等工具,可以在JVM运行时动态的dump内存、查询线程信息,甚至一些第三方的工具,比如说京东内部使用的JEX、pfinder,阿里巴巴的Arthas,优秀的开源的框架skywalking等等,也可以做到这些,那么这些工具究竟是通过什么技术手段来实现对JVM的监控和动态修改呢?本文会进行介绍和简单的原理分析,同时附带一些样例代码来进行分析。
1.从JVMTI说起
JVM在设计之初,就考虑到了虚拟机状态的监控、debug、线程和内存分析等功能,在JDK5.0之前,JVM规范就定义了JVMPI(Java Virtual Machine Profiler Interface)
也就是JVM分析接口以及JVMDI(Java Virtual Machine Debug Interface)
也就是JVM调试接口,JDK5以及以后的版本,这两套接口合并成了一套,也就是Java Virtual Machine Tool Interface
,就是我们这里说的JVMTI,这里需要注意的是:
- JVMTI是一套JVM的接口规范,不同的JVM实现方式可以不同,有的JVM提供了拓展性的功能,比如openJ9,当然也可能存在JVM不提供这个接口的实现
- JVMTI提供的是Native方式调用的API,也就是常说的JNI方式,JVMTI接口用C/C++的语言提供,最终以动态链接库的形式由JVM加载并运行
使用JNI方式调用JVMTI接口访问目标虚拟机的大体过程入下图:
jvmti.h
头文件中定义了JVMTI接口提供的方法,但是其方法的实现是由JVM提供商实现的,比如说hotspot虚拟机其实现大部分在src\share\vm\prims\jvmtiEnv.cpp
这个文件中。
2.Instrument Agent
在Jdk1.5之后,Java语言中开始提供Instrumentation
接口(java.lang.instrument)
让开发者可以使用Java语言编写Agent,但是其根本实现还是依靠JVMTI,只不过是SUN在工具包(sun.instrument.InstrumentationImpl)
编写了一些native方法,并且然后在JDK里提供了这些native方法的实现类(jdk\src\share\instrument\JPLISAgent.c)
,最终需要调用jvmti.h头文件定义的方法,跟前文提到采用JNI方式访问JVMTI提供的方法并无差异,大体流程如下图:
但是Instrument agent
仅使用到了JVMTI提供部分功能,对开发者来说,主要提供的是对JVM加载的类字节码进行插桩操作。
JVMTI方式 | Instrument方式 | |
---|---|---|
性能 | 可以独立进程,不受目标JVM影响 | 在目标JVM内,GC时会受到影响 |
功能性 | 方法众多,功能非常全面 | 在目标JVM内,GC时会受到影响专门提供代码“插桩”功能 |
易用性 | 需要掌握C/C++,以及JNI开发相关知识 | Java代码开发,上手快 |
3 JVM启动时Agent
我们知道,JVM启动时可以指定-javaagent:xxx.jar参数来实现启动时代理,这里xxx.jar就是需要被代理到目标JVM上的JAR包,实现一个可以代理到指定JVM的JAR包需要满足以下条件:
- JAR包的MANIFEST.MF清单文件中定义Premain-Class属性,指定一个类,加入Can-Redefine-Classes 和 Can-Retransform-Classes 选项。
- JAR包中包含清单文件中定义的这个类,类中包含premain方法,方法逻辑可以自己实现
了解到这两点,我们可以定义下列类:
import java.lang.instrument.Instrumentation;
public class AgentMain {
// JVM启动时agent
public static void premain(String args, Instrumentation inst) {
agent0(args, inst);
}
public static void agent0(String args, Instrumentation inst) {
System.out.println("agent is running!");
// 添加一个类转换器
inst.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
// JVM加载的所有类会流经这个类转换器
// 这里找到自定义的测试类
if (className.endsWith("WorkerMain")) {
System.out.println("transform class WorkerMain");
}
// 直接返回原本的字节码
return classfileBuffer;
}
});
}
}
JAR包内对应的清单文件(MANIFEST.MF)
需要有如下内容:
PreMain-Class: AgentMain
Can-Redefine-Classes: true
Can-Retransform-Classes: true
-javaagent
所指定 jar 包内 Premain-Class 类的 premain 方法,方法签名可以有两种:
public static void premain(String agentArgs, Instrumentation inst)
public static void premain(String agentArgs)
JVM会优先加载1签名的方法,加载成功忽略2,如果1没有,加载2方法。这个逻辑在sun.instrument.InstrumentationImpl
类中实现。
需要说明的是,addTransformer
方法的作用是添加一个字节码转换器,这个方法的入参对象需要实现ClassFileTransformer
接口,唯一需要实现的方法就是transform
方法,这个方法可以用来修改加载类的字节码,目前我们并不对字节码进行修改。
最后定义测试类:
package test;
import java.util.Random;
class WorkerMain {
public static void main(String[] args) throws InterruptedException {
for (; ; ) {
int x = new Random().nextInt();
new WorkerMain().test(x);
}
}
public void test(int x) throws InterruptedException {
Thread.sleep(2000);
System.out.println("i'm working " + x);
}
}
启动时添加-javaagent:xxx.jar
参数,指定agent刚刚生成的JAR包,可以看到运行结果:
下面尝试结合JDK源码对该流程进行浅析:
JVM开始启动时会解析-javaagent
参数,如果存在这个参数,就会执行Agent_OnLoad 方法读取并解析指定JAR包后生成JPLISAgent对象,然后注册jvmtiEventCallbacks.VMInit
这个事件,也就是虚拟机初始化事件,并设置该事件的回调函数eventHandlerVMInit
,这些代码逻辑在jdk\src\share\instrument\InvocationAdapter.c
和 jdk\src\share\instrument\JPLISAgent.c
中实现。
在JVM初始化时会调用之前注册的eventHandlerVMInit
事件的回调函数,进入processJavaStart
这个函数,首先会在注册另一个JVM事件ClassFileLoadHook
,然后会真正的执行我们在Java代码层面编写的premain
方法。当JVM开始装载类字节码文件时,会触发之前注册的ClassFileLoadHook
事件的回调方法eventHandlerClassFileLoadHook
,这个回调函数调用transformClassFile
方法,生成新的字节码,被JVM装载,完成了启动时代理的全部流程。
以上代码逻辑在jdk\src\share\instrument\JPLISAgent.c
中实现。