科普文:Java基础系列之【java框架基础:字节码增强技术框架ASM】

257 篇文章 0 订阅
202 篇文章 1 订阅

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

ASM

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

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

实现 Evaluate 要解决的第一个问题就是怎么改变原有代码的行为,它的实现在 Java 里被称为动态字节码技术。

字节码

通过javap -v Account.class能够查看Class文件的详细信息,为了方便查看,我们做了删减,它看起是这样的。

整个Class文件的内容包含很多内容,这里我们只列出其中的核心部分

  1. 类信息,包括类的访问标志(public、abstract)、名称、父类、接口、版本(编译.java文件的JDK版本)
  2. 常量池,包括类/方法/字段的引用和名称,以及代码中使用的字面常量等
  3. 类属性,通过类属性提供,如SourceFile表示源文件名称,RuntimeInvisibleAnnotations表示运行时注解、外部引用等
  4. 内部类,通过属性InnerClasses提供
  5. 字段,常量池保存字段引用Fieldref,记录了字段所属的类、字段类型和名称
  6. 方法,常量池保存方法引用Methodref,记录了方法所属的类、名称、参数和描述符等等
  7. 字节码,通过方法引用关联,能找到这个方法内的字节码,本地变量表(LocalVariableTable)、异常表(ExceptionTable)、注解信息(RuntimeVisibleAnnotations)等等

动态生成字节码

我们知道,我们编写的 Java 代码都是要被编译成字节码后才能放到 JVM 里执行的,而字节码一旦被加载到虚拟机中,就可以被解释执行。

字节码文件(.class)就是普通的二进制文件,它是通过 Java 编译器生成的。而只要是文件就可以被改变,如果我们用特定的规则解析了原有的字节码文件,对它进行修改或者干脆重新定义,这不就可以改变代码行为了么。

Java 生态里有很多可以动态生成字节码的技术,像 BCEL、Javassist、ASM、CGLib 等,它们各有自己的优势。有的使用复杂却功能强大、有的简单确也性能些差。

ASM 框架

ASM 是它们中最强大的一个,使用它可以动态修改类、方法,甚至可以重新定义类,连 CGLib 底层都是用 ASM 实现的。

当然,它的使用门槛也很高,使用它需要对 Java 的字节码文件有所了解,熟悉 JVM 的编译指令。虽然我对 JVM 的字节码语法不熟,但有大神开发了可以在 IDEA 里查看字节码的插件:ASM Bytecode Outline ,在要查看的类文件里右键选择 Show bytecode Outline 即可以右侧的工具栏查看我们要生成的字节码。对照着示例,我们就可以很轻松地写出操作字节码的 Java 代码了。

而切到 ASMified 标签栏,我们甚至可以直接获取到 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 解析方式的如下图所示:

 

ASM工作模式

ASM提供了字节码的分析、生成和修改能力,它的能力当然是基于它对字节码的理解之上构建的。ASM支持两类API,一类是基于事件的(类型XML解析的SAX),一类是基于语法树的(类似于XML的DOM)。

事件模型的性能会更好一些,这里我们主要讲解和使用事件模型。

事件模型将ASM抽象成3个核心组件,ClassReader、Visitor(ClassVisitor、MethodVisitor等)、ClassWriter,整个字节码的处理过程可以想象成这样一张处理的数据流图,整个处理过程分为3步:

  1. 生成事件流,图中CR节点,表示ClassReader,用于读取类定义解析并触发事件
  2. 过滤和转换,图中空白节点,被抽象为Visitor,常见的有ClassVisitor、MethodVisitor、FieldVisitor等,接收事件触发,过滤/修改事件传递给CW,同时它还支持生成新事件
  3. 终结操作符,图中CW节点,表示ClassWriter,起始ClassWriter也是Visitor接口的实现,不同的是它的visit方法会生成类的字节码,比如调用ClassWriter.visitMethod会在类中新增方法

 

ASM 核心类介绍

常用方法

在 ASM 的代码实现里,最明显的就是访问者模式,ASM 将对代码的读取和操作都包装成一个访问者,在解析 JVM 加载到的字节码时调用。

ClassReader 是 ASM 代码的入口,通过它解析二进制字节码,实例化时它时,我们需要传入一个 ClassVisitor,在这个 Visitor 里,我们可以实现 visitMethod()/visitAnnotation() 等方法,用以定义对类结构(如方法、字段、注解)的访问方法。

而 ClassWriter 接口继承了 ClassVisitor 接口,我们在实例化类访问器时,将 ClassWriter “注入” 到里面,以实现对类写入的声明。

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 最后被调用。

ClassWriter

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

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

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

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 方法了。

动态增加字节码

直接创建一个不存在的class文件,并执行这个class文件的main方法。

package com.zxx.study.base.reflect.asm1;

import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.reflect.Method;
/**
 * @author zhouxx
 * @create 2024-08-13 23:51
 */
public class HelloWorld extends ClassLoader {

    public static void main(String[] args) throws Exception {
        // 生成二进制字节码
        byte[] bytes = generate();
        // 输出字节码
        outputClazz(bytes);
        // 加载AsmHelloWorld
        Class<?> clazz = new HelloWorld().defineClass("com.zxx.study.base.reflect.asm1.AsmHelloWorld", bytes, 0, bytes.length);
        // 反射获取 main 方法
        Method main = clazz.getMethod("main", String[].class);
        // 调用 main 方法
        main.invoke(null, new Object[]{new String[]{}});
    }

    private static byte[] generate() {
        ClassWriter classWriter = new ClassWriter(0);
        // 定义对象头;版本号、修饰符、全类名、签名、父类、实现的接口
        classWriter.visit(Opcodes.V1_7, Opcodes.ACC_PUBLIC, "com/zxx/study/base/reflect/asm1/AsmHelloWorld", null, "java/lang/Object", null);
        // 添加方法;修饰符、方法名、描述符、签名、异常
        MethodVisitor methodVisitor = classWriter.visitMethod(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
        // 执行指令;获取静态属性
        methodVisitor.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        // 加载常量 load constant
        methodVisitor.visitLdcInsn("Hello World ASM!");
        // 调用方法
        methodVisitor.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
        // 返回
        methodVisitor.visitInsn(Opcodes.RETURN);
        // 设置操作数栈的深度和局部变量的大小
        methodVisitor.visitMaxs(2, 1);
        // 方法结束
        methodVisitor.visitEnd();
        // 类完成
        classWriter.visitEnd();
        // 生成字节数组
        return classWriter.toByteArray();
    }

    private static void outputClazz(byte[] bytes) {
        // 输出类字节码
        FileOutputStream out = null;
        try {
            String pathName = HelloWorld.class.getResource("/").getPath() + "AsmHelloWorld.class";
            out = new FileOutputStream(new File(pathName));
            System.out.println("ASM类输出路径:" + pathName);
            out.write(bytes);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (null != out) {
                try {
                    out.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

}

小结

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

  • 第一,ASM 是一个久经考验的工业级字节码操作框架。
  • 第二,ASM 的三个核心类 ClassReader、ClassVisitor、ClassWriter。ClassReader 对象创建之后,调用 ClassReader.accept() 方法,传入一个 ClassVisitor 对象。ClassVisitor 在解析字节码的过程中遇到不同的节点时会调用不同的 visit() 方法。ClassWriter 负责把最终修改的字节码以 byte 数组的形式返回。
  • 23
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

-无-为-

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值