字节码增强技术-ASM

985 阅读8分钟

概述

在Java中一般是用javac命令编译源代码为字节码文件,一个.java文件从编译到运行的示例如图所示: WX20230814-215953@2x.png

使用字节码的好处:一处编译,到处运行。java 就是典型的使用字节码作为中间语言,在一个地方编译了源码,拿着.class 文件就可以在各种计算机运行。

字节码增强技术就是一类对现有字节码进行修改或者动态生成全新字节码文件的技术。常见的字节码操作分为以下几类:

aop (1).png

优缺点如下:

字节码工具优点缺点
Java-proxy- 简单易用
- 原生支持
- 仅能代理接口或继承类
- 动态代理类需实现接口
ASM- 强大的字节码操作能力
- 高性能
- 学习曲线较陡
- 代码较复杂
AspectJ- 强大的 AOP 支持
- 高度集成
- 学习曲线较陡
- 需要特定编译器或处理器
JavaAssist- 提供高级别的 API
- 简化字节码操作
- 相对较简单
- 性能相对较低
CGLib- 可代理普通类
- 高性能
- 无法代理 final 类
- 生成代理类较大
ByteBuddy- 简洁的 API
- 高性能
- 功能全面
- 支持运行时生成和修改类
- 学习成本较高

ASM介绍

ASM是一种通用Java字节码操作和分析框架。它可以用于修改现有的class文件或动态生成class文件。

ASM is an all purpose Java bytecode manipulation and analysis framework. It can be used to modify existing classes or to dynamically generate classes, directly in binary form. ASM provides some common bytecode transformations and analysis algorithms from which custom complex transformations and code analysis tools can be built. ASM offers similar functionality as other Java bytecode frameworks, but is focused onperformance. Because it was designed and implemented to be as small and as fast as possible, it is well suited for use in dynamic systems (but can of course be used in a static way too, e.g. in compilers).

ASM的应用场景有AOP(Cglib就是基于ASM)、热部署、修改其他jar包中的类等。当然,涉及到如此底层的步骤,实现起来也比较麻烦。下面我们看一下ASM是如何编辑class字节码的。

ASM处理流程

目标类 class bytes -> ClassReader 解析 -> ClassVisitor 增强修改字节码 -> ClassWriter 生成增强后的 class bytes -> 通过 Instrumentation 解析加载为新的 Class。

image.png

ASM API

ASM API 提供了两种与 Java 类交互以进行转换和生成的方式:基于事件的方式和基于树的方式。

包名描述
org.objectweb.asm提供一个小巧且快速的字节码操作框架。
org.objectweb.asm.commons提供一些有用的类和方法适配器。
org.objectweb.asm.signature提供对类型签名的支持。
org.objectweb.asm.tree提供一个构造所访问的类的树表示的 ASM 访问者。
org.objectweb.asm.tree.analysis基于 asm.tree 包提供了静态代码分析的框架。
org.objectweb.asm.util提供对于编程和调试目的有用的 ASM 访问者。 基于事件的 API

核心API

ASM Core API可以类比解析XML文件中的SAX方式,不需要把这个类的整个结构读取进来,就可以用流式的方法来处理字节码文件。好处是非常节约内存,但是编程难度较大。然而出于性能考虑,一般情况下编程都使用Core API。

  • ClassReader:用于读取已经编译好的.class文件。
  • ClassWriter:用于重新构建编译后的类,如修改类名、属性以及方法,也可以生成新的类的字节码文件。
  • 各种Visitor类:如上所述,CoreAPI根据字节码从上到下依次处理,对于字节码文件中不同的区域有不同的Visitor,比如用于访问方法的MethodVisitor、用于访问类变量的FieldVisitor、用于访问注解的AnnotationVisitor等。为了实现AOP,重点要使用的是MethodVisitor

树形API

ASM Tree API可以类比解析XML文件中的DOM方式,把整个类的结构读取到内存中,缺点是消耗内存多,但是编程比较简单。TreeApi不同于CoreAPI,TreeAPI通过各种Node类来映射字节码的各个区域,类比DOM节点,就可以很好地理解这种编程方式。

利用ASM实现AOP

下面我们通过实践看下如何通过ASM实现增加方法,运行时修改方法:

例子

假设我们有一个类 MathUtils,其中有一个 add 方法,我们想要在方法执行前后添加日志。

public class MathUtils {
    public int add(int a, int b) {
        return a + b;
    }
}

为了利用ASM实现AOP,需要定义一个MathUtilsMethodVisitor类,用于对字节码的add方法进行修改

  
public class MathUtilsMethodAdapter extends ClassVisitor implements Opcodes {  
    public MathUtilsMethodAdapter(ClassVisitor cv) {  
        super(Opcodes.ASM4, cv);  
    }  
  
    @Override  
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {  
        if (cv != null) {  
            cv.visit(version, access, name, signature, superName, interfaces);  
        }  
    }  
  
    @Override  
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {  
  
        // 当方法名为add时进行修改
        if ("add".equals(name)) {  
            // 先得到原始的方法  
            MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);  
            MethodVisitor newMethod = null;  
            // 访问需要修改的方法  
            newMethod = new AsmMethodVisit(mv);  
            return newMethod;  
        }  
        if (cv != null) {  
            return cv.visitMethod(access, name, desc, signature, exceptions);  
        }  
        return null;  
    }  
}

