今天我们将介绍字节码相关的应用场景,首先要介绍的是如何对字节码做解析和修改,本文将会详细给大家介绍一个工业级字节码操作框架 ASM。

ASM

当我们需要对一个 class 文件做修改时,我们可以选择自己解析这个class 文件,在符合 Java 字节码规范的前提下进行字节码改造。如果你写过 class 文件的解析程序,会发现这个过程极其繁琐,更别说进行增加方法等操作了。

ASM 最开始是 2000 年 Eric Bruneton 在 INRIA(法国国立计算机及自动化研究院)读博士期间完成的一个作品。那个时候包含 java.lang.reflect.Proxy 包的 JDK 1.3 还没发布,ASM 被作为代码生成器,用来生成动态代理的代理类。经过多年的发展,ASM 在诸多框架中已经遍地开花,成为字节码操作领域事实上的标准。

简单的 API 背后 ASM 自动帮我们做了很多事情,比如维护常量池的索引,计算最大栈大小 max\_stack,局部变量表大小 max\_locals 等,除此之外还有下面这些优点:

  • 架构设计精巧,使用方便。
  • 更新速度快,支持最新的 Java 版本
  • 速度非常快,在动态代理 class 的生成和 class 的转换时,尽可能确保运行中的应用不会被 ASM 拖慢
  • 非常可靠、久经考验,已经有很多著名的开源框架都在使用,例如 cglib,、mybatis、fastjson 其它字节码操作框架在操作字节码的过程中生成大量的中间类和对象,耗费大量的内存且运行缓慢,ASM 使用了访问者(Visitor)设计模式,避免了创建和消耗大量的中间变量。

ASM 提供了两种生成和转换类的方法: 基于事件触发的 core API 和基于对象的 Tree API,这两种方式可以用 XML 解析的 SAX 和 DOM 方式来对照。

  • SAX 解析 XML 文件采用的是事件驱动,它不需要解析完整个文档,而是一边按内容顺序解析文档,如果解析时符合特定的事件则回调一些函数来处理事件。SAX运行时是单向的、流式的,解析过的部分无法在不重新开始的情况下再次读取,ASM 的 Core API 类似于这种方式。
  • DOM 解析方式则会将整个 XML 作为类似树结构的方式读入内存中以便操作及解析,ASM 的 Tree API 类似于这种方式。以下面的 XML 文件为例:
<Order>
    <Customer>Arthur</Customer>
    <Product>
        <Name>Birdsong Clock</Name>
        <Quantity>12</Quantity>
        <Price currency="USD">21.95</Price >
    </Product>
</Order>

对应的 SAX 和 DOM 解析方式的如下图所示:

Java 字节码操作框架——ASM_java

ASM 核心类介绍

Java 字节码操作框架——ASM_java_02

ClassReader

它是字节码读取和分析引擎,帮我们做了最苦最累的解析二进制的 class 文件字节码的活。采用类似于 SAX 的事件读取机制,每当有事件发生时,触发相应的 ClassVisitor、MethodVisitor 等做相应的处理。

ClassVisitor

它是一个抽象类,ClassReader 对象创建之后,调用 ClassReader.accept() 方法,传入一个 ClassVisitor 对象。ClassVisitor 在解析字节码的过程中遇到不同的节点时会调用不同的 visit() 方法,比如 visitSource, visitOuterClass, visitAnnotation, visitAttribute, visitInnerClass, visitField, visitMethod 和 visitEnd方法。在上述 visit 的过程中还会产生一些子过程,比如 visitAnnotation 会触发 AnnotationVisitor 的调用、visitMethod 会触发 MethodVisitor 的调用。正是在这些 visit 的过程中,我们得以有机会去修改各个子节点的字节码。

ClassVisitor 类中的 visit 方法必须按照以下的顺序被调用执行:

visit
[visitSource]
[visitOuterClass] 
(visitAnnotation | visitAttribute)*
(visitInnerClass | visitField | visitMethod)* 
visitEnd

visit 方法最先被调用,接着调用零次或一次 visitSource 方法,接着调用零次或一次 visitOuterClass 方法,再接下来按任意顺序调用任意多次 visitAnnotation 和 visitAttribute 方法,再接下来按任意顺序调用任意多次 visitInnerClass、visitField、visitMethod 方法,visitEnd 最后被调用。

Java 字节码操作框架——ASM_ASM_03

ClassWriter

这个类是 ClassVisitor 抽象类的一个实现类,其之前的每个 ClassVisitor 都可能对原始的字节码做修改,ClassWriter 的 toByteArray 方法则把最终修改的字节码以 byte 数组的形式返回

