ASM字节码处理工具原理及实践

1. ASM简介

我们知道程序的分析。生成和转换是很有用的技术,可以用于很多场景。ASM作为一个Java字节码处理工具,它被设计用于处理已编译的Java类。ASM不是生成和转变已编译的Java类的唯一工具,但它是最新且最有效的工具之一。特点是体积小,速度快,开源。它的作用和JVM的动态性相辅相成,在许多场景下有很好的表现,例如相比于AOP的编译期织入,ASM的操作速度更快。

2. ASM导入

2.1 maven导入
<properties>
    <asm.version>9.1</asm.version>
</properties>

<dependencies>
    <dependency>
        <artifactId>asm</artifactId>
        <groupId>org.ow2.asm</groupId>
        <version>${asm.version}</version>
    </dependency>
    <dependency>
        <artifactId>asm-commons</artifactId>
        <groupId>org.ow2.asm</groupId>
        <version>${asm.version}</version>
    </dependency>
</dependencies>
2.2 gradle导入
dependencies {
    implementation 'org.ow2.asm:asm:9.1'
    implementation 'org.ow2.asm:asm-commons:9.1'
}

3. 字节码结构

3.1 Class字节码文件结构
类型名称说明长度数量
魔数u4magic魔数,识别 Class 文件格式4 字节1
版本号u2minor_version副版本号(小版本)2 字节1
u2major_version主版本号(大版本)2 字节1
常量池集合u2constant_pool_count常量池计数器2 字节1
cp_infoconstant_pool常量池表n 字节constant_pool_count - 1
访问标识u2access_flags访问标识2 字节1
索引集合u2this_class类索引2 字节1
u2super_class父类索引2 字节1
u2interfaces_count接口计数器2 字节1
u2interfaces接口索引集合2 字节interfaces_count
字段表集合u2fields_count字段计数器2 字节1
field_infofields字段表n 字节fields_count
方法表集合u2methods_count方法计数器2 字节1
method_infomethods方法表n 字节methods_count
属性表集合u2attributes_count属性计数器2 字节1
attribute_infoattributes属性表n 字节attributes_count
3.2 Class 文件数据类型
数据类型定义说明
无符号数无符号数可以用来描述数字、索引引用、数量值、按照 utf-8 编码构成的字符串值其中无符号数属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节、8 个字节
表是由多个无符号数或其他表构成的复合数据结构所有的表都以 _info 结尾,由于表没有固定长度,所以通常会在其前面加上个数说明

3.3 魔数

  • 每个 Class 文件开头的4个字节的无符号整数称为魔数(Magic Number)
  • 它的唯一作用是确定这个文件是否为一个能被虚拟机接受的有效合法的Class文件。即:魔数是Class文件的标识符。
  • 魔数值固定为0xCAFEBABE,不会改变。
  • 如果一个Class文件不以0xCAFEBABE开头,虚拟机在进行文件校验的时候就会直接抛出以下错误:
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.ClassFormatError: Incompatible magic value 1885430635 in class file StringTest
  • 使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。
3.3 版本号对应关系
  • 紧接着魔数的4个字节存储的是Class文件的版本号。同样也是4个字节。第5个和第6个字节所代表的含义就是编译的副版本号minor_version,而第7个和第8个字节就是编译的主版本号major_version。
  • 它们共同构成了class文件的格式版本号。譬如某个Class文件的主版本号为M,副版本号为m,那么这个Class文件的格式版本号就确定为M.m;

版本号和Java编译器的对应关系如下表:

主版本副版本编译器版本ASM版本
4531.1V1_1
4601.2V1_2
4701.3V1_3
4801.4V1_4
4901.5V1_5
5001.6V1_6
5101.7V1_7
5201.8V1_8
5301.9V9
5401.10V10

后续的依照表规律类推,Java的版本号从45开始,JDK1.1之后的每个JDK大版本发布主版本号向上加1。

不同版本的Java编译器编译的Class文件的版本是不同的。高版本的JVM可以执行低版本编译器生成的Class文件,否则不行,并抛出 java.lang.UnsupportedClassVersionError 异常。

3.4 类型描述符

描述符的作用用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)以及返回值。对应关系如下:

