JVM 源码分析之 javaagent 原理完全解读

阅读数:36650 2015 年 9 月 13 日

本文重点讲述 javaagent 的具体实现,因为它面向的是我们 Java 程序员,而且 agent 都是用 Java 编写的,不需要太多的 C/C++ 编程基础,不过这篇文章里也会讲到 JVMTIAgent(C 实现的),因为 javaagent 的运行还是依赖于一个特殊的 JVMTIAgent。

对于 javaagent,或许大家都听过,甚至使用过,常见的用法大致如下:

复制代码
java -javaagent:myagent.jar=mode=test Test

我们通过 -javaagent 来指定我们编写的 agent 的 jar 路径(./myagent.jar),以及要传给 agent 的参数(mode=test),在启动的时候这个 agent 就可以做一些我们希望的事了。

javaagent 的主要功能如下:

  • 可以在加载 class 文件之前做拦截,对字节码做修改
  • 可以在运行期对已加载类的字节码做变更,但是这种情况下会有很多的限制,后面会详细说
  • 还有其他一些小众的功能
    • 获取所有已经加载过的类
    • 获取所有已经初始化过的类(执行过 clinit 方法,是上面的一个子集)
    • 获取某个对象的大小
    • 将某个 jar 加入到 bootstrap classpath 里作为高优先级被 bootstrapClassloader 加载
    • 将某个 jar 加入到 classpath 里供 AppClassloard 去加载
    • 设置某些 native 方法的前缀,主要在查找 native 方法的时候做规则匹配

想象一下可以让程序按照我们预期的逻辑去执行,听起来是不是挺酷的。

JVMTI 全称 JVM Tool Interface,是 JVM 暴露出来的一些供用户扩展的接口集合。JVMTI 是基于事件驱动的,JVM 每执行到一定的逻辑就会调用一些事件的回调接口(如果有的话),这些接口可以供开发者扩展自己的逻辑。

比如最常见的,我们想在某个类的字节码文件读取之后、类定义之前修改相关的字节码,从而使创建的 class 对象是我们修改之后的字节码内容,那就可以实现一个回调函数赋给 jvmtiEnv(JVMTI 的运行时,通常一个 JVMTIAgent 对应一个 jvmtiEnv,但是也可以对应多个)的回调方法集合里的 ClassFileLoadHook,这样在接下来的类文件加载过程中都会调用到这个函数中,大致实现如下:,

复制代码
jvmtiEventCallbacks callbacks;
jvmtiEnv * jvmtienv = jvmti(agent);
jvmtiError jvmtierror;
memset(&callbacks, 0, sizeof(callbacks));
callbacks.ClassFileLoadHook = &eventHandlerClassFileLoadHook;
jvmtierror = (*jvmtienv)->SetEventCallbacks( jvmtienv,
&callbacks,
sizeof(callbacks));

JVMTIAgent 其实就是一个动态库,利用 JVMTI 暴露出来的一些接口来干一些我们想做、但是正常情况下又做不到的事情,不过为了和普通的动态库进行区分,它一般会实现如下的一个或者多个函数:

复制代码
JNIEXPORT jint JNICALL
Agent_OnLoad(JavaVM *vm, char *options, void *reserved);
JNIEXPORT jint JNICALL
Agent_OnAttach(JavaVM* vm, char* options, void* reserved);
JNIEXPORT void JNICALL
Agent_OnUnload(JavaVM *vm);
  • Agent_OnLoad函数,如果 agent 是在启动时加载的,也就是在 vm 参数里通过 -agentlib 来指定的,那在启动过程中就会去执行这个 agent 里的Agent_OnLoad函数。
  • Agent_OnAttach函数,如果 agent 不是在启动时加载的,而是我们先 attach 到目标进程上,然后给对应的目标进程发送 load 命令来加载,则在加载过程中会调用Agent_OnAttach函数。
  • Agent_OnUnload函数,在 agent 卸载时调用,不过貌似基本上很少实现它。

