Java Agent原理分析
转载自https://mp.weixin.qq.com/s/gqxeeVFXVYmsx4VFnBf3fw
一. 简介
简单来说,Java Agent是通过JVMTI可以实现对JVM的监控和动态修改,常用的第三方框架如Arthas、Skywalking都是基于Java Agent技术来实现的。
JVM TI(JVM TOOL INTERFACE,JVM工具接口)是JVM提供的一套对JVM进行操作的工具接口。通过JVMTI,可以实现对JVM的多种操作,它通过接口注册各种事件勾子,在JVM事件触发时,同时触发预定义的勾子,以实现对各个JVM事件的响应。
- 类文件加载
- 异常产生与捕获
- 线程启动和结束
- 进入和退出临界区
- 成员变量修改
- GC开始和结束
- 方法调用进入和退出
- 临界区竞争与等待
- VM启动与退出
二. JVMTI(1.5之前)
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提供了拓展性的功能,当然也可能存在JVM不提供这个接口的实现。
- JVMTI提供的是Native方式调用的API,也就是常说的JNI方式,JVMTI接口用C/C++的语言提供,最终以动态链接库的形式由JVM加载并运行。
使用JNI方式调用JVMTI接口访问目标虚拟机的大体过程入下图:
可参考:https://www.jianshu.com/p/e59c4eed44a2
三. Instrument Agent(1.5之后)
在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加载的类字节码进行插桩操作。
3.1 JVM启动时Agent(1.5)
3.1.1 示例
JVM启动时可以指定-javaagent:xxx.jar参数来实现启动时代理,这里xxx.jar就是需要被代理到目标JVM上的JAR包,实现一个可以代理到指定JVM的JAR包需要满足以下条件:
- JAR包的MANIFEST.MF清单文件中定义Premain-Class属性,指定一个类,加入Can-Redefine-Classes 和 Can-Retransform-Classes 选项。
- JAR包中包含清单文件中定义的这个类,类中包含premain方法,方法逻辑可以自己实现。
public class AgentMain {
/**
* @param args
* @param inst
*/
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("HandlerMapping")) {
System.out.println("transform class: " + className);
}
// 直接返回原本的字节码
return classfileBuffer;
}
});
}
}
JAR包内对应的清单文件(MANIFEST.MF)需要有如下内容:
Premain-Class: com.youdao.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方法,这个方法可以用来修改加载类的字节码,目前我们并不对字节码进行修改。
最后测试下,启动时添加-javaagent:xxx.jar参数,指定agent刚刚生成的JAR包。
JVM参数
-javaagent:"/Users/lisheng/Project/Personal/java-agent-demo/target/java-agent-demo-0.0.1-jar-with-dependencies.jar"
可以看到运行结果:
3.1.2 流程分析
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 中实现。
可参考:https://www.infoq.cn/article/fh69pypqzpf6cj1ujy7x
3.2 JVM运行时Agent(1.6)
在JDK1.6版本中,SUN更进一步,提供了可以在JVM运行时代理的能力,和启动时代理类似,只需要满足:
- JAR包的MANIFEST.MF清单文件中定义Agent-Class属性,指定一个类,加入Can-Redefine-Classes 和 Can-Retransform-Classes 选项。
- JAR包中包含清单文件中定义的这个类,类中包含agentmain方法,方法逻辑可以自己实现。
运行时Agent可以在JVM运行时动态的修改某个类的字节码,然后JVM会重定义这个类(不需要创建新的类加载器),但是为了保证JVM的正常运行,新定义的类相较于原来的类需要满足:
- 父类是同一个。
- 实现的接口数也要相同,并且是相同的接口。
- 类访问符必须一致。
- 字段数和字段名要一致。
- 新增或删除的方法必须是private static/final的。
- 可以修改方法内部代码。
运行时Agent需要借助JVM的Attach机制,简单来说就是JVM提供的一种通信机制,JVM中会存在一个Attach Listener线程,监听其他JVM的attach请求,其通信方式基于socket,JVM Attach机制大体流程图如下:
3.2.1 示例
SUN在JDK中提供了Attach机制的Java语言工具包(com.sun.tools.attach),方便开发者使用Java语言进行操作,这里我们使用其中提供的loadAgent方法实现运行中agent的能力。
public class AttachUtil {
public static void main(String[] args) throws Exception {
// 获取运行中的JVM列表
List<VirtualMachineDescriptor> vmList = VirtualMachine.list();
// 需要agent的jar包路径
String agentJar = "/Users/lisheng/Project/Personal/java-agent-demo/target/java-agent-demo-0.0.1-jar-with-dependencies.jar";
for (VirtualMachineDescriptor vmd : vmList) {
// 找到测试的JVM
if (vmd.displayName().endsWith("LeafServerApplication")) {
// attach到目标ID的JVM上
VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());
// agent指定jar包到已经attach的JVM上
virtualMachine.loadAgent(agentJar);
// 卸载
virtualMachine.detach();
}
}
}
}
同时对之前启动时Agent的代码进行改写:
public class AgentMain {
/**
* JVM启动时Agent
*
* @param args
* @param inst
*/
public static void premain(String args, Instrumentation inst) {
agent0(args, inst);
}
/**
* JVM运行时Agent
*
* @param args
* @param inst
*/
public static void agentmain(String args, Instrumentation inst) {
agent1(args, inst);
}
public static void agent0(String args, Instrumentation inst) {
System.out.println("agent0 is running!");
// 添加一个类转换器
inst.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
// JVM加载的所有类会流经这个类转换器
if (className.endsWith("HandlerMapping")) {
System.out.println("transform class: " + className);
}
// 直接返回原本的字节码
return classfileBuffer;
}
});
}
public static void agent1(String args, Instrumentation inst) {
System.out.println("agent1 is running!");
inst.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
// 打印transform的类名
System.out.println(className);
return classfileBuffer;
}
}, true);
try {
// 找到WorkerMain类,对其进行重定义
Class<?> c = Class.forName("com.sankuai.inf.leaf.server.LeafServerApplication");
inst.retransformClasses(c);
} catch (Exception e) {
System.out.println("error!");
}
}
}
最后修改下JAR包内对应的清单文件(MANIFEST.MF),最终内容如下:
Premain-Class: com.youdao.AgentMain
Agent-Class: com.youdao.AgentMain
Can-Redefine-Classes: true
Can-Retransform-Classes: true
这里我们也没有对字节码进行修改,还是直接返回原本的字节码。运行AttachUtil类,运行结果如下:
3.2.2 流程分析
当AttachUtil的loadAgent方法调用时,目标JVM会调用自身的Agent_OnAttach方法,这个方法和之前提到的Agent_OnLoad 方法类似,会进行Agent JAR包的解析,不同的是Agent_OnAttach方法会直接注册ClassFileLoadHook事件回调函数,然后执行agentmain方法添加类转换器。
需要注意的是我们在Java代码里调用了Instrumentation#retransformClasses(Class<?>...)方法,追踪代码可以发现最终调用了一个native方法,而这个native方法的实现则在jdk的src\share\instrument\JPLISAgent.c类中,最终retransformClasses会调用到JVMTI的RetransformClasses方法,这里由于JVM源码实现非常复杂,感兴趣的同学可以自行阅读(hotspot源码路径src\share\vm\prims\jvmtiEnv.cpp),简单来说在这个方法里,JVM会触发ClassFileLoadHook事件回调完成类字节码的转换,并完成虚拟机内已经加载的类字节码的热替换。
四. 基于Java Agent的AOP实现
4.1 打印方法执行时间
MyClassTransformer
public class MyClassTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
// 对类字节码进行操作
// 这里需要注意,不能对classfileBuffer这个数组进行修改操作
try {
if (className != null && className.endsWith("Controller")) {
// 创建ASM ClassReader对象,导入需要增强的对象字节码
ClassReader reader = new ClassReader(classfileBuffer);
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
// 自己实现的代码增强器
MyEnhancer myEnhancer = new MyEnhancer(classWriter);
// 增强字节码
reader.accept(myEnhancer, ClassReader.SKIP_FRAMES);
// 返回MyEnhancer增强后的字节码
return classWriter.toByteArray();
}
// 直接返回原本的字节码
return classfileBuffer;
} catch (Exception e) {
e.printStackTrace();
}
// return null 则不会对类进行转换
return null;
}
}
MyEnhancer
public class MyEnhancer extends ClassVisitor implements Opcodes {
public MyEnhancer(ClassVisitor classVisitor) {
super(ASM7, classVisitor);
}
/**
* 对字节码中的方法定义进行修改
*/
@Override
public MethodVisitor visitMethod(int access, final String name, String descriptor, String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
if (isIgnore(mv, access, name)) {
return mv;
}
return new AdviceAdapter(Opcodes.ASM7, new JSRInlinerAdapter(mv, access, name, descriptor, signature, exceptions), access, name, descriptor) {
private final Type METHOD_CONTAINER = Type.getType(MethodContainer.class);
private int timeIdentifier;
private int argsIdentifier;
/**
* 进入方法前
*/
@Override
protected void onMethodEnter() {
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("method [: " + name + "] invoke start...");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
// 调用System.nanoTime()方法,将方法出参推入栈顶
invokeStatic(Type.getType(System.class), Method.getMethod("long nanoTime()"));
// 构造一个Long类型的局部变量,然后返回这个变量的标识符
timeIdentifier = newLocal(Type.LONG_TYPE);
// 存储栈顶元素也就是System.nanoTime()返回值,到指定位置本地变量区
storeLocal(timeIdentifier);
// 加载入参数组,将入参数组ref推入栈顶
loadArgArray();
// 构造一个Object[]类型的局部变量,返回这个变量的标识符
argsIdentifier = newLocal(Type.getType(Object[].class));
// 存储入参到指定位置本地变量区
storeLocal(argsIdentifier);
}
@Override
protected void onMethodExit(int opcode) {
// 加载指定位置的本地变量到栈顶
loadLocal(timeIdentifier);
loadLocal(argsIdentifier);
// 相当于调用MethodContainer.showMethod(long, Object[])方法
invokeStatic(METHOD_CONTAINER, Method.getMethod("void showMethod(long,Object[])"));
}
};
}
/**
* 方法是否需要被忽略(静态构造函数和构造函数)
*/
private boolean isIgnore(MethodVisitor mv, int access, String methodName) {
return null == mv
|| isAbstract(access)
|| isFinalMethod(access)
|| "<clinit>".equals(methodName)
|| "<init>".equals(methodName);
}
private boolean isAbstract(int access) {
return (ACC_ABSTRACT & access) == ACC_ABSTRACT;
}
private boolean isFinalMethod(int methodAccess) {
return (ACC_FINAL & methodAccess) == ACC_FINAL;
}
}
MethodContainer
public class MethodContainer {
public static void showMethod(long startTime, Object[] Args) {
System.out.println("方法耗时:" + (System.nanoTime() - startTime) / 1000000 + "ms, 方法入参:" + Arrays.toString(Args));
}
}
运行结果如下:
五. 其他
5.1 Java Agent与Spring AOP
5.2 代码插桩
动态代理 | JSR269-插入式注解处理器 | Instrument Agent | |
---|---|---|---|
插桩时机 | 运行时 | 代码编译期 | 随时 |
性能 | 生成新类,有性能开销 | 运行无影响 | 重新定义类时会STW |
功能性 | 针对方法层面 | 针对定义的注解 | 局限性最小 |
易用性 | java | 需要学习相关API | 需要了解字节码开发 |
侵入性 | VM运行时不可卸载 | VM运行时不可卸载 | VM运行时可卸载 |
实现 | Spring AOP | Lombok等 | Arthas等性能分析器 |
5.3 云课直播结构图
参考
【1】https://mp.weixin.qq.com/s/gqxeeVFXVYmsx4VFnBf3fw