Java类型类型描述符
booleanZ
charC
byteB
shortS
intI
floatF
longJ
doubleD
ObjectLjava/lang/Object;
int[][I
Object[][][[Ljava/lang/Object;
3.5 方法描述符

方法描述符是一个类型描述符的列表,它在单个字符串中描述了一个方法的参数类型和返回类型。传入参数由括号包围,紧接着为返回类型的类型描述符。例如如下几种示例;

源文件中的方法声明方法描述符
void m(int i, float f)(IF)V
int m(Object o)(Ljava/lang/Object;)I
int[] m(int i, String s)(ILjava/lang/String;)[I
Object m(int[] i)([I)Ljava/lang/Object;
3.6 访问标识符

标志名称

标志值

含义

ACC_PUBLIC

0x0001

字段是否为 public

ACC_PRIVATE

0x0002

字段是否为 private

ACC_PROTECTED

0x0004

字段是否为 protected

ACC_STATIC

0x0008

字段是否为 static

ACC_FINAL

0x0010

字段是否为 final

ACC_VOLATILE

0x0040

字段是否为 volatile

ACC_TRANSTENT

0x0080

字段是否为 transient

ACC_SYNCHETIC

0x1000

字段是否为由编译器自动产生

ACC_ENUM

0x4000

字段是否为 enum

当有多个访问标识符来修饰时,采用加法结合(或者 | 或运算),比如 public final 修饰的类,其标记为 ACC_PUBLIC | ACC_FINAL,其值为 0x0011,对应的十进制为 17 。

补充

  • 带有 ACC_INTERFACE 标志的 class 文件表示的是接口而不是类
    • 如果一个class文件被设置了 ACC_INTERFACE 标志,同时也得设置 ACC_ABSTRACT标志。同时它不能再设置ACC_FINAL、ACC_SUPER 或ACC_ENUM标志。
    • 如果没有设置ACC_INTERFACE标志,那么这个class文件可以具有上表中除ACC_ANNOTATION外的其他所有标志。当然,ACC_FINAL和ACC_ABSTRACT这类互斥的标志除外。这两个标志不得同时设置。
  • ACC_SUPER标志用于确定类或接口里面的invokespecial指令使用的是哪一种执行语义,Java虚拟机默认为每个class文件都设置了 ACC_SUPER 标志
  • ACC_SYNTHETIC标志意味着该类或接口是由编译器生成的,而不是由源代码生成的。
  • 注解类型必须设置ACC_ANNOTATION标志。如果设置了ACC_ANNOTATION标志,那么也必须设置ACC_INTERFACE标志。
  • ACC_ENUM标志表明该类或其父类为枚举类型。
3.6 常量池:存放所有常量

在版本号之后,紧跟着的是常量池的数量,以及若干个常量池表项。

常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的无符号数,代表常量池容量计数值(constant_pool_count)。与Java中语言习惯不一样的是,这个容量计数是从1而不是0开始的。

类型名称数量
u2(无符号数)constant_pool_count1
cp_info(表)constant_poolconstant_pool_count - 1

数据类型

定义

说明

无符号数

无符号数可以用来描述数字、索引引用、数量值或按照utf-8编码构成的字符串值。

其中无符号数属于基本的数据类型。 以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节

表是由多个无符号数或其他表构成的复合数据结构。

所有的表都以“_info”结尾。 由于表没有固定长度,所以通常会在其前面加上个数说明。

由上表可见,Class文件使用了一个前置的容量计数器(constant_pool_count),再加若干个连续的数据项(constant_pool)的形式来描述常量池内容。我们把这一系列连续常量池数据称为常量池集合。

常量池表项中,用于存放编译时期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

3.7 常量池计数器:constant_pool_count
  • 由于常量池的数量不固定,时长时短,所以需要放置两个字节来表示常量池容量计数值。
  • 常量池容量计数值(u2类型):从1开始,表示常量池中有多少项常量。即constant_pool_count=1表示常量池中有0个常量项,例如Demo的值为:

其值为0x0016,转换为十进制就是22。 需要注意的是,这实际上只有21项常量。索引为范围是1一21。为什么呢? 通常我们写代码时都是从0开始的,但是这里的常量池却是从1开始,因为它把第0项常量空出来了。这是为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况可用索引值0来表示。

3.8 常量池表:constant_pool 

constant_pool是一种表结构,以1 ~ (constant_pool_count - 1)为索引,表明了后面有多少个常量项。

常量池主要存放两大类常量:字面量(Literal) 和符号引用(Symbolic References)

  • 字面量:基本数据类型,字符串类型常量等;
  • 符号引用:类、字段、方法、接口等的符号引用;

它包含 .class 文件结构及其子结构中,引用的所有字符串常量、类或接口名、字段名和其他常量

3.9 常量类型和结构

常量池中的每一项都具备相同的特征。第1个字节作为类型标记,用于确定该项的格式,这个字节称为tag byte(标记字节、标签字节)。 

类型标志 / 标识描述
CONSTANT_Utf8_info1UTF-8 编码的字符串
CONSTANT_Integer_info3整型字面量
CONSTANT_Float_info4浮点型字面量
CONSTANT_Long_info5长整型字面量
CONSTANT_Double_info6双精度浮点型字面量
CONSTANT_Class_info7类或接口的符号引用
CONSTANT_String_info8字符串类型字面量
CONSTANT_Fieldref_info9字段的符号引用
CONSTANT_Methodref_info10类中方法的符号引用
CONSTANT_InterfaceMethodref_info11接口中方法的符号引用
CONSTANT_NameAndType_info12字段或方法的符号引用
CONSTANT_MethodHandle_info15表示方法句柄
CONSTANT_MethodType_info16标志方法类型
CONSTANT_InvokeDynamic_info18表示一个动态方法调用点
3.10 字面量和符号引用

常量池主要存放两大类常量:字面量(Literal) 和符号引用(Symbolic References)。如下表:

常量

具体的常量

字面量

文本字符串

声明为final的常量值

符号引用

类和接口的全限定名

字段的名称和描述符

方法的名称和描述符

全限定名

com/wsh/jvm/test/Demo这个就是类的全限定名,仅仅是把包名的"."替换成"/",为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“;”表示全限定名结束。

简单名称

简单名称是指没有类型和参数修饰的方法或者字段名称,上面例子中的类的add()方法和num字段的简单名称分别是add和num。

描述符

描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte、 char、double、float、int、long、short、boolean)以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示,详见下表:

标志符

含义

B

基本数据类型byte

C

基本数据类型char

D

基本数据类型double

F

基本数据类型float

I

基本数据类型int

J

基本数据类型long

S

基本数据类型short

Z

基本数据类型boolean

V

代表void类型

L

对象类型,比如:Ljava/lang/Object;

[

数组类型,代表一维数组。比如:double[][][] is [[[D

用描述符来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。如方法java.lang.String toString()的描述符为() Ljava/lang/String;,方法int abc(int【】x, int y)的描述符为(【II)I。 - 如下图,基本类型与引用类型的区别:

public class Demo {
    public static void main(String[] args) {
        Object[] objects = new Object[10];
        System.out.println(objects);
 
        String[] strings = new String[10];
        System.out.println(strings);
 
        long[][] longs = new long[10][];
        System.out.println(longs);
    }
}

 运行结果:

[Ljava.lang.Object;@13221655
[Ljava.lang.String;@2f2c9b19
[[J@31befd9f

 虚拟机加载Class文件时才会进行动态链接,也就是说,Class文件中不会保存各个方法和字段的最终内存布局信息,因此,这些字段和方法的符号引用不经过转换是无法直接被虚拟机使用的。当虚拟机运行时,需要从常量池中获得对应的符号引用,再在类加载过程中的解析阶段将其替换为直接引用,并翻详到具体的内存地址中。这里说明下符号引用和直接引用的区别与关联:

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中。
  • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那说明引用的目标必定已经存在于内存之中。
3.11 常见类型和结构细节

下面我们解析Demo类中常量池中所有的常量:

 比如第一项0a,转换为十进制就是10,对照上面表格,10表示这是一个方法符号引用,可以通过字节码文件对照得出:

3.12 常量池表项数据的总结

我们发现上述18种常量中并没有出现byte、short、char,boolean,原因:他们编译之后都可以理解为Integer。

总结一:

  • 这14种表(或者常量项结构)的共同点是:表开始的第一位是一个u1类型的标志位(tag),代表当前这个常量项使用的是哪种表结构,即哪种常量类型。
  • 在常量池列表中,CONSTANT_Utf8_info常量项是一种使用改进过的UTF-8编码格式来存储诸如文字字符串、类或者接口的全限定名、字段或者方法的简单名称以及描述符等常量字符串信息。
  • 这14种常量项结构还有一个特点是,其中13个常量项占用的字节固定,只有CONSTANT_Utf8_info占用字节不固定,其大小由length决定。为什么呢?因为从常量池存放的内容可知,其存放的是字面量和符号引用,最终这些内容都会是一个字符串,这些字符串的大小是在编写程序时才确定,比如你定义一个类,类名可以取长取短,所以在没编译前,大小不固定,编译后,通过UTF-8编码,就可以知道其长度。

总结二:

  • 常量池:可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型(后面的很多数据类型都会指向此处),也是占用Class文件空间最大的数据项目之一。

常量池中为什么要包含这些内容?

Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态链接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

更多详情参考:Class字节码文件结构总结_超字节码是什么-CSDN博客

4. ASM 解析类信息

4.1 ASM组件

ASM提供了基于 ClassVisitor API的三个基本组件用来生成/转换class:

  • ClassReader: 用于解析class文件,通过 accept 方法可以开始解析,并调用传入的 ClassVisitor 的 visitXxx 方法回调解析结果。ClassReader可以认为是解析事件的生产者。
  • ClassWriter: 这是 ClassVisitor 的一个实现类。它可以生成class字节码文件的byte数组,生成的byte数组可以通过 toByteArray 方法获取。ClassWriter可以认为是解析事件的消费者
  • ClassVisitor: 它所接收到的方法调用需要委托给另一个 ClassVisitor 实例。ClassVisitor可以看做是解析事件的过滤器。
4.2 解析一个类 - ClassReader + ClassVisitor

根据4.1 ASM 组件的介绍,我们知道:ClassReader作为解析事件的发生者,用于读取类信息。ClassVisitor作为解析事件的访问者/过滤器,可以接收来自ClassReader发来的回调,并将这个回调转发给其他ClassVisitor实例,访问到当前ClassReader读取到的类信息。ClassVisitor是一个抽象类,提供了各种访问信息的回调接口,列出最基本的几个内容:

public abstract class ClassVisitor {
    public ClassVisitor(int api);
    public ClassVisitor(int api, ClassVisitor cv);
    public void visit(int version, int access, String name,
                      String signature, String superName, String[] interfaces);
    public void visitSource(String source, String debug);
    public void visitOuterClass(String owner, String name, String desc);
    AnnotationVisitor visitAnnotation(String desc, boolean visible);
    public void visitAttribute(Attribute attr);
    public void visitInnerClass(String name, String outerName,
                                String innerName, int access);
    public FieldVisitor visitField(int access, String name, String desc,
                                   String signature, Object value);
    public MethodVisitor visitMethod(int access, String name, String desc,
                                     String signature, String[] exceptions);
    void visitEnd();
}

实现一个ClassVisitor的子类ClassPrinter来打印读取到的类信息,这个 ClassPrinter 并不做任何过滤操作,仅将所有回调内容都打印出来:

public class ClassPrinter extends ClassVisitor {
    public ClassPrinter() {
        super(Opcodes.ASM4);
    }

    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        System.out.println("----visit");
        System.out.println(version+" "+access+" "+name+" "+signature+" "+superName+" "+ Arrays.toString(interfaces));
        super.visit(version, access, name, signature, superName, interfaces);
    }

    @Override
    public void visitSource(String source, String debug) {

        System.out.println("----visitSource");
        System.out.println(source+" "+debug);
        super.visitSource(source, debug);
    }

    @Override
    public void visitOuterClass(String owner, String name, String descriptor) {
        System.out.println("----visitOuterClass");
        System.out.println(owner+" "+name+" "+descriptor);
        super.visitOuterClass(owner, name, descriptor);

    }

    @Override
    public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
        System.out.println("----visitAnnotations");
        System.out.println(descriptor+" "+visible);
        return super.visitAnnotation(descriptor, visible);
    }

    @Override
    public void visitAttribute(Attribute attribute) {
        System.out.println("----visitAttribute");
        System.out.println(attribute);
        super.visitAttribute(attribute);
    }

    @Override
    public void visitInnerClass(String name, String outerName, String innerName, int access) {
        System.out.println("----visitInnerClass");
        System.out.println(name+" "+outerName+" "+innerName+" "+access);
        super.visitInnerClass(name, outerName, innerName, access);
    }

    @Override
    public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
        System.out.println("----visitField");
        System.out.println(access+" "+name+" "+descriptor+" "+signature+" "+value);
        return super.visitField(access, name, descriptor, signature, value);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        System.out.println("----visitMethod");
        System.out.println(access+" "+name+" "+descriptor+" "+signature+" "+ Arrays.toString(exceptions));
        return super.visitMethod(access, name, descriptor, signature, exceptions);
    }

    @Override
    public void visitEnd() {
        System.out.println("----visitEnd");
        super.visitEnd();
    }
}


我们定义一个很简单的类:

@MyAnnotation("value")
public class ASMTest {
    public int num;

    public final int getNum() {
        return num;
    }

    private void setNum(int num) {
        this.num = num;
    }
}

结合ClassReader和ClassPrinter来解析它:

//ClassVisitor的实现类,打印所有visitXxx的回调参数
ClassPrinter cp = new ClassPrinter();
try {
    //ClassReader是类信息的读取者,解析事件的产生者
    ClassReader cr = new ClassReader("asmcore.base.ASMTest");
    //通过accept方法开始解析,并在解析到对应元素时回调传入的cp的visitXxx方法
    cr.accept(cp,0);
} catch (IOException e) {
    e.printStackTrace();
}


得到解析的结果:

----visit
52 33 asmcore/base/ASMTest null java/lang/Object []
----visitSource
ASMTest.java null
----visitAnnotations
Lasmcore/base/MyAnnotation; true
----visitField
1 num I null null
----visitMethod
1 <init> ()V null null
----visitMethod
17 getNum ()I null null
----visitMethod
2 setNum (I)V null null
----visitEnd

Process finished with exit code 0

可以看到,class文件的所有关键信息都被打印了出来。需要我们注意的是 visitXxx 方法的回调关系(先后回调顺序为表格从上到下):

方法名调用次数
visit1
visitSource最多一次
visitOuterClass最多一次
(visitAnnotation | visitAttribute)0次或更多
(visitInnerClass | visitField | visitMethod)0次或更多
visitEnd1

其中(A|B)形式表示A和B的出现顺序不是保证有先后的。但表格中从上到下整体的顺序是严格的回调先后的顺序。

4.3 生成一个类 - ClassWriter

我们知道 ClassWriter 继承自 ClassVisitor,它的任务是将访问回调的结果保存到byte数组中,直到 visitEnd 方法被回调表示解析/访问事件结束,也就意味着一个Class文件对应的byte数组数据也就保存完成,可以通过 ClassWriter 的 toByteArray 方法获取到保存的byte数组。

我们假装自己是一个 ClassReader,严格根据 4.2 总结的回调顺序回调 ClassWriter 的 visitXxx 顺序,由此来让 ClassWriter 去构建一个类:

ClassWriter cw = new ClassWriter(0);
//先模拟类访问开始
//package pkg;
//public interface Comparable extends Measure
cw.visit(V1_8,
         ACC_PUBLIC|ACC_ABSTRACT|ACC_INTERFACE,
         "Comparable",
         null,
         "java/lang/Object",
         new String[]{"Measurable"});
//模拟访问回调字段信息
//int TAG = 0; //接口中的字段描述符默认为 public final static
cw.visitField(ACC_PUBLIC|ACC_FINAL|ACC_STATIC,
              "TAG","I",null,0)
    .visitEnd();
//模拟访问回调方法信息
//int compareTo(Object o); //接口中方法描述符默认为 public abstract
cw.visitMethod(ACC_PUBLIC|ACC_ABSTRACT,
               "compareTo","(Ljava/lang/Object;)I",null,null)
    .visitEnd();
//模拟类访问结束
cw.visitEnd();
//这个byte[]就是class文件的字节流了
byte[] b = cw.toByteArray();

最后得到的字节码反编译后结果为:

public interface Comparable extends Measurable {
    int TAG = 0;

    int compareTo(Object var1);
}

需要注意的是 visitMethod 和 visitField 之后需要跟上 visitEnd,这两个方法的返回值是 MethodVisitor 和 FieldVisitor 实例,他们事件访问结束的标识为 visitEnd 的回调。

4.4 使用生成的class文件字节流 - ClassLoader

我们回顾到类加载机制,JVM通过类加载器,将class文件加载入JVM,class文件的来源可以有很多,本地或者网络,只要是符合规范的class文件,就可以通过字节流的方式进行读取,ClassLoader的 defineClass 方法将byte[]加载为Class对象。

public class MyClassLoader extends ClassLoader {
    public MyClassLoader(ClassLoader parent){
        super(parent);
    }
    
    public Class<?> generateClass(String name,byte[] b){
        return defineClass(name,b,0,b.length);
    }
}


需要注意这里的类加载器需要传入父类加载器,如果要加载的class文件中使用了项目中其他自定义类,则要通过双亲委派机制让父类加载器去完成加载。

4.5 删除、添加类成员 - ClassReader+ClassVisitor+ClassWriter

根据前面的介绍,我们知道 ClassVisitor 可以作为一个事件的过滤器,这句话的意思可以理解为,ClassVisitor 访问事件的回调可以进行分发!它有点像装饰器的代码增强,给出下面的代码或许可以更好的描述我的意思:

public class ClassVisitorFilter extends ClassVisitor{
    //父类的 ClassVisitor cv 可以理解为装饰器的被装饰者
    public ClassVisitorFilter(ClassVisitor downstream){
        super(V1_8,downstream);
    }
    
    @Override
    public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value){
        //before
        pre();
        //转发事件到被装饰者的该方法
        FiledVisitor fv = super.visitField(access,name,descriptor,signature,value);
        //不对被装饰者/下游分发该方法,将上述代码注释掉,如下:
        //FiledVisitor fv = super.visitField(access,name,descriptor,signature,value);
        //after
        after();
        return fv;
    }
}

我们给一个类结构如下:

package asmcore.base;

public class Student {
    public String name;
    
    public String getName(){
        return name;
    }
}

我们现在要增加一个 int 类型的字段 age,并删除 name 字段和 getName 方法。我们首先通过 ClassReader 发起一个解析事件,并将回调交给事件过滤器 ClassVisitor,我们这里把它命名为 XxxAdapter,通过责任链模式的过滤处理后,最后将事件转发给最后的事件处理器 ClassWriter。流程图如下:

针对字段增加和删除的过滤处理,我们写一个 ClassVisitor 的实现类 FieldAdapter:

public class FieldAdapter extends ClassVisitor {

    private int fAcc;
    private String fName;
    private String fDesc;
    private boolean isFieldPresent;
    private boolean isAdd;

    public FieldAdapter(ClassVisitor cv, int fAcc, String fName, String fDesc,boolean isAdd) {
        super(ASM4,cv);
        this.fAcc = fAcc;
        this.fName= fName;
        this.fDesc = fDesc;
        this.isAdd = isAdd;
    }

    @Override
    public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
        //-------代码增强---------
        if (!isAdd && name.equals(fName)){
            //如果不是添加,就将这个field删除
            return null;
        }
        //如果是添加field,就先判断这个field是否出现过,如果没有出现过,最后可以在visitEnd的时候添加
        if (name.equals(fName)){
            isFieldPresent = true;
        }
        //-------代码增强---------
        //回调给下一层
        return cv.visitField(access, name, descriptor, signature, value);
    }

    @Override
    public void visitEnd() {
        if (!isFieldPresent && isAdd){
            FieldVisitor fv = cv.visitField(fAcc, fName, fDesc, null, null);
            if (fv != null){
                fv.visitEnd();
            }
        }
        super.visitEnd();
    }
}

需要注意的是我们要避免重名的字段出现,所以要在扫描过所有字段过后,确保没有该字段出现,才往其中添加目标字段。visitEnd 方法的回调可以确保之前 visitField 已经完全结束,此时再转发给下游 visitField() 模拟真的访问到了要添加的目标字段,从而达到添加字段的效果。

类似的思路,我们可以定义一个删除方法的事件过滤器,其实就是不向下游转发 visitMethod 即可,就达成了模拟没有访问到该方法的效果。

public class RemoveMethodAdapter extends ClassVisitor {
    private String mName;
    private String mDesc;

    public RemoveMethodAdapter(ClassVisitor cv,String name,String desc) {
        super(ASM4,cv);
        this.mName = name;
        this.mDesc = desc;
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        //--------代码增强--------
        if (name.equals(mName) && descriptor.equals(mDesc)){
            //如果是要删除的方法,则不回调 MethodVisitor
            return null;
        }
        //--------代码增强--------
        return cv.visitMethod(access, name, descriptor, signature, exceptions);
    }
}

这是装饰器模式,配合责任链,我们可以实现过滤处理:

ClassWriter cw = new ClassWriter(0);
//添加一个 age 字段
FieldAdapter addFieldAdapter = new FieldAdapter(cw, ACC_PUBLIC, "age", "I",true);
//删除一个 name 字段
FieldAdapter removeFieldAdapter = new FieldAdapter(addFieldAdapter,ACC_PUBLIC,"name","Ljava/lang/String;",false);
//删除一个 getName 方法
RemoveMethodAdapter removeMethodAdapter = new RemoveMethodAdapter(removeFieldAdapter, "getName", "()Ljava/lang/String;");
try {
    ClassReader cr = new ClassReader("asmcore.base.Student");
    //装饰器责任链
    cr.accept(removeMethodAdapter,0);
    byte[] bytes = cw.toByteArray();
    save(bytes,"Student");
} catch (IOException e) {
    e.printStackTrace();
}

最后我们得到处理后的Student类字节码文件:

package asmcore.base;

public class Student {
    public int age;

    public Student() {
    }
}

可以发现成功地把 name 字段和 getName 方法删除,并添加了 int 类型的 age 字段。


上面讲了ASM的简介、导入,以及字节码文件结构,并给出了ASM通过ClassVisitor对class进行访问的基础实战。本篇将进入MethodVisitor,尝试对方法进行访问、生成、转换。方法的代码存储为字节码指令序列。在此之前,我们需要先复习JVM栈结构,才能更好地理解方法中字节码指令的逻辑。

1. JVM栈结构

一个JVM栈中包含了若干个栈帧,表征着一个个方法的调用栈。一个栈帧中存储着:

  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)
  • 动态链接(Dynamic Linking)指向运行时常量池的方法引用(MethodRef)
  • 方法返回地址(Return Address)方法正常退出或异常退出的定义
  • 附加信息

每个线程都有自己各自的栈,栈是线程私有的。栈帧的大小主要由局部变量表和操作数栈决定。操作数栈的深度、局部变量表的长度在编译器就已经确定,并写入到字节码中。如果栈帧只展示局部变量表和操作数栈,一个执行堆栈可能会为如下形式:

1.1 局部变量表

局部变量表也称为 局部变量数组 或 本地变量表。最基本的存储单元是 Slot (变量槽),参数值的存放总是在局部变量数组的 index = 0 开始,直到 数组长度-1 的索引结束。

局部变量表中,32位以内的类型占用一个 slot, 64位的类型(long、double)占用两个 slot。上图中的 L1、L2 等都忽略了slot的个数,可能L2是double类型,那么它应当占用 2 个 slot。局部变量表的长度按 slot 的个数计算。

1.2 操作数栈

独立的栈帧除了包含有局部变量表之外,还包含一个后进先出的操作数栈(Operand Stack)。操作数栈,在方法执行的执行过程中,根据字节码指令,往栈中写入数据或者提取数据,即入栈(push)和出栈(pop)

某些字节码指令将值压入操作数栈,其余的字节码指令将操作数栈取出栈,使用它们之后再把执行结果压入栈。

与局部变量表类似的,栈中可以是任意类型的Java数据类型

  • 32位的类型占用一个栈单位深度
  • 64位的类型占用两个栈单位深度

2. 字节码指令

字节码指令由操作码 opcode 和操作数 arguments 表征:

  • 操作码 opcode 是一个无符号的字节值,是代号,由助记符标识。
  • 操作数 arguments 是定义精确指令行为的静态值。

根据字节码指令和操作数栈的关系,字节码指令可以分为两类:一类字节码指令设计用于将值从局部变量表转移到操作数栈中,反之亦然;另一类则只操作操作数栈,它们从操作数栈弹出一些值,根据这些值进行计算,将结果重新压入操作数栈栈顶。

例如 ILOAD、LLOAD、FLOAD、DLOAD和ALOAD指令读取一个局部变量的值,并将这个值压入到操作数栈中。由于这些指令操作的是局部变量表,需要提供表/数组的索引 i 作为参数,来表示读取哪一个局部变量。我们之前提到32位以内的类型都存入到一个 slot 中,反过来,提取的时候这里 ILOAD 可以用于加载 boolean、byte、char、short和int类型的局部变量。不仅如此,我们也说了 64位 的局部变量将会占用两个插槽 slot ,故LLOAD、DLOAD加载数据时候,实际上加载了 i 和 i+1 两个插槽的内容。ALOAD 用于加载其他类型,比引用类型、数组引用类型等。

我们可以观察到 xLOAD和 xSTORE指令都是由x表征类型的,者用来确保不进行非法的转换。

除了上述 xLOAD 和 xSTORE 的指令之外,其他字节码指令只在操作数栈上工作。这里给出字节码指令的汇总:

注意:

  • a 和 b 表示 int、float、long、double类型
  • o 和 p 表示 对象引用类型
  • v 表示单位为1的类型
  • w 表示long、double这样单位为2的类型
  • i 和 j 和 n 表示 int 类型
2.1 Local variables 局部变量
指令栈(原先)栈(指令执行后)
ILOAD,LLOAD,FLOAD,DLOAD var…, a
ALOAD var…, o
ISTORE,LSTORE,FSTORE,DSTORE var…, a
ASTORE var…, o
IINC var incr

示例:

【局部变量压栈指令】将一个局部变量加载到操作数栈:xload 、xload_<n>(其中x为i、l、f、d、a, n从0到3

aload_0 // 将局部变量表中0号局部变量的值压入到操作数栈
aload 5 // 将局部变量表中5号局部变量的值压入操作数栈

【出栈装入局部变量表指令】将一个数值从操作数栈存储到局部变量表:xstore、xstore_<n>(其中x为i、l、f、d、a,n为0到3);xastore(其中x为i、l、f、d、a、b、c、s)

2.2 Stack 操作数栈
指令栈(原先)栈(指令执行后)
POP…, v
POP2…, v1, v2
…, w
DUP…, v…, v , v
DUP2…, v1, v2…, v1, v2, v1, v2
…, w…, w, w
SWAP…, v1, v2…, v2, v1
DUP_X1…, v1, v2…, v2, v1, v2
DUP_X2…, v1, v2, v3…, v3, v1, v2, v3
…, w, v…, v, w, v
DUP2_X1…, v1, v2, v3…, v2, v3, v1, v2, v3
…, v, w…, w, v, w
DUP2_X2…, v1, v2, v3, v4…, v3, v4, v1, v2, v3, v4
…, w, v1, v2…, v1, v2, w, v1, v2
…, v1, v2, w…, w, v1, v2, w
…, w1, w2…, w2, w1, w2

2.3 Constants 常量操作
指令栈(原先)栈(指令执行后)
ICONST_n ( n  5)… , n
LCONST_n (0  n  1)… , nL
FCONST_n (0  n  2)… , nF
DCONST_n (0  n  1)… , nD
BIPUSH b128  b < 127… , b
SIPUSH s32768  s < 32767… , s
LDC cst (int, float, long, double, String or Type)… , cst
ACONST_NULL… , null

【常量入栈指令】将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>)、fconst_<f>、dconst_<d>

示例:

ldc指令可以接受一个8位的参数,指向常量池中int、float或者String的索引,并将指定的内容压入操作数栈
例如:
    ldc #9 , 常量池表#9为“hello”,将这个字符串索引压入栈中
ldc_w 把常量池中的项压入栈,w表示使用宽索引,款缩影支持索引范围大于ldc,接收两个8位的参数
ldc2_w 把常量池中 long 或者 double 类型的项压入栈
aconst_null 把null压入操作数栈
fconst_0 把浮点数0压入栈
2.4 Arithmetic and logic 计算和逻辑运算
指令栈(原先)栈(指令执行后)
IADD,LADD,FADD,DADD…, a, b…, a+b
ISUB,LSUB,FSUB,DSUB…, a, b…, a- b
IMUL, LMUL, FMUL, DMUL… , a , b… , a * b
IDIV, LDIV, FDIV, DDIV… , a , b… , a / b
IREM, LREM, FREM, DREM… , a , b… , a % b
INEG, LNEG, FNEG, DNEG (negtive)… , a… , -a
ISHL, LSHL (left)… , a , n… , a << n
ISHR, LSHR (right)… , a , n… , a >> n
IUSHR, LUSHR… , a , n… , a >>> n
IAND, LAND… , a , b… , a & b
IOR, LOR… , a , b… , a | b
IXOR, LXOR… , a , b… , a ^ b
LCMP… , a , b… , a == b ? 0 : (a < b ? -1 : 1)
FCMPL, FCMPG… , a , b… , a == b ? 0 : (a < b ? -1 : 1)
DCMPL, DCMPG… , a , b… , a == b ? 0 : (a < b ? -1 : 1)
2.5 Cast类型转换
指令栈(原先)栈(指令执行后)
I2B… , i… , (byte) i
I2C… , i… , (char) i
I2S… , i… , (short) i
L2I, F2I, D2I… , a… , (int) a
I2L, F2L, D2L… , a… , (long) a
I2F, L2F, D2F… , a… , (float) a
I2D, L2D, F2D… , a… , (double) a
CHECKCAST class… , o… , (class) o

从byte、char、short类型到int类型的宽化类型转换实际上是不存在的,虚拟机对这种类型转换并没有做实质性的转化处理,只是通过操作数栈交换了两个数据。

窄化数据转换可能发生精度丢失,可能丢失掉几个最低有效位上的值,转换后的浮点数值根据IEEE754最接近含入模式所得到的正确整数值。窄化类型转换可能会发生上限溢出、下限溢出和精度丢失等情况。

2.5 Objects对象、Field字段、Method方法

c: class类, f: field字段名, m:method方法名, t: description描述符

指令栈(原先)栈(执行后)
NEW class…, new class
GETFIELD c f t…, o… , o.f
PUTFIELD c f t… , o , v
GETSTATIC c f t… , c.f
PUTSTATIC c f t… , v
INVOKEVIRTUAL c m t… , o , v1 , … , vn… , o.m(v1, … vn)
INVOKESPECIAL c m t… , o , v1 , … , vn… , o.m(v1, … vn)
INVOKESTATIC c m t… , v1 , … , vn… , c.m(v1, … vn)
INVOKEINTERFACE c m t… , o , v1 , … , vn… , o.m(v1, … vn)
INVOKEDYNAMIC m t bsm… , o , v1 , … , vn… , o.m(v1, … vn)
INSTANCEOF class… , o… , o instanceof class
MONITORENTER… , o
MONITOREXIT… , o

2.6 Arrays集合
指令栈(原先)栈(指令执行后)
NEWARRAY type (for any primitive type)… , n… , new type[n]
ANEWARRAY class… , n… , new class[n]
MULTIANEWARRAY […[t n… , i1 , … , in… , new t[i1]…[in]…
BALOAD, CALOAD, SALOAD… , o , i… , o[i]
IALOAD, LALOAD, FALOAD, DALOAD… , o , i… , o[i]
AALOAD… , o , i… , o[i]
BASTORE, CASTORE, SASTORE… , o , i , j
IASTORE, LASTORE, FASTORE, DASTORE… , o , i , a
AASTORE… , o , i , p
ARRAYLENGTH… , o… , o.length

示例:

public class Student {
    public String[] infos;

    public String getInfo(int index){
        return infos[index];
    }
}

其中 getInfo(int index) 的字节码指令为:

0 aload_0 // 先将 this 压入栈
1 getfield #2 <asmcore/base/Student.infos> //拿到class下的infos变量,这是个 [Ljava/lang/String 类型的
4 iload_1 //将局部变量表 1号 变量 index 压入栈
5 aaload //..., o, i -> ..., o[i]
6 areturn //将 o[i] 返回出去

由于 getInfo 是非静态方法,所以局部变量表的0号局部变量为this引用

2.7 Jumps 跳转指令
指令栈(原先)说明
IFEQ… , ijump if i == 0
IFNE… , ijump if i != 0
IFLT… , ijump if i < 0
IFGE… , ijump if i >= 0
IFGT… , ijump if i > 0
IFLE… , ijump if i <= 0
IF_ICMPEQ… , i , jjump if i == j
IF_ICMPNE… , i , jjump if i != j
IF_ICMPLT… , i , jjump if i < j
IF_ICMPGE… , i , jjump if i >= j
IF_ICMPGT… , i , jjump if i > j
IF_ICMPLE… , i , jjump if i <= j
IF_ACMPEQ… , o , pjump if o == p
IF_ACMPNE… , o , pjump if o != p
IFNULL… , ojump if o == null
IFNONNULL… , ojump if o != null
GOTOjump always
TABLESWITCH… , ijump always
LOOKUPSWITCH… , ijump always

示例:

public class Student {
    public String[] infos;

    public String getInfo(int index){
        if (index < 0 ){
            return "nothing";
        }else{
            return infos[index];
        }
    }
}

编译后,代码优化为:

public class Student {
    public String[] infos;

    public Student() {
    }

    public String getInfo(int index) {
        return index < 0 ? "nothing" : this.infos[index];
    }
}

其中 getInfo(int index) 字节码指令为:

 0 iload_1 //将1号局部变量index压入栈
 1 ifge 7 (+6) //如果栈顶元素大于0,跳转到7行
 4 ldc #2 <nothing> //从常量池中将 "nothing" 压入栈
 6 areturn //将栈顶 “nothing” 返回出去
 7 aload_0	//如果栈顶元素大于0,来到这里,将0号局部变量this压入栈
 8 getfield #3 <asmcore/base/Student.infos> //拿到其field
11 iload_1 //将1号局部变量index压入栈
12 aaload //...,o,i -> ..., o[i]
13 areturn //将栈顶 o[i] 返回出去

在 ASM 中字节码可以表示为:

public getInfo(I)Ljava/lang/String; //方法
   L0
    LINENUMBER 7 L0 //记录行号
    ILOAD 1	//获取1号int类型局部变量 index 压入栈
    IFGE L1 //如果 index >=0 跳转到 L1
   L2
    LINENUMBER 8 L2 //记录行号
    LDC "nothing" //将常量 "nothing" 压入栈
    ARETURN	//栈顶返回引用类型对象
   L1
    LINENUMBER 10 L1 //记录行号
   FRAME SAME	//帧
    ALOAD 0	//获取0号引用类型局部变量 this 压入栈
    GETFIELD asmcore/base/Student.infos : [Ljava/lang/String; //拿到 this 的String[]类型的 infos 字段
    ILOAD 1 //获取1号int类型局部变量 index 压入栈
    AALOAD //...,o,i -> ..., o[i]
    ARETURN //栈顶返回引用类型对象
   L3
    LOCALVARIABLE this Lasmcore/base/Student; L0 L3 0 //局部变量表中变量this索引为0
    LOCALVARIABLE index I L0 L3 1 //局部变量表中index变量索引为1
    MAXSTACK = 2 //操作数栈深度
    MAXLOCALS = 2 //局部变量表长度
2.8 Return 返回指令
指令说明
IRETURN, LRETURN, FRETURN, DRETURN… , a返回数据类型
ARETURN… , o返回引用类型
RETURN返回类型为void
ATHROW…, o抛出异常结束执行

3. MethodVisitor

与 ClassVisitor类似,MethodVisitor也有访问回调顺序。

在MethodVisitor类当中,定义了许多的visitXxx()方法,这些方法的调用,也要遵循一定的顺序。

(visitParameter)*
[visitAnnotationDefault]
(visitAnnotation | visitAnnotableParameterCount | visitParameterAnnotation | visitTypeAnnotation | visitAttribute)*
[
    visitCode
    (
        visitFrame |
        visitXxxInsn |
        visitLabel |
        visitInsnAnnotation |
        visitTryCatchBlock |
        visitTryCatchAnnotation |
        visitLocalVariable |
        visitLocalVariableAnnotation |
        visitLineNumber
    )*
    visitMaxs
]
visitEnd

我们可以把这些visitXxx()方法分成三组:

  1. 在visitCode()方法之前的方法。这一组的方法,主要负责parameter、annotation和attributes等内容;在当前课程当中,我们暂时不去考虑这些内容,可以忽略这一组方法。
  2. 在visitCode()方法和visitMaxs()方法之间的方法。这一组的方法,主要负责当前方法的“方法体”内的opcode内容。其中,visitCode()方法,标志着方法体的开始,而visitMaxs()方法,标志着方法体的结束。
  3. 是visitEnd()方法。这个visitEnd()方法,是最后一个进行调用的方法。

对这些visitXxx()方法进行精简之后,内容如下:

[
    visitCode
    (
        visitFrame |
        visitXxxInsn |
        visitLabel |
        visitTryCatchBlock
    )*
    visitMaxs
]
visitEnd

这些方法的调用顺序,可以记忆如下:

  1. 调用visitCode()方法,调用一次。
  2. 调用visitXxxInsn()方法,可以调用多次。对这些方法的调用,就是在构建方法的“方法体”。
  3. 调用visitMaxs()方法,调用一次。
  4. 调用visitEnd()方法,调用一次。

MethodVisitor 的相关方法为:

abstract class MethodVisitor { // public accessors ommited
    MethodVisitor(int api);
    MethodVisitor(int api, MethodVisitor mv);
    AnnotationVisitor visitAnnotationDefault();
    AnnotationVisitor visitAnnotation(String desc, boolean visible);
    AnnotationVisitor visitParameterAnnotation(int parameter,
                                               String desc, boolean visible);
    void visitAttribute(Attribute attr);
    void visitCode();
    void visitFrame(int type, int nLocal, Object[] local, int nStack,
                    Object[] stack);
    void visitInsn(int opcode);
    void visitIntInsn(int opcode, int operand);
    void visitVarInsn(int opcode, int var);
    void visitTypeInsn(int opcode, String desc);
    void visitFieldInsn(int opc, String owner, String name, String desc);
    void visitMethodInsn(int opc, String owner, String name, String desc);
    void visitInvokeDynamicInsn(String name, String desc, Handle bsm,
                                Object... bsmArgs);
    void visitJumpInsn(int opcode, Label label);
    void visitLabel(Label label);
    void visitLdcInsn(Object cst);
    void visitIincInsn(int var, int increment);
    void visitTableSwitchInsn(int min, int max, Label dflt, Label[] labels);
    void visitLookupSwitchInsn(Label dflt, int[] keys, Label[] labels);
    void visitMultiANewArrayInsn(String desc, int dims);
    void visitTryCatchBlock(Label start, Label end, Label handler,
                            String type);
    void visitLocalVariable(String name, String desc, String signature,
                            Label start, Label end, int index);
    void visitLineNumber(int line, Label start);
    void visitMaxs(int maxStack, int maxLocals);
    void visitEnd();
}

我们发现,annotations 和 attributes 必须要首先访问,然后再访问方法的方法体。按照上述的访问顺序进行访问。visitCode 和 visitMaxs 表征着当前来到方法体的开始、结束位置。和 ClassVisitor 类似, visitEnd 在最后被调用,表征着访问事件的结束。

我们并不能直接访问到方法,而是需要通过一些 MethodVisitor 相关的API 来访问:

  • 首先通过 ClassReader 的 accept 方法来开启访问事件,将事件传递给 ClassVisitor
  • 当访问到方法时,ClassVisitor会被调用 visitMethod 方法,这个方法返回了一个 MethodVisitor实例,接下去进入这个 MethodVisitor进行方法的访问事件的回调
  • MethodVisitor 也把它接收到的所有方法转发给另一个 MethodVisitor 实例,所以它也可以被看做是一个事件过滤器。

最后通常还是会来到事件消费者 ClassWriter,当它接收到 MethodVisitor 传来的方法访问细节时,可能会面临计算帧、计算局部变量表、操作数栈大小的问题。 ASM 为我们提供了几种选择:

  • new Classwriter(0):不做任何自动化计算,程序员必须自己计算帧、局部变量表和操作数栈的大小
  • new ClassWriter(ClassWriter.COMPUTE_MAXS):自动计算局部变量表和操作数栈的大小,你仍然必须调用 visitMaxs,但它的参数将不被使用。
  • new ClassWriter(ClassWriter.COMPUTE_FRAMES):所有东西都被自动计算,你无需调用 visitFrame,但你仍然必须调用 visitMaxs(参数将忽略,不被使用)

自动计算有好处,就是方便了程序员的开发,但是它会带来性能损耗,官方给出:使用 COMPUTE_MAXS 会让性能降低 10% , 使用 COMPUTE_FRAMES 会让性能降低 20% 。

如果我们要自行计算帧,我们要使用 visitFrame(F_NEW, nLocals, locals, nStack, stack),其中 nLocals 和 nStack 是局部变量表和操作数栈的大小, locals 和 stack 是相应的集合。自动计算帧的时候,可能会加载父类到JVM,并通过反射的手段做一些事情,如果你正在生成的几个类相互之间有关联,可能关联的类此时还不存在,会出现自动计算错误。官方提示可以通过重写 getCommonSuperClass方法来解决这个问题。

3.1 生成一个 Method 方法 - ClassWriter+MethodVisitor

假设我们当前有一个类:

public class Bean{
    public int f;
}

我要加一个给f设置值的方法:

public class Bean{
    public int f;
    //添加一个方法:
    public void checkAndSet(int f){
        if(f >= 0){
            this.f = f;
        }else{
            throw new IllegalArgumentException();
        }
    }
}

我们可以在把访问事件分发给 ClassWriter 的时候,模拟分发一个原本不存在的MethodVisitor的访问事件,从而实现让 ClassWriter 添加一个方法的效果,首先根据 ASMPlugin 查看字节码形式,或者 jclasslib查看也行:

使用ASMPlugin查看

public checkAndSet(I)V
   L0
    LINENUMBER 8 L0
    ILOAD 1
    IFLT L1
   L2
    LINENUMBER 9 L2
    ALOAD 0
    ILOAD 1
    PUTFIELD asmcore/base/Bean.f : I
    GOTO L3
   L1
    LINENUMBER 11 L1
   FRAME SAME
    NEW java/lang/IllegalArgumentException
    DUP
    INVOKESPECIAL java/lang/IllegalArgumentException.<init> ()V
    ATHROW
   L3
    LINENUMBER 13 L3
   FRAME SAME
    RETURN
   L4
    LOCALVARIABLE this Lasmcore/base/Bean; L0 L4 0
    LOCALVARIABLE f I L0 L4 1
    MAXSTACK = 2
    MAXLOCALS = 2

使用 jclasslib 查看:

 0 iload_1
 1 iflt 12 (+11)
 4 aload_0
 5 iload_1
 6 putfield #2 <asmcore/base/Bean.f>
 9 goto 20 (+11)
12 new #3 <java/lang/IllegalArgumentException>
15 dup
16 invokespecial #4 <java/lang/IllegalArgumentException.<init>>
19 athrow
20 return

根据 ASMPlugin 的结果,我们来尝试构建MethodVisitor大概的代码结构,分析字节码,其逻辑大概是判断传入参数 f 是否非负,如果是,继续执行字节码,如果不是,跳转到另一个代码块(跳转到另一个label/跳转到另一个代码段起始位置)。需要注意 visitCode为方法代码的开始标志,visitEnd为方法代码的结束标志。

mv.visitCode();//模拟访问代码开始
mv.visitVarInsn(ILOAD, 1);//变量操作,将传入参数f压入操作数栈
Label label = new Label();
mv.visitJumpInsn(IFLT,label);//跳转标志,跳转判断的数字为操作数栈顶元素,如果符合条件,跳入else{}代码块的label标识
//如果上述判断成功,继续执行下面字节码指令
mv.visitVarInsn(ALOAD, 0);//把this压入栈
mv.visitVarInsn(ILOAD, 1);//把传入参数f压入操作数栈(局部变量表中并没有把f剔除,所以可以多次使用,例如压入操作数栈)
mv.visitFieldInsn(PUTFIELD,"asmcore/base/Bean","f","I");//访问field字段,Bean类的f字段,描述符为I,意为int类型数据
Label end = new Label();
mv.visitJumpInsn(GOTO, end);//if(){}的代码块执行完成,进入return,return这块代码由end标识
//来到else{}代码块的label部分
mv.visitLabel(label);//打上label标签
mv.visitFrame(F_SAME, 0, null, 0, null);//自行计算帧
mv.visitTypeInsn(NEW,"java/lang/IllegalArgumentException");//类相关,new一个对象
mv.visitInsn(DUP);//在操作数栈中复制一份栈顶元素
mv.visitMethodInsn(INVOKESPECIAL,"java/lang/IllegalArgumentException","<init>","()V");//取出栈顶元素,调用这个类的构造函数,得到的对象放入操作数栈顶
mv.visitInsn(ATHROW);//无参指令,ATHROW将栈顶对象作为异常对象抛出
//来到return代码块的label部分
mv.visitLabel(end);
mv.visitFrame(F_SAME, 0, null, 0, null);//自行计算帧
mv.visitInsn(RETURN);//直接返回
mv.visitMaxs(2,2);//局部变量表、操作数栈的深度计算
mv.visitEnd();//方法的代码段结束

我们来实践一下:

  1. ClassReader读取 Bean 类
  2. 将事件传递给 ClassVisitor,其中这个ClassVisitor的visitEnd()中,模拟转发这个方法访问事件的转发,交给ClassWriter去消费/记录

首先,我们写一个 AddMethodAdapter 用来添加模拟转发方法事件,且将模拟转发方法访问事件设计在ClassVisitor的 visitEnd 调用返回之前完成。

public class AddMethodAdapter extends ClassVisitor {
    public AddMethodAdapter(ClassVisitor downstream) {
        super(ASM4,downstream);
    }

    @Override
    public void visitEnd() {
        //模拟添加一个方法
        MethodVisitor mv = super.visitMethod(0x1,"checkAndSet","(I)V",null,new String[]{});
        mv.visitCode();//模拟访问代码开始
        mv.visitVarInsn(ILOAD, 1);//变量操作,将传入参数f压入操作数栈
        Label label = new Label();
        mv.visitJumpInsn(IFLT,label);//跳转标志,跳转判断的数字为操作数栈顶元素,如果符合条件,跳入else{}代码块的label标识
//如果上述判断成功,继续执行下面字节码指令
        mv.visitVarInsn(ALOAD, 0);//把this压入栈
        mv.visitVarInsn(ILOAD, 1);//把传入参数f压入操作数栈(局部变量表中并没有把f剔除,所以可以多次使用,例如压入操作数栈)
        mv.visitFieldInsn(PUTFIELD,"asmcore/base/Bean","f","I");//访问field字段,Bean类的f字段,描述符为I,意为int类型数据
        Label end = new Label();
        mv.visitJumpInsn(GOTO, end);//if(){}的代码块执行完成,进入return,return这块代码由end标识
//来到else{}代码块的label部分
        mv.visitLabel(label);//打上label标签
        mv.visitFrame(F_SAME, 0, null, 0, null);//自行计算帧
        mv.visitTypeInsn(NEW,"java/lang/IllegalArgumentException");//类相关,new一个对象
        mv.visitInsn(DUP);//在操作数栈中复制一份栈顶元素
        mv.visitMethodInsn(INVOKESPECIAL,"java/lang/IllegalArgumentException","<init>","()V",false);//取出栈顶元素,调用这个类的构造函数,得到的对象放入操作数栈顶
        mv.visitInsn(ATHROW);//无参指令,ATHROW将栈顶对象作为异常对象抛出
//来到return代码块的label部分
        mv.visitLabel(end);
        mv.visitFrame(F_SAME, 0, null, 0, null);//自行计算帧
        mv.visitInsn(RETURN);//直接返回
        mv.visitMaxs(2,2);//局部变量表、操作数栈的深度计算
        mv.visitEnd();//方法的代码段结束
        super.visitEnd();
    }
}

接下去就可以按我们的老套路,开始利用这个代码增强的性质,进行链式转发:

ClassWriter cw=  new ClassWriter(0);
AddMethodAdapter addMethodAdapter = new AddMethodAdapter(cw);
try {
    ClassReader cr = new ClassReader("asmcore.base.Bean");
    cr.accept(addMethodAdapter,0);
    byte[] b = cw.toByteArray();
    //将byte[]写入文件
    save(b,"Bean");
} catch (IOException e) {
    e.printStackTrace();
}

最后我们查看生成的字节码文件的反编译结果:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package asmcore.base;

public class Bean {
    public int f;

    public Bean() {
    }

    public void checkAndSet(int var1) {
        if (var1 >= 0) {
            this.f = var1;
        } else {
            throw new IllegalArgumentException();
        }
    }
}

符合我们的预期。

3.2 转变方法 Transforming Methods

除了增加、删除一个方法,我们可能还要对原有方法进行修改,例如在原有代码的基础上进行代码增强。我们尝试在方法的开头和结束位置加上一个执行时间的记录。假设我们有一个工具类,其中代码增强部分为该方法的耗时计算:

public class Util {
    //耗时操作时间记录
    public void doSomething() throws Exception {
        //----增加的代码begin----
        long begin = System.currentTimeMillis();
        //----增加的代码end----
        Thread.sleep(100);
        //----增加的代码begin----
        System.out.println(System.currentTimeMillis() - begin);
        //----增加的代码end----
    }
}


我们来看一下这个字节码是什么样的,先来看一下没有增加代码的时候的情况:

public doSomething()V throws java/lang/Exception 
   L0
    LINENUMBER 8 L0
    LDC 100
    INVOKESTATIC java/lang/Thread.sleep (J)V
   L1
    LINENUMBER 10 L1
    RETURN
   L2
    LOCALVARIABLE this Lasmcore/base/Util; L0 L2 0
    MAXSTACK = 2
    MAXLOCALS = 1


然后是增加代码之后的情况,在其中标注出了新增加的内容:

public doSomething()V throws java/lang/Exception 
   L0
    //新代码
    LINENUMBER 7 L0
    //新增加了方法调用
    INVOKESTATIC java/lang/System.currentTimeMillis ()J
    //将返回值存入var1(也就是 begin 这个本地变量)
    LSTORE 1
   L1
    //原有代码
    LINENUMBER 8 L1
    LDC 100
    INVOKESTATIC java/lang/Thread.sleep (J)V
   L2
    //新代码
    LINENUMBER 9 L2
    //获取PrintStream对象
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
	//调用System.currentMillis
    INVOKESTATIC java/lang/System.currentTimeMillis ()J
    //将begin的值存入操作数栈
    LLOAD 1
    //操作数栈减法
    LSUB
    //调用打印(传入参数就一个,就是操作数栈栈顶元素)
    INVOKEVIRTUAL java/io/PrintStream.println (J)V
   L3
    //原有代码
    LINENUMBER 10 L3
    RETURN
   L4
    //局部变量表信息
    LOCALVARIABLE this Lasmcore/base/Util; L0 L4 0
    LOCALVARIABLE begin J L1 L4 1
    MAXSTACK = 5
    MAXLOCALS = 3

由于这里引入了新的局部变量,所以我们需要处理局部变量表,这部分我们直接使用 LocalVariablesSorter 这个封装好的 MethodVisitor 的实现类,帮我们处理局部变量。ClassWriter 使用 ClassWriter.COMPUTE_MAXS 来自动计算局部变量表。其他部分尽量使用原生API,保持与上文描述一致:

public class AddTimerAdapter extends ClassVisitor {
    public AddTimerAdapter(ClassVisitor downstream) {
        super(ASM4,downstream);
    }

    //如果是 doSomething 方法,就对这个方法进行代码增强
    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        if (name.equals("doSomething")){
            return new TimerMethodVisitor(ASM4,access,descriptor,cv.visitMethod(access, name, descriptor, signature, exceptions));
        }
        return cv.visitMethod(access, name, descriptor, signature, exceptions);
    }

    public static class TimerMethodVisitor extends LocalVariablesSorter {

        int beginIndex;

        protected TimerMethodVisitor(int api, int access, String descriptor, MethodVisitor methodVisitor) {
            super(api, access, descriptor, methodVisitor);
        }


        @Override
        public void visitCode() {
            //在原来MethodVisitor的基础上,在代码开始的地方插入内容
            mv.visitCode();
            //调用System.currentMillis方法
            mv.visitMethodInsn(INVOKESTATIC,"java/lang/System","currentMillis","()J",false);
            //由于这里无法处理局部变量表、操作数栈的下标,所以需要让Writer自己去做
            //借助 AdviceAdapter 帮我们封装好新增局部变量的方法进行新增局部变量
            beginIndex = newLocal(Type.LONG_TYPE);//index为本地变量下标
            //存入操作数栈
            mv.visitVarInsn(LSTORE,beginIndex);
        }

        //在方法退出之前计算时间
        @Override
        public void visitInsn(int opcode) {
            //需要注意的是owner给的是类,descriptor是描述符
            //如果是return,或者是throw exception,就提前打印时间
            if ((opcode >= IRETURN && opcode <= RETURN ) || opcode == ATHROW){
                //获取printStream对象,存入操作数栈
                mv.visitFieldInsn(GETSTATIC,"java/lang/System","out","Ljava/io/PrintStream;");
                //调用System.currentMillis方法
                mv.visitMethodInsn(INVOKESTATIC,"java/lang/System","currentMillis","()J",false);
                //将begin的值存入操作数栈
                mv.visitVarInsn(LLOAD,beginIndex);
                //操作数栈减法
                mv.visitInsn(LSUB);
                //调用打印
                mv.visitMethodInsn(INVOKEVIRTUAL,"java/io/PrintStream","println","(J)V",false);
            }
            super.visitInsn(opcode);
        }

        @Override
        public void visitMaxs(int maxStack, int maxLocals) {
            //其实这里没有用了,因为ClassWriter设置了 COMPUTE_MAXS
            super.visitMaxs(maxStack+2, maxLocals);
        }
    }
}


我们来调用一下,查看结果:

ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
AddTimerAdapter addTimerAdapter = new AddTimerAdapter(cw);
try {
    ClassReader cr = new ClassReader("asmcore.base.Util");
    cr.accept(addTimerAdapter,0);
    byte[] b = cw.toByteArray();
    save(b,"Util");
} catch (IOException e) {
    e.printStackTrace();
}


运行后得到的字节码为:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package asmcore.base;

public class Util {
    public Util() {
    }

    public void doSomething() throws Exception {
        long var1 = System.currentMillis();
        Thread.sleep(100L);
        System.out.println(System.currentMillis() - var1);
    }
}

上面多次用到了save方法,这里提供个类似的方法。

 private void writeBytes(File srcDir, String clzName, byte[] bytes) {
        File dir = new File(srcDir, SpiAction.packageName);
        if (dir.isDirectory() && !dir.exists()) {
            dir.mkdirs();
        }
        File clzFile = new File(dir, clzName);
        try (OutputStream out = new FileOutputStream(clzFile); BufferedOutputStream buff = new BufferedOutputStream(out)) {
            buff.write(bytes);
            buff.flush();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

具体参考:https://github.com/jdsjlzx/spi

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值