这三个核心类的关系如下图

Java 字节码操作框架——ASM_ASM_04

一个最简单的用法如下面的代码所示:

public class FooClassVisitor extends ClassVisitor {
    ...
    // visitXXX() 函数
    ...
}

ClassReader cr = new ClassReader(bytes);
ClassWriter cw = new ClassWriter(cr,
        ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES);
ClassVisitor cv = new FooClassVisitor(cw);
cr.accept(cv, 0);

上面的代码中,ClassReader 负责读取类文件字节数组,accept 调用之后 ClassReader 会把解析字节码过程的事件源源不断的通知给 ClassVisitor 对象调用不同的 visit 方法,ClassVisitor 可以在这些 visit 方法中对字节码进行修改,ClassWriter 可以生成最终修改过的自己字节码。

ASM 操作字节码案例

接下面我们用几个简单的例子来演示 ASM 各个核心类操作字节码的案例。

访问类的方法和字段

ASM 的 visitor 设计模式可以很方便的用来访问类文件中我们感兴趣的部分,比如类文件的字段和方法列表,有下面的类:

public class MyMain {
    public int a = 0;
    public int b = 1;
    public void test01() {
    }
    public void test02() {
    }
}

使用 javac 编译为 class 文件,可以用下面的 ASM 代码来输出类的方法和字段列表:

byte[] bytes  = getBytes(); // MyMain.class 文件的字节数组
ClassReader cr = new ClassReader(bytes);
ClassWriter cw = new ClassWriter(0);
ClassVisitor cv = new ClassVisitor(ASM5, cw) {
    @Override
    public FieldVisitor visitField(int access, String name, String desc, String signature, Object value) {
        System.out.println("field: " + name);
        return super.visitField(access, name, desc, signature, value);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        System.out.println("method: " + name);
        return super.visitMethod(access, name, desc, signature, exceptions);
    }
};
cr.accept(cv, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG);

输出结果:

field: a
field: b
method: <init>
method: test01
method: test02

值得注意的是 ClassReader 类 accept 方法的第二个参数 flags,这个参数是一个比特掩码(bit-mask),可以选择组合的值如下:

  • SKIP\_DEBUG:跳过类文件中的调试信息,比如行号信息(LineNumberTable)等
  • SKIP\_CODE:跳过方法体中的 Code 属性(方法字节码、异常表等)
  • EXPAND\_FRAMES:展开 StackMapTable 属性,
  • SKIP\_FRAMES:跳过 StackMapTable 属性 前面有提到 ClassVisitor 是一个抽象类,我们可以选择关心的事件进行处理,比如例子中的覆写了 visitField 和 visitMethod 方法,仅对字段和方法进行处理,对于不感兴趣的事件可以选择不覆写或者返回 null 值,这样 ASM 就知道可以跳过对应的解析事件了。

使用 Tree Api 的方式也可以实现同样的效果

byte[] bytes = getBytes();

ClassReader cr = new ClassReader(bytes);
ClassNode cn = new ClassNode();
cr.accept(cn, ClassReader.SKIP_DEBUG | ClassReader.SKIP_CODE);

List<FieldNode> fields = cn.fields;
for (int i = 0; i < fields.size(); i++) {
    FieldNode fieldNode = fields.get(i);
    System.out.println("field: " + fieldNode.name);
}
List<MethodNode> methods = cn.methods;
for (int i = 0; i < methods.size(); ++i) {
    MethodNode method = methods.get(i);
    System.out.println("method: " + method.name);
}
ClassWriter cw = new ClassWriter(0);
cr.accept(cn, 0);
byte[] bytesModified = cw.toByteArray();
新增一个字段

在实际字节码转换中,经常会需要给类新增一个字段存储额外的信息,在 ASM 中给类新增一个字段非常简单,以下面的 MyMain 类为例,使用 javac 编译为 class 文件。

public class MyMain {
}

那么问题来了,在 ClassVisitor 的哪个方法里面进行添加字段的操作呢?由前面介绍的调用顺序可知,visitField 调用时机只能在 visitInnerClass、visitField、visitMethod、visitEnd 这四种方法中选择,又因为 visitInnerClass、visitField 不一定都会被调用到,且它们可能被调用多次,因此放在 visitEnd 方法中进行处理比较恰当。

使用下面的代码可以给 MyMain 新增一个 String 类型的 xyz 字段。

