Java字节码操纵框架ASM入门

一、ASM是什么

ASM是一个Java字节码操纵框架,能被用来动态生成新类,或对既有类的内容进行增、删、改。
ASM可以直接产生二进制class文件,也可以在类被加载入Java虚拟机之前动态改变类行为。

二、使用ASM配合JavaAgent,在类被加载入Java虚拟机之前动态改变类

1、实现一个含有premain(String args, Instrumentation inst)方法的类,在premain(String args, Instrumentation inst)方法中,调用Instrumentation.addTransformer(ClassFileTransformer transformer)方法。

package cn.houseyoung.asm;

import java.lang.instrument.Instrumentation;

/**
* Created by houseyoung on 2018/9/13.
*/
public class StartAgent {
    public static void premain(String args, Instrumentation inst) {
        inst.addTransformer(new MyClassFileTransformer());
    }
}

2、实现ClassFileTransformer类及其中的byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer)方法,在该方法中,使用ASM框架对传入的字节数组形式的class文件classfileBuffer进行操纵,返回操纵完毕后的class文件字节数组。

3、在manifest.mf文件中指定premain()方法所在的类:

Premain-Class: cn.houseyoung.asm.StartAgent

4、将包含premain()方法的类打为jar包。

5、用如下方式运行Java程序:

java -javaagent:jar 文件的位置 [= 传入 premain 的参数 ]

 

三、ASM使用的设计模式——访问者模式简介

元素提供accept方法,该方法传入访问者对象,执行访问者提供的visit方法。
访问者提供visit方法,用于访问具体元素,并对之进行操作。
这种模式的优势在于,能在不修改元素的情况下添加对于元素访问的操作,只需要让元素accept一个新的访问者即可。
但在增加/修改元素内容后,所有的访问者类都需要修改相应的visit方法,所以访问者模式并不适合元素类频繁变动的场景,这也是访问者模式自身最大的缺陷。
对于ASM来说,ClassReader元素类来源于Java class,而Java class的格式几乎不变,因而规避了这一缺陷。

四、ASM API三大核心组件

ASM提供了三个基于ClassVisitor API的核心组件,用于生成和变化类:

ClassReader类分析以字节数组形式给出的已编译类,并针对在其accept()方法参数中传送的ClassVisitor实例,调用相应的visitXXX()方法。

ClassWriter类是ClassVisitor抽象类的一个子类,它直接以二进制形式生成编译后的类,它会生成一个字节数组形式的输出,其中包含了已编译类,可以用toByteArray()方法来提取。

ClassVisitor类将它收到的所有方法调用都委托给另一个ClassVisitor类。

时序图:

asm

 

五、ASM中的类信息访问次序

ASM依据对类信息的遍历顺序进而调用不同的visit方法,其访问顺序为:

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

即,必须先调用visit()方法;然后调用0或1次visitSource()方法;接着调用0或1次visitOuterClass()方法;然后以任意顺序和任意次数(0、1或多次)调用visitAnnotation()和visitAttribute()方法;接着以任意顺序和任意次数(0、1或多次)调用visitInnerClass()、visitField()和visitMethod()方法;最后,调用visitEnd()方法结束对类的访问。

这一顺序不能被打破,例如,不能在visitAttribute()方法前调用visitField()。

六、使用ClassWriter生成新类/新抽象类/新接口

例如如下的一个接口:

package pkg;
public interface Comparable extends Mesurable {
    int LESS = -1;
    int EQUAL = 0;
    int GREATER = 1;
    int compareTo(Object o);
}

可以以如下的方式使用ClassWriter生成:

ClassWriter cw = new ClassWriter(0);
cw.visit(V1_5), ACC_PUBLIC + ACC_ABSTRACT + ACC_INTERFACE, “pkg/Comparable”, null, “java/lang/Object”, new String[] { “pkg/Mesurable” });
cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, “LESS”, “I”, null, new Integer(-1)).visitEnd();
cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, “EQUAL”, “I”, null, new Integer(0)).visitEnd();
cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, “GREATER”, “I”, null, new Integer(1)).visitEnd();
cw.visitMethod(ACC_PUBLIC + ACC_ABSTRACT, “compareTo”, “(Ljava/lang/Object;)I”, null, null).visitEnd();
cw.visitEnd();

 

其中,第二行的visit()方法:

public final void visit(int version, int access, String name, String signature, String superName, String[] interfaces)

第一个参数version指的是Java版本,这里V1_5的常量指的是Java 1.5。

第二个参数access指的是修饰符,这里是一个接口,虽然Java代码中仅声明了public interface,但其隐含的abstract修饰符在这里也要被写出。

第三个参数name指的是类名,已编译类中没有import和package,因而这里必须写完全限定名。另外,在这里需要以Java字节码格式的类型描述符写出。具体Java字节码类型描述符与Java类型的对应,在下一节详细讲解。