其实我们每天都在和 JVMTIAgent 打交道,只是你可能没有意识到而已,比如我们经常使用 Eclipse 等工具调试 Java 代码,其实就是利用 JRE 自带的 jdwp agent 实现的,只是 Eclipse 等工具在没让你察觉的情况下将相关参数 (类似-agentlib:jdwp=transport=dt_socket,suspend=y,address=localhost:61349) 自动加到程序启动参数列表里了,其中 agentlib 参数就用来跟要加载的 agent 的名字,比如这里的 jdwp(不过这不是动态库的名字,JVM 会做一些名称上的扩展,比如在 Linux 下会去找libjdwp.so的动态库进行加载,也就是在名字的基础上加前缀lib,再加后缀.so),接下来会跟一堆相关的参数,将这些参数传给Agent_OnLoad或者Agent_OnAttach函数里对应的options

说到 javaagent,必须要讲的是一个叫做 instrument 的 JVMTIAgent(Linux 下对应的动态库是 libinstrument.so),因为 javaagent 功能就是它来实现的,另外 instrument agent 还有个别名叫 JPLISAgent(Java Programming Language Instrumentation Services Agent),这个名字也完全体现了其最本质的功能:就是专门为 Java 语言编写的插桩服务提供支持的。

instrument agent

instrument agent 实现了Agent_OnLoadAgent_OnAttach两方法,也就是说在使用时,agent既可以在启动时加载,也可以在运行时动态加载。其中启动时加载还可以通过类似-javaagent:myagent.jar的方式来间接加载 instrument agent,运行时动态加载依赖的是 JVM 的 attach 机制( JVM Attach 机制实现),通过发送 load 命令来加载 agent。

instrument agent 的核心数据结构如下:

复制代码
struct _JPLISAgent {
JavaVM * mJVM; /* handle to the JVM */
JPLISEnvironment mNormalEnvironment; /* for every thing but retransform stuff */
JPLISEnvironment mRetransformEnvironment;/* for retransform stuff only */
jobject mInstrumentationImpl; /* handle to the Instrumentation instance */
jmethodID mPremainCaller; /* method on the InstrumentationImpl that does the premain stuff (cached to save lots of lookups) */
jmethodID mAgentmainCaller; /* method on the InstrumentationImpl for agents loaded via attach mechanism */
jmethodID mTransform; /* method on the InstrumentationImpl that does the class file transform */
jboolean mRedefineAvailable; /* cached answer to "does this agent support redefine" */
jboolean mRedefineAdded; /* indicates if can_redefine_classes capability has been added */
jboolean mNativeMethodPrefixAvailable; /* cached answer to "does this agent support prefixing" */
jboolean mNativeMethodPrefixAdded; /* indicates if can_set_native_method_prefix capability has been added */
char const * mAgentClassName; /* agent class name */
char const * mOptionsString; /* -javaagent options string */
};
struct _JPLISEnvironment {
jvmtiEnv * mJVMTIEnv; /* the JVM TI environment */
JPLISAgent * mAgent; /* corresponding agent */
jboolean mIsRetransformer; /* indicates if special environment */
};

这里解释一下几个重要项:

  • mNormalEnvironment:主要提供正常的类 transform 及 redefine 功能。
  • mRetransformEnvironment:主要提供类 retransform 功能。
  • mInstrumentationImpl:这个对象非常重要,也是我们 Java agent 和 JVM 进行交互的入口,或许写过 javaagent 的人在写`premain`以及`agentmain`方法的时候注意到了有个 Instrumentation 参数,该参数其实就是这里的对象。
  • mPremainCaller:指向`sun.instrument.InstrumentationImpl.loadClassAndCallPremain`方法,如果 agent 是在启动时加载的,则该方法会被调用。
  • mAgentmainCaller:指向`sun.instrument.InstrumentationImpl.loadClassAndCallAgentmain`方法,该方法在通过 attach 的方式动态加载 agent 的时候调用。
  • mTransform:指向`sun.instrument.InstrumentationImpl.transform`方法。
  • mAgentClassName:在我们 javaagent 的 MANIFEST.MF 里指定的`Agent-Class`。
  • mOptionsString:传给 agent 的一些参数。
  • mRedefineAvailable:是否开启了 redefine 功能,在 javaagent 的 MANIFEST.MF 里设置`Can-Redefine-Classes:true`。
  • mNativeMethodPrefixAvailable:是否支持 native 方法前缀设置,同样在 javaagent 的 MANIFEST.MF 里设置`Can-Set-Native-Method-Prefix:true`。
  • mIsRetransformer:如果在 javaagent 的 MANIFEST.MF 文件里定义了`Can-Retransform-Classes:true`,将会设置 mRetransformEnvironment 的 mIsRetransformer 为 true。