byte[] bytes = FileUtils.readFileToByteArray(new File("./MyMain.class"));
ClassReader cr = new ClassReader(bytes);
ClassWriter cw = new ClassWriter(0);
ClassVisitor cv = new ClassVisitor(ASM5, cw) {
    @Override
    public void visitEnd() {
        super.visitEnd();
        FieldVisitor fv = cv.visitField(Opcodes.ACC_PUBLIC, "xyz", "Ljava/lang/String;", null, null);
        if (fv != null) fv.visitEnd();
    }
};
cr.accept(cv, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG);
byte[] bytesModified = cw.toByteArray();
FileUtils.writeByteArrayToFile(new File("./MyMain2.class"), bytesModified);

使用 javap 查看 MyMain2 的字节码,可以看到已经多了一个类型为String 的 xyz 变量了。

...
public java.lang.String xyz;
descriptor: Ljava/lang/String;
flags: ACC_PUBLIC
...
新增方法

在这个例子中,同样使用 MyMain 类为例,给这个类新增一个 xyz 方法。

public void xyz(int a, String b) {

}

新增方法需要调用 visitMethod 方法,根据前面的调用顺序来看,同 visitField 一样,visitMethod 调用时机只能在 visitInnerClass、visitField、visitMethod、visitEnd 这四种方法中选择,这里选择 visitEnd 方法。根据第一章的内容可以知道 xyz 方法的签名为 (ILjava/lang/String;)V

byte[] bytes = FileUtils.readFileToByteArray(new File("./MyMain.class"));
ClassReader cr = new ClassReader(bytes);
ClassWriter cw = new ClassWriter(0);
ClassVisitor cv = new ClassVisitor(ASM5, cw) {
    @Override
    public void visitEnd() {
        super.visitEnd();
        MethodVisitor mv = cv.visitMethod(Opcodes.ACC_PUBLIC, "xyz", "(ILjava/lang/String;)V", null, null);
        if (mv != null) mv.visitEnd();
    }
};
cr.accept(cv, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG);
byte[] bytesModified = cw.toByteArray();
FileUtils.writeByteArrayToFile(new File("./MyMain2.class"), bytesModified);

使用 javap 查看生成的 MyMain2 类,确认 xyz 方法已经生成:

...
public void xyz(int, java.lang.String);
descriptor: (ILjava/lang/String;)V
flags: ACC_PUBLIC
...
移除方法和字段

前面介绍了利用 ASM 给 class 文件新增方法和字段,接下来介绍如何删掉方法和字段,假设有 MyMain 类代码如下,下面介绍如何删掉 abc 字段和 xyz 方法。

public class MyMain {
    private int abc = 0;
    private int def = 0;
    public void foo() {
    }
    public int xyz(int a, String b) {
        return 0;
    }
}

如果如果仔细观察 ClassVisitor 类的 visit 方法,会发现visitField、visitMethod 等方法是有返回值的,如果这些方法直接返回 null,效果是这些字段、方法从类中被移除。

byte[] bytes = FileUtils.readFileToByteArray(new File("./MyMain.class"));
ClassReader cr = new ClassReader(bytes);
ClassWriter cw = new ClassWriter(0);
ClassVisitor cv = new ClassVisitor(ASM5, cw) {
    @Override
    public FieldVisitor visitField(int access, String name, String desc, String signature, Object value) {
        if ("abc".equals(name)) {
            return null;
        }
        return super.visitField(access, name, desc, signature, value);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        if ("xyz".equals(name)) {
            return null;
        }
        return super.visitMethod(access, name, desc, signature, exceptions);
    }
};

cr.accept(cv, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG);
byte[] bytesModified = cw.toByteArray();
FileUtils.writeByteArrayToFile(new File("./MyMain2.class"), bytesModified);

同样使用 javap 查看 MyMain2 的字节码,可以看到 abc 字段和 xyz 方法已经被移除,只剩下 def 字段和 foo 方法了。

小结

这篇文章我们主要讲解了 ASM 字节码操作框架,一起来回顾一下要点:

  • 第一,ASM 是一个久经考验的工业级字节码操作框架。
  • 第二,ASM 的三个核心类 ClassReader、ClassVisitor、ClassWriter。ClassReader 对象创建之后,调用 ClassReader.accept() 方法,传入一个 ClassVisitor 对象。ClassVisitor 在解析字节码的过程中遇到不同的节点时会调用不同的 visit() 方法。ClassWriter 负责把最终修改的字节码以 byte 数组的形式返回。

转载: https://mp.weixin.qq.com/s/pP6Bt3p82-rJPWJ2lspWyg