Java Agent注入的使用方法
在进程B中向进程A中注入java agent,需要满足以下条件:
- java agent中的所有依赖,在进程A中的classpath中都要能找到,否则在注入时进程A会报错NoClassDefFoundError
- java agent的pom文件中包含如下内容,以在jar包中包含MANIFEST.MF并设置Agent-Class和Can-Retransform-Classes属性
- 进程B的classpath中必须有tools.jar(提供VirtualMachine attach api),jdk默认有tools.jar,jre默认没有。
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>2.6</version>
<configuration>
<archive>
<manifestEntries>
<Agent-Class>com.warrenyoung.instrumentions.agent.AgentMain</Agent-Class>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
java agent开发和生效流程
- 开发带有agentmain/premain方法的class,添加ClassFileTransformer的子类,显式调用retransformClasses函数,并在MANIFEST.MF中设置上文中的属性
- 实现ClassFileTransformer类的transform方法,进行代码注入,可借助于javassist工具
- 在另一个java程序app2中,查找到需要注入代码的JVM,attach java agent的jar包到指定的JVM中
- 示例工程位置:instrumentiontest
// AgentMain.class
public class AgentMain {
public static void agentmain (String agentArgs, Instrumentation inst) throws UnmodifiableClassException {
inst.addTransformer(new TransformerTest(), true);
inst.retransformClasses(InstrumentTestClass.class);
System.out.println("Agent Main Done");
}
}
// TransformerTest.class
public class TransformerTest implements ClassFileTransformer {
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
if (!className.equals("com/warrenyoung/instrumentions/instrumentiondest/InstrumentTestClass"))
{
System.out.println(className);
return classfileBuffer;
}
try {
System.out.println("x:" + className);
ClassPool classPool = new ClassPool();
classPool.appendClassPath(new LoaderClassPath(loader));
final CtClass ctClass;
try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(classfileBuffer)) {
ctClass = classPool.makeClass(byteArrayInputStream);
}
ctClass.getDeclaredMethod("getStr").setBody("return \"b\";");
ctClass.getDeclaredMethod("getNum").setBody("return 2;");
System.out.println("transformed");
return ctClass.toBytecode();
} catch (Exception e)
{
e.printStackTrace();
}
return classfileBuffer;
}
}
// 用于执行注入动作的app2
public class Main {
private static ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
public static void main(String[] args)
{
// 这里其实不需要循环,单次注入,被注入JVM终生受影响,除非其他行为又触发了class被重新加载
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
run1();
}
}, 2000, 20000000, TimeUnit.MILLISECONDS);
}
public static void run1() {
List<VirtualMachineDescriptor> listAfter = null;
try {
int count = 0;
listAfter = VirtualMachine.list();
for (VirtualMachineDescriptor vmd : listAfter) {
if (vmd.displayName().equals("com.warrenyoung.instrumentions.instrumentiondest.Main"))
{
VirtualMachine vm = VirtualMachine.attach(vmd);
vm.loadAgent("/Users/warrenyoung/develops/instrumentiontest/agenttest/target/agenttest-1.0-SNAPSHOT.jar");
vm.detach();
}
System.out.println(vmd.displayName() + ", " + vmd.id());
}
} catch (Exception e) {
}
}
}
Java Agent的使用场景
IDE启动的时候会使用 -javaagent参数,如
/Library/Java/JavaVirtualMachines/jdk1.8.0_152.jdk/Contents/Home/bin/java “-javaagent:/Applications/IntelliJ IDEA.app/Contents/lib/idea_rt.jar=55984:/Applications/IntelliJ IDEA.app/Contents/bin” …com.warrenyoung.instrumentions.instrumentiondest.Main
Java agent工作原理
JVMTI是JVM Tool Interface,是JVM开放的一些供开发者扩展的native编程接口,基于事件驱动,开发者一般设置一些callback接口,对应事件(如:虚拟机初始化、开始运行、结束,类的加载,方法出入,线程始末等等)被触发时,callback会被调用。
JVMTI 是一套本地代码接口,因此使用 JVMTI 需要我们与 C/C++ 以及 JNI 打交道。事实上,开发时一般采用建立一个 Agent 的方式来使用 JVMTI,它使用 JVMTI 函数,设置一些回调函数,并从 Java 虚拟机中得到当前的运行态信息,并作出自己的判断,最后还可能操作虚拟机的运行态。把 Agent 编译成一个动态链接库之后,我们就可以在 Java 程序启动的时候来加载它(启动加载模式),也可以在 Java 5 之后使用运行时加载(活动加载模式)。
-agentlib:agent-lib-name=options
-agentpath:path-to-agent=options
Agent 是在 Java 虚拟机启动之时加载的,这个加载处于虚拟机初始化的早期,在这个时间点上:
- 所有的 Java 类都未被初始化;
- 所有的对象实例都未被创建;
- 因而,没有任何 Java 代码被执行;
JVMTI Agent可以实现三个方法
- JVM启动的时候如果设置了Java Agent,会执行OnLoad
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved) - JVM运行时使用attach的方式动态加载Java Agent,会执行OnAttach
JNIEXPORT jint JNICALL Agent_OnAttach(JavaVM* vm, char *options, void *reserved); - JVM关闭或者动态加载的Java Agent detach的时候会执行OnLoad
JNIEXPORT void JNICALL Agent_OnUnload(JavaVM *vm)
JVMTI的一种典型应用是Java调试功能,这主要包括了设置断点、调试(step)等,在 JVMTI 里面,设置断点的 API 本身很简单:
jvmtiError SetBreakpoint(jvmtiEnv* env, jmethodID method, jlocation location)
我们常用的Java Agent是基于一个叫做Instrument的JVMTI Agent实现的,Instrument Agent实现了OnLoad, OnAttach方法。
Instrumentation的redefineClasses和retransformClasses功能相似,redefineClasses是Java5引入的,retransformClasses是Java6引入的。
在Instrumentation.addTransformer,添加ClassFileTransformer,在以下三种情形下ClassFileTransformer.transform会被执行
- 新的class被加载
- Instrumentation.redefineClasses显式调用
- addTransformer第二个参数为true时,Instrumentation.retransformClasses显式调用
所以java agent的两种工作模式下,
- 如果是以 -javaagent方式使用的,则不需要在
premain
中显式调用retransformClasses/redefineClasses
,因为premain
方法是在所有用户类被加载之前执行的 - 如果是以attach的方式使用的,则
可能
需要在agentmain
中显式调用retransformClasses/redefineClasses
,因为attach的时候用户类可能
已经被加载过了
在第一种方式下,由于java agent premain
方法是在所有用户类被加载之前执行的,transform
前后的类的结构可以完全不同;第二种方式下,由于java agent agentmain
方法执行的时候,部分类已经被加载过了,如果需要重新加载已加载的类,为了保证transform
之后的类仍然可用,要求新的类格式与老的类格式兼容,因为transform
只是更新了类里内容,相当于只更新了指针指向的内容,并没有更新指针,避免了遍历大量已有类对象对它们进行更新带来的开销。限制如下:
- 父类是同一个
- 实现的接口数也要相同,并且是相同的接口
- 类访问符必须一致
- 字段数和字段名要一致
- 新增或删除的方法必须是private static/final的
- 可以修改方法
Class Redefine的实现
类重新定义,这是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_RedefineClasses
的VM_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对象
- 对比新老类,要求前后格式兼容(具体格式要求见上文)
- 对新类做字节码校验
- 合并新老类的常量池
- 如果老类上有断点,那都清除掉
- 对老类做jit去优化
- 对新老方法匹配的方法的jmethodid做更新,将老的jmethodId更新到新的method上
- 新类的常量池的holer指向老的类
- 将新类和老类的一些属性做交换,比如常量池,methods,内部类
- 初始化新的vtable和itable
- 交换annotation的method,field,paramenter
- 遍历所有当前类的子类,修改他们的vtable及itable
上面是基本的过程,总的来说就是只更新了类里内容,相当于只更新了指针指向的内容,并没有更新指针,避免了遍历大量已有类对象对它们进行更新带来的开销。
Class Retransform的实现
Java 5就提供了Class Redifine的能力,而Java 6才支持Class Restransform,可以认为Restransform是Redifine的一种升级版本,更加方便使用,两者能实现的功能是一致的,只是调用方式有些区别。
Instrumentation.retransform过程中会对每个class逐个调用使用Instrumentation.addTransformer添加的ClassFileTransformer的transform方法,多个ClassFileTransformer之间的变更具备传递性。
public void retransformClasses(Class<?>[] classes) {
if (!isRetransformClassesSupported()) {
throw new UnsupportedOperationException(
"retransformClasses is not supported in this environment");
}
retransformClasses0(mNativeAgent, classes);
}