在启动时加载 instrument agent

正如前面“概述”里提到的方式,就是启动时加载 instrument agent,具体过程都在`InvocationAdapter.c`的`Agent_OnLoad`方法里,这里简单描述下过程:

  • 创建并初始化 JPLISAgent
  • 监听 VMInit 事件,在 vm 初始化完成之后做下面的事情:
    • 创建 InstrumentationImpl 对象
    • 监听 ClassFileLoadHook 事件
    • 调用 InstrumentationImpl 的`loadClassAndCallPremain`方法,在这个方法里会调用 javaagent 里 MANIFEST.MF 里指定的`Premain-Class`类的 premain 方法
  • 解析 javaagent 里 MANIFEST.MF 里的参数,并根据这些参数来设置 JPLISAgent 里的一些内容

在运行时加载 instrument agent

在运行时加载的方式,大致按照下面的方式来操作:

复制代码
VirtualMachine vm = VirtualMachine.attach(pid);
vm.loadAgent(agentPath, agentArgs);

上面会通过 JVM 的 attach 机制来请求目标 JVM 加载对应的 agent,过程大致如下:

  • 创建并初始化 JPLISAgent
  • 解析 javaagent 里 MANIFEST.MF 里的参数
  • 创建 InstrumentationImpl 对象
  • 监听 ClassFileLoadHook 事件
  • 调用 InstrumentationImpl 的loadClassAndCallAgentmain方法,在这个方法里会调用 javaagent 里 MANIFEST.MF 里指定的Agent-Class类的agentmain方法

instrument agent 的 ClassFileLoadHook 回调实现

不管是启动时还是运行时加载的 instrument agent,都关注着同一个 jvmti 事件——ClassFileLoadHook,这个事件是在读取字节码文件之后回调时用的,这样可以对原来的字节码做修改,那这里面究竟是怎样实现的呢?

复制代码
void JNICALL
eventHandlerClassFileLoadHook( jvmtiEnv * jvmtienv,
JNIEnv * jnienv,
jclass class_being_redefined,
jobject loader,
const char* name,
jobject protectionDomain,
jint class_data_len,
const unsigned char* class_data,
jint* new_class_data_len,
unsigned char** new_class_data) {
JPLISEnvironment * environment = NULL;
environment = getJPLISEnvironment(jvmtienv);
/* if something is internally inconsistent (no agent), just silently return without touching the buffer */
if ( environment != NULL ) {
jthrowable outstandingException = preserveThrowable(jnienv);
transformClassFile( environment->mAgent,
jnienv,
loader,
name,
class_being_redefined,
protectionDomain,
class_data_len,
class_data,
new_class_data_len,
new_class_data,
environment->mIsRetransformer);
restoreThrowable(jnienv, outstandingException);
}
}

先根据 jvmtiEnv 取得对应的 JPLISEnvironment,因为上面我已经说到其实有两个 JPLISEnvironment(并且有两个 jvmtiEnv),其中一个是专门做 retransform 的,而另外一个用来做其他事情,根据不同的用途,在注册具体的 ClassFileTransformer 时也是分开的,对于作为 retransform 用的 ClassFileTransformer,我们会注册到一个单独的 TransformerManager 里。

接着调用 transformClassFile 方法,由于函数实现比较长,这里就不贴代码了,大致意思就是调用 InstrumentationImpl 对象的 transform 方法,根据最后那个参数来决定选哪个 TransformerManager 里的 ClassFileTransformer 对象们做 transform 操作。