第四个参数signature是泛型擦除时使用的,这里不涉及,因而为null。

第五个参数superName指的是父类名,这里是一个接口,虽然Java代码中不需要指定其父类,但其隐含的父类java.lang.Object必须写出。另外,在这里需要以Java字节码格式的类型描述符写出。具体Java字节码类型描述符与Java类型的对应,在下一节详细讲解。

第五个参数interfaces指的是实现/继承的接口名,因为Java支持接口的多实现,因而该参数为一个String数组。另外,在这里需要以Java字节码格式的类型描述符写出。具体Java字节码类型描述符与Java类型的对应,在下一节详细讲解。

第三、四、五行的visitField()方法:

public final FieldVisitor visitField(int access, String name, String desc, String signature, Object value)

第一个参数access指的是修饰符,这里是接口中的变量,虽然Java代码不需要指定,但其隐含的public static final修饰符在这里也要被写出。

第二个参数name指的是变量名。

第三个参数desc指的是类型描述符,I代表Java类型中的int,具体Java字节码类型描述符与Java类型的对应,在下一节详细讲解。

第四个参数signature是泛型擦除时使用的,这里不涉及,因而为null。

第五个参数value指的是变量的值,在Java字节码中,只有static final这种永不会改变的常量字段,才会在这里指定value,其他情况下该字段必须为null。|

第六行的visitMethod()方法:

public final MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions)

第一个参数access指的是修饰符,这里是接口中的方法,虽然Java代码不需要指定,但其隐含的public abstract修饰符在这里也要被写出。

第二个参数name指的是方法名。

第三个参数desc指的是方法描述符,(Ljava/lang/Object;)I代表Java中的返回类型int、入参Object,具体Java字节码方法描述符与Java方法声明的对应,在下一节详细讲解。

第四个参数signature是泛型擦除时使用的,这里不涉及,因而为null。

第五个参数exceptions是异常,这里不涉及,因而为null。

七、Java字节码中的类型描述符与方法描述符

类型描述符与Java类型的对应列表:

