# JVM 基础 - 字节码增强

字节码增强技术就是一类对现有字节码进行修改或者动态生成全新字节码文件的技术。

# ASM

ASM 可以直接生成 .class 字节码文件,也可以在类被加载入 JVM 之前动态修改类行为。ASM 框架是 JDK 内部自带的,最基本的就是通过 ClassWriter 对象编辑类的字节码文件。

public class Main {
    public static void main(String[] args) {
        ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
    }
}

在学习 ASM 前,请先学习一下访问者模式

字节码文件的结构是由 JVM 固定的,适合利用访问者模式对字节码文件进行修改。

# 核心 API

  • ClassReader :用于读取已经编译好的.class 文件。

  • ClassWriter :用于重新构建编译后的类,如修改类名、属性以及方法,也可以生成新的类的字节码文件。

  • 各种 Visitor 类:如上所述,CoreAPI 根据字节码从上到下依次处理,对于字节码文件中不同的区域有不同的 Visitor,比如用于访问方法的 MethodVisitor 、用于访问类变量的 FieldVisitor 、用于访问注解的 AnnotationVisitor 等。

API 文档:https://tool.oschina.net/apidocs/apidoc?api=asm

# 方法名和描述符

这里补充一下类文件中方法名和描述符的表示。

public class Test {
    private int m;
    public int inc() {
        return m + 1;
    }
}

使用 jclasslib 插件查看一下:

可以看到,方法名在字节码文件中被 <> 包围,描述符其实就是返回值和参数列表,也都被 <> 包围。 <()I> 表示:

  • 括号里的是参数,后面的是返回值。
  • I 表示 int ,也就是说,该方法返回 int ,如果是 void ,这里就会是 V

这样我们就固定了方法的方法名和参数列表以及返回值。从图中可以看到, inc 方法上面还有个 <init> 方法,那就是构造方法。

如果方法参数中加入了对象:

public int inc(String s,Object o) {
        return m + 1;
}
// 描述符:<(Ljava/lang/String;Ljava/lang/Object;) I>
public int inc(char[][] a) {
    return m+1;
}
// 描述符:<([[C) I>

L 开头表示是一个对象,参数之间要用 ; 隔开。详细的读者可以自己写一些方法来看一下具体的效果。

# 基本使用

ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);

构造函数里的参数与操作数栈和局部临时变量表有关,不想琢磨,用 COMPUTE_MAXS 即可。

首先指定一下类的基本信息:

public class Main {
    public static void main(String[] args) {
        ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
        // 因为这里用到的常量比较多,所以说直接一次性静态导入:
        // import static jdk.internal.org.objectweb.asm.Opcodes.*;
        writer.visit(V1_8, ACC_PUBLIC,"com/JVM/Main", null,
                     "java/lang/Object",null);
        // 第三个参数,需要根据自己的 Main 路径填写
    }
}

设定的基本信息依次是:版本 --Java8,修饰符 -- ACC_PUBLIC ,类名 -- 要携带包名,标签 -- null ,父类 -- Object

将其保存,然后写入到自己生成的字节码文件当中:

public class Main {
    public static void main(String[] args) {
        ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
        writer.visit(V1_8, ACC_PUBLIC,"com/test/Main", null,
                     "java/lang/Object",null);
        // 调用 visitEnd 表示结束编辑
        writer.visitEnd();
        try(FileOutputStream stream = new FileOutputStream("./Main.class")){
            stream.write(writer.toByteArray());  
            // 直接通过 ClassWriter 将字节码文件转换为 byte 数组,并保存到根目录下
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

结果为:

从 class 文件可知, Main.class 没有构造方法,所以可以添加一个

// 通过 visitMethod 方法可以添加一个新的方法
// 放在 writer.visitEnd (); 前面即可
writer.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);

再次编译,发现存在构造方法,之前讲了字节码文件中的方法名和描述符,读者可以自行修改描述符感受一下效果。

但是 Main 继承了 Object 类,所以在子类构造方法中应该调用父类的构造方法。也就是说,沃恩需要在方法中添加父类构造方法调用指令:

public com.test.Main();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1             // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 11: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/test/Main;

所以我们开始访问代码( visitCode() ),也就是对方法进行详细 i 编辑:

// 通过 MethodVisitor 接收返回值,进行进一步操作
MethodVisitor visitor = writer.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
// 开始编辑代码
visitor.visitCode();
// Label 用于存储行号,当前代码写到哪行了,l1 得到的就是多少行
Label l1 = new Label();
// 添加源代码行数对应表 (字节码中的 LineNumberTable)
visitor.visitListNumber(11,l1);
// 不同类型的指令需要不同方法调用
visitor.visitVarInsn(ALOAD,0);
visitor.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
visitor.visitInsn(RETURN);
Label l2 = new Label();
visitor.visitLabel(l2);
// 添加本地变量表 (字节码中的 LocalVariableTable),这里是 this 关键字
visitor.visiLocalVariable("this",,"Lcom/JVM/Main;",null,l1, l2, 0);
// 最后设定最大栈深度和本地变量数
visitor.visitMaxs(1, 1);
// 结束编辑
visitor.visitEnd();

至此构造方法编辑完成,可以看到是非常麻烦的一件事,其实哪怕只是完成一个

public static void main(String[] args) {
    int a = 10;
    System.out.println(a);
}

都是非常复杂的一件事:

// 开始安排 main 方法
MethodVisitor v2 = writer.visitMethod(ACC_PUBLIC | ACC_STATIC, "main", 
                                      "([Ljava/lang/String;)V", null, null);
v2.visitCode();
// 记录起始行信息
Label l3 = new Label();
v2.visitLabel(l3);
v2.visitLineNumber(13, l3);
// 首先是 int a = 10 的操作,执行指令依次为:
//bipush 10     将 10 推向操作数栈顶
//istore_1      将操作数栈顶元素保存到 1 号本地变量 a 中
v2.visitIntInsn(BIPUSH, 10);
v2.visitVarInsn(ISTORE, 1);
Label l4 = new Label();
v2.visitLabel(l4);
// 记录一下行信息
v2.visitLineNumber(14, l4);
// 这里是获取 System 类中的 out 静态变量(PrintStream 接口),用于打印
v2.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
// 把 a 的值取出来
v2.visitVarInsn(ILOAD, 1);
// 调用接口中的抽象方法 println
v2.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(I)V", false);
// 再次记录行信息
Label l6 = new Label();
v2.visitLabel(l6);
v2.visitLineNumber(15, l6);
v2.visitInsn(RETURN);
Label l7 = new Label();
v2.visitLabel(l7);
// 最后是本地变量表中的各个变量
v2.visitLocalVariable("args", "[Ljava/lang/String;", null, l3, l7, 0);
v2.visitLocalVariable("a", "I", null, l4, l7, 1);
v2.visitMaxs(1, 2);
// 终于 OK 了
v2.visitEnd();

写之前就需要先写好 Java 程序进行反编译,再照着字节码一行一行使用 MathodVisitor 编辑。

# 例子:实现 AOP

AOP-- 面向切面编程,Spring 核心之一,如果你不了解 AOP,就暂时理解为,在一个执行流程(模块)中,加入一个切面,通过这个切面的方法可以被切面加入一些操作,如记录日志啥的。

我们希望通过 ASM 来实现 AOP:在方法调用前后增加逻辑(也叫做前置通知和后置通知)。

具体代码实现感兴趣的读者可以参考:直接利用 ASM 实现 AOP

# 参考

https://www.yuque.com/qingkongxiaguang/javase/keopmg

https://pdai.tech/md/java/jvm/java-jvm-class-enhancer.html