复制代码
private byte[]
transform( ClassLoader loader,
String classname,
Class classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer,
boolean isRetransformer) {
TransformerManager mgr = isRetransformer?
mRetransfomableTransformerManager :
mTransformerManager;
if (mgr == null) {
return null; // no manager, no transform
} else {
return mgr.transform( loader,
classname,
classBeingRedefined,
protectionDomain,
classfileBuffer);
}
}
public byte[]
transform( ClassLoader loader,
String classname,
Class classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
boolean someoneTouchedTheBytecode = false;
TransformerInfo[] transformerList = getSnapshotTransformerList();
byte[] bufferToUse = classfileBuffer;
// order matters, gotta run 'em in the order they were added
for ( int x = 0; x < transformerList.length; x++ ) {
TransformerInfo transformerInfo = transformerList[x];
ClassFileTransformer transformer = transformerInfo.transformer();
byte[] transformedBytes = null;
try {
transformedBytes = transformer.transform( loader,
classname,
classBeingRedefined,
protectionDomain,
bufferToUse);
}
catch (Throwable t) {
// don't let any one transformer mess it up for the others.
// This is where we need to put some logging. What should go here? FIXME
}
if ( transformedBytes != null ) {
someoneTouchedTheBytecode = true;
bufferToUse = transformedBytes;
}
}
// if someone modified it, return the modified buffer.
// otherwise return null to mean "no transforms occurred"
byte [] result;
if ( someoneTouchedTheBytecode ) {
result = bufferToUse;
}
else {
result = null;
}
return result;
}

以上是最终调到的 java 代码,可以看到已经调用到我们自己编写的 javaagent 代码里了,我们一般是实现一个 ClassFileTransformer 类,然后创建一个对象注册到对应的 TransformerManager 里。

这里说的 class transform 其实是狭义的,主要是针对第一次类文件加载时就要求被 transform 的场景,在加载类文件的时候发出 ClassFileLoad 事件,然后交给 instrumenat agent 来调用 javaagent 里注册的 ClassFileTransformer 实现字节码的修改。

类重新定义,这是 Instrumentation 提供的基础功能之一,主要用在已经被加载过的类上,想对其进行修改,要做这件事,我们必须要知道两个东西,一个是要修改哪个类,另外一个是想将那个类修改成怎样的结构,有了这两个信息之后就可以通过 InstrumentationImpl 下面的 redefineClasses 方法操作了:

复制代码
public void redefineClasses(ClassDefinition[] definitions) throws ClassNotFoundException {
if (!isRedefineClassesSupported()) {
throw new UnsupportedOperationException("redefineClasses is not supported in this environment");
}
if (definitions == null) {
throw new NullPointerException("null passed as 'definitions' in redefineClasses");
}
for (int i = 0; i < definitions.length; ++i) {
if (definitions[i] == null) {
throw new NullPointerException("element of 'definitions' is null in redefineClasses");
}
}
if (definitions.length == 0) {
return; // short-circuit if there are no changes requested
}
redefineClasses0(mNativeAgent, definitions);
}

在 JVM 里对应的实现是创建一个VM_RedefineClassesVM_Operation,注意执行它的时候会 stop-the-world:

复制代码
jvmtiError
JvmtiEnv::RedefineClasses(jint class_count, const jvmtiClassDefinition* class_definitions) {
//TODO: add locking
VM_RedefineClasses op(class_count, class_definitions, jvmti_class_load_kind_redefine);
VMThread::execute(&op);
return (op.check_error());
} /* end RedefineClasses */

这个过程我尽量用语言来描述清楚,不详细贴代码了,因为代码量实在有点大:

  • 挨个遍历要批量重定义的 jvmtiClassDefinition
  • 然后读取新的字节码,如果有关注 ClassFileLoadHook 事件的,还会走对应的 transform 来对新的字节码再做修改
  • 字节码解析好,创建一个 klassOop 对象
  • 对比新老类,并要求如下:
    • 父类是同一个
    • 实现的接口数也要相同,并且是相同的接口
    • 类访问符必须一致
    • 字段数和字段名要一致
    • 新增的方法必须是 private static/final 的
    • 可以删除修改方法
  • 对新类做字节码校验
  • 合并新老类的常量池
  • 如果老类上有断点,那都清除掉
  • 对老类做 JIT 去优化
  • 对新老方法匹配的方法的 jmethodId 做更新,将老的 jmethodId 更新到新的 method 上
  • 新类的常量池的 holer 指向老的类
  • 将新类和老类的一些属性做交换,比如常量池,methods,内部类
  • 初始化新的 vtable 和 itable
  • 交换 annotation 的 method、field、paramenter
  • 遍历所有当前类的子类,修改他们的 vtable 及 itable