定义AsmMethodVisit在进入方法时打印begin Entering method,返回时打印end Entering method

public class AsmMethodVisit extends MethodVisitor {  
    public AsmMethodVisit(MethodVisitor mv) {  
        super(Opcodes.ASM4, mv);  
    }  
  
    @Override  
    public void visitMethodInsn(int opcode, String owner, String name, String desc) {  
        super.visitMethodInsn(opcode, owner, name, desc);  
    }  
  
    @Override  
    public void visitCode() {  
        // 此方法在访问方法的头部时被访问到,仅被访问一次  
        // 此处可插入新的指令  
        super.visitCode();  
        mv.visitFieldInsn(org.springframework.asm.Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");  
        mv.visitLdcInsn("begin Entering method ");  
        mv.visitMethodInsn(org.springframework.asm.Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);  
    }  
  
    @Override  
    public void visitInsn(int opcode) {  
        // 此方法可以获取方法中每一条指令的操作类型,被访问多次  
        // 如应在方法结尾处添加新指令,则应判断:  
        if (opcode == Opcodes.IRETURN) {  
            // pushes the 'out' field (of type PrintStream) of the System class  
            mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");  
            // pushes the "Hello World!" String constant  
            mv.visitLdcInsn("end Entering method");  
            // invokes the 'println' method (defined in the PrintStream class)  
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V");  
        }  
        super.visitInsn(opcode);  
    }  
}

最后,加个测试类MathUtilsTest,使用 ASM 生成一个add1的新方法,并在运行add方法时修改字节码来增强 add 方法,实现执行前后增加日志

public class MathUtilsTest extends ClassLoader implements Opcodes {  
  
    public static void main(String args[]) throws Exception {  
        ClassReader cr = new ClassReader(MathUtils.class.getName());  
        ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);  
        ClassVisitor cv = new MathUtilsMethodAdapter(cw);  
        cr.accept(cv, Opcodes.ASM4);  

        // 新增加一个方法  
        MethodVisitor mw = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "add1", "([Ljava/lang/String;)V", null, null);  
        mw.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");  
        mw.visitLdcInsn("this is add method print!");  
        mw.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V");  
        mw.visitInsn(RETURN);  
        // this code uses a maximum of two stack elements and two local  
        // variables  
        mw.visitMaxs(0, 0);  
        mw.visitEnd();  

        byte[] code = cw.toByteArray();  
        MathUtilsTest loader = new MathUtilsTest();  
        Class<?> exampleClass = loader.defineClass(MathUtils.class.getName(), code, 0, code.length);  

        for (Method method : exampleClass.getMethods()) {  
            System.out.println(method);  
        }  

        System.out.println("***************************");  
        // 调用add方法,方法执行前后打印日志  
        Object result = exampleClass.getMethods()[1].invoke(exampleClass.newInstance(), 1, 2);  
        System.out.println(result);  
        // 将生成的MathUtils.class输出到磁盘,方便我们观察
        FileOutputStream fos = new FileOutputStream("/Users/xx/MathUtils.class");  
        fos.write(code);  
        fos.close();  
    }  
}

上述程序运行结果如下:

image.png 反编译生成的MathUtils.class,可以看到如下结果:

image.png

步骤总结

利用上面这个类实现对字节码的修改。详细解读其中的代码,对字节码做修改的步骤是:

  • 首先通过MathUtilsMethodAdapter类中的visitMethod方法,判断当前字节码读到哪一个方法了。跳过构造方法 <init> 后,将需要被增强的方法交给类AsmMethodVisit来进行处理。
  • 接下来,进入类AsmMethodVisit中的visitCode方法,它会在ASM开始访问某一个方法的Code区时被调用,重写visitCode方法,将AOP中的前置逻辑就放在这里。 类AsmMethodVisit继续读取字节码指令,每当ASM访问到无参数指令时,都会调用AsmMethodVisit中的visitInsn方法。我们判断了当前指令是否为无参数的“IRETURN”指令,如果是就在它的前面添加一些指令,也就是将AOP的后置逻辑放在该方法中。
  • 综上,重写AsmMethodVisit中的两个方法,就可以实现AOP了,而重写方法时就需要用ASM的写法,手动写入或者修改字节码。通过调用methodVisitor的visitXXXXInsn()方法就可以实现字节码的插入,XXXX对应相应的操作码助记符类型,比如mv.visitLdcInsn(“end Entering method”)对应的操作码就是ldc “end”,即将字符串“end”压入栈。

ASM工具

利用ASM手写字节码时,需要利用一系列visitXXXXInsn()方法来写对应的助记符,所以需要先将每一行源代码转化为一个个的助记符,然后通过ASM的语法转换为visitXXXXInsn()这种写法。第一步将源码转化为助记符就已经够麻烦了,不熟悉字节码操作集合的话,需要我们将代码编译后再反编译,才能得到源代码对应的助记符。第二步利用ASM写字节码时,如何传参也很令人头疼。ASM社区也知道这两个问题,所以提供了工具ASM ByteCode Outline

image.png

总结

Java ASM是一个强大的 Java 字节码操作框架,用于生成、修改和分析 Java 类的字节码。它允许您在不修改源代码的情况下,通过编程方式操作字节码,从而实现动态代码生成、AOP(面向切面编程)、字节码优化、代码注入等高级功能。ASM 可以在运行时或编译时进行字节码操作,它被广泛用于许多 Java 应用和框架中,如 Spring、Hibernate 等。