终于知道 Java agent 怎么重写字节码了
作者:丁仪 来源:https://chengxuzhixin.com/blog/post/Javaagent-zen-me-zhong-xie-zi-jie-ma.html
Java 自从 JDK 1.5 开始提供了 Instrument 机制,允许使用单独的 agent 获取 JVM 信息、动态修改 class 字节码,可以实现无侵入的运行时 AOP。使用 Java agent 可以在 JVM 启动前(JDK 1.5+)或启动后(JDK 1.6+)修改字节码,实现运行时数据的采集和回放(如 doom),也可以用于实时查看线上运行情况(如 arthas)。
启动前加载 agent
启动前加载 agent 可以在 Java 启动命令中,加入 javaagent 选项来启动代理。命令格式是:
-javaagent:jarpath [=options]
其中,jarpath 是代理 jar 文件的路径,options是传给代理的入参数据。javaagent 命令可以使用多次从而创建多个代理,且多个代理可以使用相同的 jarpath。
用于代理的 jar 文件需要满足如下要求:
- manifest 文件中必须指定 Premain-Class 属性,属性的值是代理类的全路径名称;
- 代理类必须实现一个 premain 方法。
premain 方法有两个可用的定义, JVM 首先尝试调用:
public static void premain(String agentArgs, Instrumentation inst);
如果该方法未实现,JVM 再次尝试调用:
public static void premain(String agentArgs);
在 options 配置的入参,会通过 agentArgs 参数传入,由开发者自行解析字符串的内容。如果使用 javaagent 命令创建了多个代理,JVM 会依次调用每个代理的 premain 方法,然后才会调用 main 方法。所以 premain 方法必须返回,否则将无法启动。
代理类会使用系统类加载器( ClassLoader.getSystemClassLoader)来加载,因此 premain 会和 main 方法拥有相同的安全和加载器规则。JVM 不限制 premain 的实现,main 方法能做的事情,premain 都可以做。
启动后加载 agent
除了 premain 方法,JVM(1.6 及以后)还支持在启动之后启动代理。用于代理的 jar 文件需要满足如下要求:
- manifest 文件中必须指定 Agent-Class 属性,属性的值是代理类的全路径名称;
- 代理类必须实现一个 public static 类型的 agentmain 方法;
- 系统类加载器( ClassLoader.getSystemClassLoader)必须支持将代理 jar 文件添加到系统类路径的机制。
agentmain 方法有两个可用的定义, JVM 首先尝试调用:
public static void agentmain(String agentArgs, Instrumentation inst);
如果该方法未实现,JVM 再次尝试调用:
public static void agentmain(String agentArgs);
入参会通过 agentArgs 参数传入,由开发者自行解析字符串的内容。
JVM 启动后启动代理,是通过 VirtualMachine 类来实现,该类位于 com.sun.tools.attach 包中,提供了 JVM 相关的操作方法,代理相关的主要是以下方法:
- list :获取当前已经启动的 JVM 列表;
- attach :连接指定的 JVM;
- loadAgent :指定的 JVM 加载代理类;
demo 代码如下,首先获取运行中的 JVM 列表,然后 attach 到 JVM,调用 loadAgent 方法让 JVM 启动指定的代理类。
manifest 文件
manifest 文件中可以同时存在 Premain-Class 属性和 Agent-Class 属性。使用 -javaagent 选项在命令行上启动代理后,Premain-Class 属性生效,Agent-Class 属性会被忽略。如果在启动 JVM 后启动代理,则 Agent-Class 属性生效,Premain-Class 属性会被忽略。
manifest 文件除指定 agent 类外,还有其他可选选项可以配置,如下:
- Boot-Class-Path:引导类加载器搜索的路径列表。在特定于平台的类的机制失败后,引导类加载器将搜索这些路径。按照列出的顺序搜索路径。列表中的路径由一个或多个空格分隔。(可选);
- Can-Redefine-Classes:true 表示能重定义此代理所需的类,默认值为 false(可选);
- Can-Retransform-Classes :true 表示能重新转换此代理所需的类,默认值为 false(可选);
- Can-Set-Native-Method-Prefix: true 表示能设置此代理所需的本机方法前缀,默认值为 false(可选)。
Instrumentation
无论是 premain 还是 agentmain,JVM 都可以通过入参传入 Instrumentation 实例。Instrumentation 接口定义在 java.lang.instrument 包中,该包中还有 ClassFileTransformer 接口。ClassFileTransformer 接口只有一个方法 transform,用于转换 class 字节码。Instrumentation 接口提供了以下方法:
- addTransformer:增加 ClassFileTransformer 实例,我们在 ClassFileTransformer 中自定义重写字节码;
- removeTransformer:删除 ClassFileTransformer 实例;
- getAllLoadedClasses:返回所有加载的 class;
- retransformClasses:重新转换 class,也就是重新加载类定义,不能修改声明(如字段、方法);
- redefineClasses:重新定义 class,修改字节码数据直接给到 JVM,不会经过 transform 方法;
借助 Instrument 机制,我们可以获取 JVM 加载的所有类,对目标类进行修改并重新生成字节码文件,实现对目标的增强。
修改字节码
修改字节码的技术比较多,比如 ASM、Javassist、BCEL、CGLib 等。创建 Demo 类,预期在代码前后插入增强代码。
package com.chengxuzhixin;
public class Demo {
public void test(){
// 预期前面插入 打印 start
System.out.println("test");
// 预期后面插入 打印 end
}
}
ASM 是在指令层次上操作字节码。字节码结构比较稳定,很适合使用 ASM 访问者模式进行修改。ASM 提供了 ClassReader 可以读取已经编译好的 class 文件,通过 ClassVisitor、MethodVisitor、FieldVisitor 等各种类型的 Visitor 修改字节码,然后用 ClassWriter 重新生成字节码。这样的重写方式需要开发者对字节码非常了解,要熟悉一系列 visitXXXInsn 方法,可以使用社区工具 ASM ByteCode Outline 来帮助生成 visitXXXInsn 方法。
// 创建 ClassReader
ClassReader reader = new ClassReader("com.chengxuzhixin.Demo");
// 创建 ClassWriter
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
// 创建 ClassVisitor 并传入
reader.accept(new ClassAdapter(classWriter){
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
if (name.equals("test")) {
// 只处理 test 方法
mv = new MethodAdapter(mv){
@Override
public void visitCode() {
super.visitCode();
// 前面插入代码
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("asm start");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V");
}
@Override
public void visitInsn(int opcode) {
if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN)
|| opcode == Opcodes.ATHROW) {
// 后面插入代码
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("asm end");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V");
}
super.visitInsn(opcode);
}
};
}
return mv;
}
}, ClassReader.SKIP_DEBUG);
// Java agent 获取字节码数据
return classWriter.toByteArray();
Javassist 可以直接用 Java 编码来实现增强,无需关注字节码结构,比 ASM 更简单。Javassist 中核心的类主要有四个:
- CtClass:类信息;
- ClassPool:可以从中获取 CtClass,key 为类的全限定名;
- CtMethod:方法信息;
- CtField:字段信息。
基于这四个类,可以方便地实现增强,比如要在指定方法前后增加代码,如下所示:
// 获取默认 ClassPool
ClassPool cp = ClassPool.getDefault();
// 找到 CtClass,重写 com.chengxuzhixin.Demo
CtClass cc = cp.get("com.chengxuzhixin.Demo");
// 增强方法 test
CtMethod m = cc.getDeclaredMethod("test");
// 前面插入代码
m.insertBefore("{ System.out.println(\"javassist start\"); }");
// 后面插入代码
m.insertAfter("{ System.out.println(\"javassist end\"); }");
// Java agent 获取字节码数据
return cc.toBytecode();
推荐阅读