上面是基本的过程,总的来说就是只更新了类里的内容,相当于只更新了指针指向的内容,并没有更新指针,避免了遍历大量已有类对象对它们进行更新所带来的开销。

retransform class 可以简单理解为回滚操作,具体回滚到哪个版本,这个需要看情况而定,下面不管那种情况都有一个前提,那就是 javaagent 已经要求要有 retransform 的能力了:

  • 如果类是在第一次加载的的时候就做了 transform,那么做 retransform 的时候会将代码回滚到 transform 之后的代码
  • 如果类是在第一次加载的的时候没有任何变化,那么做 retransform 的时候会将代码回滚到最原始的类文件里的字节码
  • 如果类已经加载了,期间类可能做过多次 redefine(比如被另外一个 agent 做过),但是接下来加载一个新的 agent 要求有 retransform 的能力了,然后对类做 redefine 的动作,那么 retransform 的时候会将代码回滚到上一个 agent 最后一次做 redefine 后的字节码

我们从 InstrumentationImpl 的retransformClasses方法参数看猜到应该是做回滚操作,因为我们只指定了 class:

复制代码
public void retransformClasses(Class<?>[] classes) {
if (!isRetransformClassesSupported()) {
throw new UnsupportedOperationException( "retransformClasses is not supported in this environment");
}
retransformClasses0(mNativeAgent, classes);
}

不过 retransform 的实现其实也是通过 redefine 的功能来实现,在类加载的时候有比较小的差别,主要体现在究竟会走哪些 transform 上,如果当前是做 retransform 的话,那将忽略那些注册到正常的 TransformerManager 里的 ClassFileTransformer,而只会走专门为 retransform 而准备的 TransformerManager 的 ClassFileTransformer,不然想象一下字节码又被无声无息改成某个中间态了。

复制代码
private:
void post_all_envs() {
if (_load_kind != jvmti_class_load_kind_retransform) {
// for class load and redefine,
// call the non-retransformable agents
JvmtiEnvIterator it;
for (JvmtiEnv* env = it.first(); env != NULL; env = it.next(env)) {
if (!env->is_retransformable() && env->is_enabled(JVMTI_EVENT_CLASS_FILE_LOAD_HOOK)) {
// non-retransformable agents cannot retransform back,
// so no need to cache the original class file bytes
post_to_env(env, false);
}
}
}
JvmtiEnvIterator it;
for (JvmtiEnv* env = it.first(); env != NULL; env = it.next(env)) {
// retransformable agents get all events
if (env->is_retransformable() && env->is_enabled(JVMTI_EVENT_CLASS_FILE_LOAD_HOOK)) {
// retransformable agents need to cache the original class file
// bytes if changes are made via the ClassFileLoadHook
post_to_env(env, true);
}
}
}

javaagent 除了做字节码上面的修改之外,其实还有一些小功能,有时候还是挺有用的

  • 获取所有已经被加载的类:Class[] getAllLoadedClasses(); 
  • 获取所有已经初始化了的类: Class[] getInitiatedClasses(ClassLoader loader); 
  • 获取某个对象的大小: long getObjectSize(Object objectToSize); 
  • 将某个 jar 加入到 bootstrap classpath 里优先其他 jar 被加载: void appendToBootstrapClassLoaderSearch(JarFile jarfile); 
  • 将某个 jar 加入到 classpath 里供 appclassloard 去加载:void appendToSystemClassLoaderSearch(JarFile jarfile); 
  • 设置某些 native 方法的前缀,主要在找 native 方法的时候做规则匹配: void setNativeMethodPrefix(ClassFileTransformer transformer, String prefix)。

李嘉鹏,花名寒泉子,使用“你假笨”的ID 混迹网络,蚂蚁金服码农一枚。本科毕业四年多,一直待在支付宝,先后从事过监控系统、框架容器以及性能分析系统等研发工作,其中从事框架容器三年多,主要负责开发支付宝的统一编程框架sofa,2014 年下半年开始重点从事性能分析系统的研发工作并于年底加入JVM 团队。


感谢臧秀涛对本文的审校。

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论