字节码增强
一、class字节码结构
我们都知道java是一个跨平台的开发语言,其主要核心在于class字节码,也就是java在编译后会生成对应的class字节码文件,会通过类加载器将class字节码加载到jvm运行空间中。
在编写一个如下java文件:
public class SimpleJava {
public static void main(String[] args) {
int a = 0;
int b = 2;
System.out.println(a + b);
}
}
可以通过javap -c可以对class进行反汇编,其实class文件就是一个 十六进制的数据。
在class文件中主要存储了java类的魔数 cafebabe 以及对应的版本号,通过javap -c可以进行反汇编。
可以看到这里的信息,code就是方法区,我们的字节码增强也就是增强方法区的代码。
方法区里面都是一些指令。
iconst_0 也就是一个入栈指令,其作用是用来将int类型的数字、取值在-1到5之间的整数压入栈中。
istore_1 将数据存入到本地变量表中的第一个变量
iconst_2 将2进入入栈
istore_2 将2存入本地变量表中
二、ASM生成新的class字节码
ASM是一个Java字节码操作库。它允许Java开发人员在不借助Java编译器的情况下,直接以二进制形式操作Java类文件,并且可以动态生成或修改类的内容。ASM提供了一组API来访问和操作Java字节码指令。
import org.objectweb.asm.*;
public class MyClassGenerator {
public static void main(String[] args) {
String className = "com.example.MyClass";
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
int access = Opcodes.ACC_PUBLIC;
String superClassName = "java/lang/Object";
String[] interfaces = new String[0];
cw.visit(Opcodes.V1_8, access, className, null, superClassName, interfaces);
cw.visitField(Opcodes.ACC_PRIVATE, "field", "Ljava/lang/String;", null, null).visitEnd();
MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "myMethod", "()V", null, null);
mv.visitCode();
mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("Hello, world!");
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
mv.visitInsn(Opcodes.RETURN);
mv.visitMaxs(0, 0);
mv.visitEnd();
byte[] code = cw.toByteArray();
ClassLoader classLoader = MyClass.class.getClassLoader
上述代码就是使用asm进行生成对应的class文件,不过其需要掌握指令级别,难度有点大,因此有javassit框架来进行生成或修改class文件,通过源码的方式。
ASM能够更加精细操作字节码,而javassist提供了更好容易操作的api,如果要实现性能要求较高则需要使用asm框架。
三、javassist
Javassist(Java Programming Assistant)是一个开源的 Java 字节码编辑工具库,它可以动态地修改已有的类和生成新的类。与 ASM 等其它字节码操作库不同,Javassist 主要通过修改 Java 类的源代码来进行字节码操作,因此更加易于使用和学习。Javassist 提供了一组 API 来访问和操作 Java 类的属性、方法、构造函数等元素,并提供了丰富的模板功能来简化 Java 类的创建和修改过程。Javassist 通常被广泛应用于 Java 框架和应用程序中,例如 Hibernate、Spring、Tomcat 等。
3.1 核心对象
- CtClass: 代表了一个 Java 类的字节码。它提供了一组 API 来访问和修改 Java 类的结构和属性,并支持动态创建、修改和加载 Java 类。
- ClassPool : 用于管理 CtClass 对象,每个 ClassPool 实例都维护了一个字节码池,其中包含了所有已加载的 CtClass 对象。通过创建 ClassPool 实例,可以方便地访问和修改 CtClass 的属性和方法。
- CtMethod: 用来创建类的方法
- CtField: 用于创建字段的对象
3.2 创建一个类
public static void main(String[] args) throws NotFoundException, CannotCompileException, InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException, IOException {
// 1. 获得classPool 创建 ccClass
ClassPool classPool = ClassPool.getDefault();
CtClass ctClass = classPool.makeClass("aop.JavassistMakeClass");
ctClass.setSuperclass(classPool.get("java.lang.Object"));
// 2. 为ctClass 添加 sayHi方法
CtMethod sayHiMethod = new CtMethod(CtClass.voidType, "sayHi", new CtClass[0], ctClass);
sayHiMethod.setBody("{System.out.println(\" say hi \");}");
ctClass.addMethod(sayHiMethod);
// 3. 加载到jvm中
ctClass.writeFile();
Class<?> aClass = ctClass.toClass();
Object o = aClass.newInstance();
Method sayHi = aClass.getMethod("sayHi");
sayHi.invoke(o);
}
3.3 动态修改一个类
public static void main(String[] args) throws NotFoundException, CannotCompileException, InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException, IOException {
// 0. 先加载一个SimpleJava
SimpleJava simpleJava = new SimpleJava();
// 1. 获得classPool 获得 simpleJavaClass
ClassPool classPool = ClassPool.getDefault();
CtClass ctClass = classPool.getCtClass("aop.SimpleJava");
// 2. 获得方法
CtMethod sayHello = ctClass.getDeclaredMethod("sayHello");
sayHello.insertBefore("System.out.println(\"say hi\");\r\n");
ctClass.writeFile();
ctClass.writeFile(); // 写入磁盘
Class<?> clazz = ctClass.toClass(); // 动态加载类
Object obj = clazz.newInstance();
clazz.getMethod("sayHello").invoke(obj);
}
但是这边运行的时候会出现一个错误。
Caused by: java.lang.ClassFormatError: loader (instance of sun/misc/Launcher$AppClassLoader): attempted duplicate class definition for name: “aop/SimpleJava”
也就是javassist不能这么简单地修改运行的class文件,不然在defineClass的时候,因为全路径是相同的,因此会出现重复定义了。
3.4 通过指令修改class
在3.3 中我们使用了beforeInsert ,就是在方法前进行插入,当然我们也可以修改对应的方法的代码。这样就需要利用到class字节码的javap -c中的code区域的指令集,也就是说我们方法在运行的时候,会被编译成指令进行操作,因此我们可以修改对应的指令来改变行为。
四、Instrument库动态修改class
Instrument 是 Java 中的一个 API,用于在运行时修改字节码并对 Java 类进行重定义、重新转换等操作,但它本身并不提供创建新类的方法。它提供了一组方法,可以让开发人员在不停止 JVM 的情况下修改已加载的类,并使得新的代码与老代码兼容。
使用 Instrument API 可以实现很多有趣的功能,例如:
在运行时对程序的字节码进行增强或修补,以增加程序的功能或改善性能;
在运行时添加或删除类的字段、方法或接口,以满足特定的需求;
在运行时动态生成新的类,并将其加载到 JVM 中
在实现instrument只需要在代理类实现ClassFileTransformer中的transform方法,并且返回新的class文件。
public class InstrumentTest implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws Exception {
if(!className.equals("aop.SimpleJava")){
return null; // 不对其他类进行操作
}
// 1. 获得classPool 重载类
ClassPool classPool = ClassPool.getDefault();
CtClass ctClass = classPool.makeClass("aop.SimpleJava");
ctClass.setSuperclass(classPool.get("java.lang.Object"));
// 2. 为ctClass 添加 sayHi方法
CtMethod sayHiMethod = new CtMethod(CtClass.voidType, "sayHi", new CtClass[0], ctClass);
sayHiMethod.setBody("{System.out.println(\" say hi \");}");
ctClass.addMethod(sayHiMethod);
return ctClass.toBytecode();
}
}
但是这里我们写出了Instrument的实现类,没有注入到jvm中,因此需要一个agent进行注入。
4.2 redefineClass 重新覆盖类
五、Agent注入Instrument
Agent被注入到jvm中,有两种方式,每一种方式的实现方式不一致,但是原理类似。
- 实现premain,在jvm启动时通过-javaagent 加载代理程序进行指定
- 实现agentmain , 在jvm运行时进行自动调用。
5.1 -agentlib方式
"javaagent"是Java提供的一种机制,用于在JVM启动时加载并运行Java代理程序。
具体来说,可以使用"-javaagent:agent.jar"命令行参数来指定要加载的Java代理程序,并在其中实现"premain"方法。在JVM启动时,会自动加载该代理程序,并调用其中的"premain"方法。在"premain"方法中,可以通过Instrumentation接口对应用程序的类进行重定义和增强等操作。
需要注意的是,通过"javaagent"加载的Java代理程序必须打包成一个独立的JAR文件,并且其中必须包含一个名为"premain"的公共静态方法,其签名必须为:
public static void premain(String agentArgs, Instrumentation inst);
- 实现instrument
public class LogInstrument implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if(!className.equals("com/nt/auth/controller/openApi/A")){
return null;
}
System.out.println("找到对应注入类,进行生成");
try {
// 使用指定的类加载器创建 ClassPath 对象
ClassPath classPath = new LoaderClassPath(loader);
ClassPool classPool = new ClassPool();
classPool.insertClassPath(classPath);
CtClass ctClass = classPool.get("com.nt.auth.controller.openApi.A");
System.out.println("生成对应类:" + ctClass);
CtMethod[] declaredMethods = ctClass.getDeclaredMethods();
System.out.println("declaredMethods:" + declaredMethods.length);
for(CtMethod ctMethod : declaredMethods){
System.out.println("ctMethod" + ctMethod);
// 判断是否有参数
CtClass[] parameterTypes = ctMethod.getParameterTypes();
System.out.println("parameterTypes" + parameterTypes);
if(parameterTypes.length != 0) {
System.out.println("有参数");
int index = 1;
for(CtClass param : parameterTypes){
ctMethod.insertBefore("System.out.println(\"注入对应日志代理,解析传入参数信息:\" + " + "$" + index + ".toString());\r\n");
index++;
}
} else {
System.out.println("没有参数");
ctMethod.insertBefore("System.out.println(\"注入对应日志代理,当前参数为0\");");
}
}
return ctClass.toBytecode();
} catch (Exception e){
return null;
}
}
}
- 实现preMain的agent
public class MyAgent {
public static void premain(String agentArgs, Instrumentation inst){
inst.addTransformer(new LogInstrument());
}
}
- 实现MANIFEST.MF
Premain-Class: com.agent.test.MyAgent
Can-Redefine-Classes: true
- 打成jar
5. 在目标应用中使用
-javaagent:agent.jar
6. 如果遇到错误,是使用maven打包,需要添加如下配置
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<manifestFile>${project.build.outputDirectory}/META-INF/MANIFEST.MF</manifestFile>
</archive>
</configuration>
</plugin>
</plugins>
</build>
- 演示,我在注入了一个日志提醒,打印出入参参数
ctMethod.insertBefore("System.out.println(\"注入对应日志代理,解析传入参数信息:\" + " + "$" + index + ".toString());\r\n")
8. 注意点
- transform.方法的入参的className的形式为 A/b/c ,不是a.b.c,不然会找不到
- 在进行classpool的时候,通过get(a.b.c)的形式
- 如果修改了已存在的class的方法,则不需要再执行ctClass.addMethod
- 如果打包后,成功启动了应用,但是不显示对应的注入点的代码,那是因为你的javassist代码写错了,写错了不会抛出异常,只会不执行。比如 需要一个打印的功能
ctMethod.insertBefore(“System.out.println(“注入对应日志代理,当前参数为0”)”);
上述代码存在错误,因为没有加“;”导致,代码出现异常,没有注入成功,但是也不会抛出异常。
5.2 通过agentmain的形式,在运行时注入
agentmain 注入是一种 Java 技术,它允许在运行时通过Java Agent将字节码注入到正在运行的 JVM 中的方法中。它可以用于实现一些特定的功能,比如性能监控、调试、代码注入等。
5.2.1 实现方式
- 编写agentmain
public class MyAgentMain {
public static void premain(String agentArgs, Instrumentation inst) throws ClassNotFoundException, UnmodifiableClassException {
inst.addTransformer(new LogInstrument());
}
// 入口方法
public static void agentmain(String agentArgs, Instrumentation inst) throws ClassNotFoundException, UnmodifiableClassException {
premain(agentArgs, inst);
}
}
- 在META-INF中METAFEST.MF
Manifest-Version: 1.0
Agent-Class: com.agent.test.MyAgentMain
Can-Retransform-Classes: true
-
打成jar
-
启动目标应用
-
获得应用进程
jps -v -
通过命令注入到jvm
jcmd <pid> java.lang.instrumentation.loadAgent /path/to/agent-loader.jar
- 通过java代码
public static void main(String[] args) throws AttachNotSupportedException, IOException, AgentLoadException, AgentInitializationException {
// 传入目标 JVM pid
VirtualMachine vm = VirtualMachine.attach("6785");
vm.loadAgent("/path/to/agent-loader.jar");
}
5.4 注意事项
- java字节码修改重载是需要添加如下代码
Class<?> aClass = Class.forName(“com.nt.auth.controller.openApi.xx”);
inst.retransformClasses(aClass);
这样就要人有问了,为啥premain不需要加,因为premain是使用-agentlib命令,在jvm启动的时候进行加载的,这样其他所有的class都是会去加载,而如果运行时注入对应的instrument则会对重新加载的class生效,如果想对之前的进行生效,则需要使用retransformClasses - 需要再MAINFEST.MF添加: Can-Retransform-Classes: true
- 需要在目标应用开启JPDA功能:(本地运行不需要)
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000