Java类型 类型描述符
boolean Z
char C
byte B
short S
int I
float F
long J
double D
Object Ljava/lang/Object;
int[] [I
Object[][] [[Ljava/lang/Object;

方法描述符与Java中的方法声明的对应列表:

Java中的方法声明 方法描述符
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;

 

八、如何使用自定义的ClassVisitor对类进行操控

1、移除成员

在visitXXX方法中直接return null,不把它继续转发下去。

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

 

2、增加类成员

首先在这里回顾一下类信息访问次序

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

因为类信息访问次序的存在,因而只能在visitInnerClass | visitField | visitMethod或者visitEnd中增加类成员。一般选择在visitEnd增加,因为可以保证肯定可以执行,而且只执行一次。但有特殊需求(例如需要计算该类一共有多少个方法)时,也可以选择在visitInnerClass | visitField | visitMethod增加。

增加时,重写visitEnd()方法,在其中调用visitField()、visitMethod()等方法即可。下面的例子是在类中加入public static final String addField = “added field”:

@Override
public void visitEnd() {
    FieldVisitor fv = cv.visitField(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC + Opcodes.ACC_FINAL, "addField", "Ljava/lang/String;", null, "added field");
    if (fv != null) {
        fv.visitEnd();
    }
}

 

3、修改类成员

重写visitField()、visitMethod()等方法,在其中使用自己实现的MethodVisitor、FieldVisitor等类对类成员进行操作。

下面的例子是对类中以test开头的方法使用自定义的TestMethodVisitor操作:

@Override
public MethodVisitor visitMethod(final int access, final String name,
final String desc, final String signature, final String[] exceptions) {
    MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
    MethodVisitor wrappedMv = mv;
    if (mv != null) {
        if (name.startsWith("test")) {
            wrappedMv = new TestMethodVisitor(api, mv);
        }
    }

    return wrappedMv;
}

 

九、介绍Java字节码指令

在ASM中新增/修改方法时,需要使用Java字节码指令才可以实现各种操作。
具体的介绍,在《ASM使用指南》中已有详细的介绍,在此不再赘述,只粘贴过来:

字节代码指令由一个标识该指令的操作码和固定数目的参数组成:
操作码是一个无符号字节值——即字节代码名,由助记符号标识。例如,操作码0用助记符号NOP表示,对应于不做任何操作的指令。
参数是静态值,确定了精确的指令行为。它们紧跟在操作码之后给出。比如GOTO标记指令(其操作码的值为167)以一个指明下一条待执行指令的标记作为参数标记。不要将指令参数与指令操作数相混淆:参数值是静态已知的,存储在编译后的代码中,而操作数值来自操作数栈,只有到运行时才能知道。
字节代码指令可以分为两类:一小组指令,设计用来在局部变量和操作数栈之间传送值;其他一些指令仅用于操作数栈:它们从栈中弹出一些值,根据这些值计算一个结果,并将它压回栈中。

ILOAD, LLOAD, FLOAD, DLOADALOAD 指令读取一个局部变量,并将它的值压到操 作数栈中。它们的参数是必须读取的局部变量的索引 i
ILOAD 用于加载一个 booleanbytecharshortint 局部变量。LLOADFLOADDLOAD 分别用于加载 longfloatdouble 值。(LLOADDLOAD 实际加载两个槽 ii+1)。最后,ALOAD 用于加载任意非基元值,即对 象和数组引用。与之对应,ISTORELSTOREFSTOREDSTOREASTORE 指令从操作数栈 中弹出一个值,并将它存储在由其索引 i 指定的局部变量中。
可以看到,xLOAD 和 xSTORE 指令被赋入了类型(事实上,下面将要看出,几乎所有指令 都被赋予了类型)。它用于确保不会执行非法转换。实际上,将一个值存储在局部变量中,然后再以不同类型加载它,是非法的。例如,ISTORE 1 ALOAD 1 序列是非法的——它允许将一个任意内存位置存储在局部变量 1 中,并将这个地址转换为对象引用!但是,如果向一个局部变量中存储一个值,而这个值的类型不同于该局部变量中存储的当前值,却是完全合法的。这意味着一个局部变量的类型,即这个局部变量中所存值的类型可以在方法执行期间发生变化。
上面已经说过,所有其他字节代码指令都仅对操作数栈有效。它们可以划分为以下类别:

这些指令用于处理栈上的值:POP弹出栈顶部的值,DUP压入顶部栈值的一个副本, SWAP 弹出两个值,并按逆序压入它们,等等。

常量 这些指令在操作数栈压入一个常量值:ACONST_NULL压入nullICONST_0压入 int 值 0,FCONST_0 压入 0fDCONST_0 压入 0dBIPUSH b 压入字节值 bSIPUSH s 压入 shortsLDC cst 压入任意 intfloatlongdoubleStringclass1 常量 cst,等等。

算术与逻辑 这些指令从操作数栈弹出数值,合并它们,并将结果压入栈中。它们没有任何参数。xADDxSUBxMULxDIVxREM 对应于+-*/%运算,其中 xILFD 之一。类似地,还有其他对应于<<>>>>>|&^运算的指令,用于 处理intlong值。
类型变换这些指令从栈中弹出一个值,将其转换为另一类型,并将结果压入栈中。它们对应于 Java 中的类型转换表达式。I2F, F2D, L2D 等将数值由一种数值类型转换为另一种类型。CHECKCAST t 将一个引用值转换为类型 t
对象这些指令用于创建对象、锁定它们、检测它们的类型,等等。例如,NEWtype指令将一个 type 类型的新对象压入栈中(其中 type 是一个内部名)。

字段 这些指令读或写一个字段的值。GETFIELD owner name desc 弹出一个对象引用,并压和其 name 字段中的值。PUTFIELD owner name desc 弹出一个值和一个对象引用,并将这个值存储在它的 name 字段中。在这两种情况下,该对象都必须是 owner 类型,它的字段必须为 desc 类型。GETSTATICPUTSTATIC 是类似指令,但用于静态字段。

方法 这些指令调用一个方法或一个构造器。它们弹出值的个数等于其方法参数个数加 1 (用于目标对象),并压回方法调用的结果。INVOKEVIRTUAL owner name desc 调用在类 owner 中定义的 name 方法,其方法描述符为 descINVOKESTATIC 用于静态方法, INVOKESPECIAL 用于私有方法和构造器,INVOKEINTERFACE 用于接口中定义的方法。最后,对于 Java 7 中的类,INVOKEDYNAMIC 用于新动态方法调用机制。

数组 这些指令用于读写数组中的值。xALOAD指令弹出一个索引和一个数组,并压入此索引处数组元素的值。xASTORE 指令弹出一个值、一个索引和一个数组,并将这个值存 储在该数组的这一索引处。这里的 x 可以是 ILFDA,还可以是 BCS

跳转 这些指令无条件地或者在某一条件为真时跳转到一条任意指令。它们用于编译if、 for、do、while、break 和 continue 指令。例如,IFEQ label 从栈中弹出一个 int 值,如果这个值为 0,则跳转到由这个 label 指定的指令处(否则,正常执行下一条指令)。还有许多其他跳转指令,比如 IFNE 或 IFGE。最后,TABLESWITCH 和 LOOKUPSWITCH 对应于 switch Java 指令。

返回 最后,xRETURN和RETURN指令用于终止一个方法的执行,并将其结果返回给调用者。RETURN 用于返回 void 的方法,xRETURN 用于其他方法。

附录、《ASM4使用指南》中文版

ASM4使用指南