字节码及ASM入门

一、前言

本篇为字节码及 ASM 使用学习笔记。学,就硬学。
大部分转载自 https://segmentfault.com/a/1190000009956534。

二、字节码

1. 字节码定义

字节码(bytecode)是一种包含执行程序、由一序列 op 代码/数据对 组成的二进制文件。字节码是一种中间码,它比机器码更抽象,需要直译器转译后才能成为机器码的中间代码。通常情况下它是已经经过编译,但与特定机器码无关。字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。

字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。

Java 字节码的执行是由 JVM 执行引擎来完成的。

2. 字节码执行

方法调用在 JVM 中转换成的是字节码执行,字节码指令执行的数据结构就是栈帧(stack frame)。也就是在虚拟机栈中的栈元素。虚拟机会为每个方法分配一个栈帧,因为虚拟机栈是 LIFO 的,所以当前线程正在活动的栈帧,也就是栈顶的栈帧,JVM规范中称之为“ CurrentFrame ”,这个当前栈帧对应的方法就是“CurrentMethod”。字节码的执行操作,指的就是对当前栈帧数据结构进行的操作。

JVM 运行时的数据区的结构如下图:

栈帧的数据结构主要分为四个部分:局部变量表、操作数栈、动态链接以及方法返回地址(包括正常调用和异常调用的完成结果)。下面就一一介绍下这四种数据结构。

局部变量表 local variables

当方法被调用时,参数会传递到从 0 开始的连续的局部变量表的索引位置上。栈帧中局部变量表的长度存储在类或接口的二进制表示中。阅读 Class 文件会找到 Code 属性,所以能知道 local variables 的最大长度是在编译期间决定的。

一个局部变量表的占用了 32 位的存储空间(一个存储单位称之为 slot ,槽),所以可以存储一个 boolean、byte、char、short、float、int、refrence 和 returnAdress 数据, long 和 double 需要 2 个连续的局部变量表来保存,通过较小位置的索引来获取。如果被调用的是实例方法,那么第 0 个位置存储 “this” 关键字代表当前实例对象的引用。

操作数栈 operand stack

操作数栈同局部变量表一样,也是编译期间就能决定了其存储空间(最大的单位长度),通过 Code 属性存储在类或接口的字节流中。操作数栈也是个 LIFO 栈。

操作数栈是在JVM字节码执行一些指令时创建的,主要是把局部变量表中的变量压入操作数栈,在操作数栈中进行字节码指令的操作,再将变量出操作数栈,结果入操作数栈。

同局部变量表,除了 long 和 double ,其他类型数据都只占用一个栈的单位深度。

动态链接

每个栈帧指向运行时常量池中该栈帧所属的方法的引用,也就是字节码的发放调用的引用。动态链接就是将符号引用所表示的方法,转换成方法的直接引用。

加载阶段或第一次使用时转化为直接引用的(将变量的访问转化为访问这些变量的存储结构所在的运行时内存位置)就叫做静态解析。

JVM的动态链接还支持运行期转化为直接引用。也可以叫做 Late Binding ,晚期绑定。动态链接是 java 灵活 OO 的基础结构。

方法返回地址

方法正常退出,JVM 执行引擎会恢复上层方法局部变量表操作数栈并把返回值压入调用者的栈帧的操作数栈,PC 计数器的值就会调整到方法调用指令后面的一条指令。

这样使得当前的栈帧能够和调用者连接起来,并且让调用者的栈帧的操作数栈继续往下执行。

方法的异常调用完成,主要是 JVM 抛出的异常,如果异常没有被捕获住,或者遇到 athrow 字节码指令显示抛出,那么就没有返回值给调用者。

注:

  1. 操作 long double 类型数据,一定要分配两个 slot ,如果分配了一个 slot 不会报错,但会导致数据丢失;
  2. 实例方法的第 0 个位置存储“ this ”关键字代表当前实例对象的引用。

3. 字节码指令集

注:大多数的指令有前缀和(或)后缀来表明其操作数的类型。

前/后缀 操作数类型
i 整数
l 长整数
s 短整数
b 字节
c 字符
f 单精度浮点数
d 双精度浮点数
z 布尔值
a 引用

加载和储存指令

加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传输。

作用 指令
将一个局部变量加载到操作数栈 iload,iload_<n>,lload,lload_<n>
float, fload_<n>、dload,dload_<n>
aload,aload_<n>
将一个数值从操作数栈存储到局部变量表 istore,istore_<n>,lstore,lstore_<n>
fstore,fstore_<n>,dstore,dstore_<n>
astore,astore_<n>
将常量加载到操作数栈 bipush,sipush
ldc,ldc_w,ldc2_w
aconst_null
iconst_ml,iconst_<i>,lconst_<l>
fconst_<f>,dconst_<d>
局部变量表访问索引 wide

一部分以尖括号结尾的指令代表了一组指令,如 iload_<i>,代表了 iload_0,iload_1 等,这几组指令都是带有一个操作数的通用指令。

运算指令

算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。

作用 指令
加法 iadd,ladd,fadd,dadd
减法 isub,lsub,fsub,dsub
乘法 imul,lmul,fmul,dmul
除法 idiv,ldiv,fdiv,ddiv
求余 irem,lrem,frem,drem
取反 ineg,leng,fneg,dneg
位移 ishl,ishr,iushr,lshl,lshr,lushr
按位或指令 ior,lor
按位与指令 iand,land
按位异或指令 ixor,lxor
局部变量自增指令 iinc
比较指令 dcmpg,dcmpl,fcmpg,fcmpl,lcmp

Java 虚拟机没有明确规定整型数据溢出的情况,但规定了处理整型数据时,只有除法和求余指令出现除数为0时会导致虚拟机抛出异常。

Java 虚拟机要求在浮点数运算的时候,所有结果否必须舍入到适当的精度,如果有两种可表示的形式与该值一样,会优先选择最低有效位为零的。称之为最接近数舍入模式。

浮点数向整数转换的时候,Java 虚拟机使用 IEEE 754 标准中的向零舍入模式,这种模式舍入的结果会导致数字被截断,所有小数部分的有效字节会被丢掉。

类型转换指令

类型转换指令将两种 Java 虚拟机数值类型相互转换,这些操作一般用于实现用户代码的显式类型转换操作。

JVM 直接就支持宽化类型转换(小范围类型向大范围类型转换):

  1. int 类型到 long,float,double 类型
  2. long 类型到 float,double 类型
  3. float 到 double 类型

但在处理窄化类型转换时,必须显式使用转换指令来完成。

作用 指令
窄化类型转换 i2b,i2c,i2s,l2i,f2i,f2l,d2i,d2l,d2f

将 int 或 long 窄化为整型 T 的时候,仅仅简单的把除了低位的 N 个字节以外的内容丢弃,N 是 T 的长度。这有可能导致转换结果与输入值有不同的正负号。
在将一个浮点值窄化为整数类型 T(仅限于 int 和 long 类型),将遵循以下转换规则:

  1. 如果浮点值是NaN , 转换结果就是 int 或 long 类型的 0
  2. 如果浮点值不是无穷大,浮点值使用 IEEE 754 的向零舍入模式取整,获得整数 v, 如果 v 在 T 表示范围之内,那结果就是 v
  3. 否则,根据 v 的符号, 转换为 T 所能表示的最大或者最小正数

对象创建与访问指令

虽然类实例和数组都是对象,Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。

作用 指令
创建实例 new
创建数组 newarray,anewarray,multianewarray
访问字段 getfield,putfield,getstatic,putstatic
把数组元素加载到操作数栈 baload,caload,saload,ialoa,,laload,faload,daload,aaload
将操作数栈的数值存储到数组元素中执行 bastore,castore,castore,sastore,iastore,fastore,dastore,aastore
取数组长度 arraylength
检查实例类型 instanceof,checkcast

操作数栈管理指令

如同操作一个普通数据结构中的堆栈那样,Java 虚拟机提供了一些用于直接操作操作数栈的指令。

作用 指令
将操作数栈的栈顶一个或两个元素出栈 pop,pop2
复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶 dup,dup2,dup_x1,dup2_x1,dup_x2,dup2_x2
将栈最顶端的两个数值互换 swap

控制转移指令

让JVM有条件或无条件从指定指令而不是控制转移指令的下一条指令继续执行程序。

作用 指令
条件分支 ifeq,iflt,ifle,ifne,ifgt,ifge
ifnull,ifnotnull
if_cmpeq,if_icmpne,if_icmlt,if_icmpgt等
复合条件分支 tableswitch,lookupswitch
无条件分支 goto,goto_w,jsr,jsr_w,ret

JVM 中有专门的指令集处理 int 和 reference 类型的条件分支比较操作,为了可以无明显标示一个实体值是否是 null ,有专门的指令检测 null 值。
boolean/byte/char/short 类型的条件分支比较操作,都使用 int 类型的比较指令完成,而 long/float/double 条件分支比较操作,由相应类型的比较运算指令,运算指令会返回一个整型值到操作数栈中,随后再执行 int 类型的条件比较操作完成整个分支跳转。

各种类型的比较都最终会转化为 int 类型的比较操作。

方法调用和返回指令

方法调用

指令 作用
invokevirtual 调用对象的实例方法,根据对象的实际类型进行分派(虚拟机分派)
invokeinterface 调用接口方法,在运行时搜索一个实现这个接口方法的对象,找出合适的方法进行调用
invokespecial 调用需要特殊处理的实例方法,包括实例初始化方法,私有方法和父类方法
invokestatic 调用类方法(static)

返回指令

方法返回指令是根据返回值的类型区分的,包括 ireturn (返回值是boolean/byte/char/short/int),lreturn,freturn,drturn ,areturn,另外一个return供void方法,实例初始化方法,类和接口的类初始化方法使用。

异常处理指令

在 Java 程序中显式抛出异常的操作(throw语句)都由 athrow 指令来实现,除了用 throw 语句显示抛出异常情况外,Java 虚拟机规范还规定了许多运行时异常会在其他 Java 虚拟机指令检测到异常状况时自动抛出。
在 Java 虚拟机中,处理异常不是由字节码指令来实现的,而是采用异常表来完成的。

同步指令

方法级的同步是隐式的,无需通过字节码指令来控制,它实现在方法调用和返回操作中。虚拟机从方法常量池中的方法标结构中的 ACC_SYNCHRONIZED 标志区分是否是同步方法。方法调用时,调用指令会检查该标志是否被设置,若设置,执行线程持有 moniter ,然后执行方法,最后完成方法时释放 moniter 。
同步一段指令集序列,通常由 synchronized 块标示,JVM指令集中有 monitorenter 和 monitorexit 来支持 synchronized 语义。

三、字节码查看

Java 字节码类文件(.class)是 Java 编译器编译 Java 源文件(.java)产生的“目标文件”。它是一种8位字节的二进制流文件, 各个数据项按顺序紧密的从前向后排列, 相邻的项之间没有间隙, 这样可以使得 class 文件非常紧凑, 体积轻巧, 可以被 JVM 快速的加载至内存, 并且占据较少的内存空间(方便于网络的传输)。

Java 源文件在被 Java 编译器编译之后, 每个类(或者接口)都单独占据一个 class 文件, 并且类中的所有信息都会在 class 文件中有相应的描述, 由于 class 文件很灵活, 它甚至比 Java 源文件有着更强的描述能力。

一个 Java 类文件大致可以归为 10 个项:

  • Magic:该项存放了一个 Java 类文件的魔数(magic number)和版本信息。一个 Java 类文件的前 4 个字节被称为它的魔数。每个正确的 Java 类文件都是以 0xCAFEBABE 开头的,这样保证了 Java 虚拟机能很轻松的分辨出 Java 文件和非 Java 文件。
  • Version:该项存放了 Java 类文件的版本信息,它对于一个 Java 文件具有重要的意义。因为 Java 技术一直在发展,所以类文件的格式也处在不断变化之中。类文件的版本信息让虚拟机知道如何去读取并处理该类文件。
  • Constant Pool:该项存放了类中各种文字字符串、类名、方法名和接口名称、final 变量以及对外部类的引用信息等常量。虚拟机必须为每一个被装载的类维护一个常量池,常量池中存储了相应类型所用到的所有类型、字段和方法的符号引用,因此它在 Java 的动态链接中起到了核心的作用。常量池的大小平均占到了整个类大小的 60% 左右。
  • Access_flag:该项指明了该文件中定义的是类还是接口(一个 class 文件中只能有一个类或接口),同时还指名了类或接口的访问标志,如 public,private, abstract 等信息。
  • This Class:指向表示该类全限定名称的字符串常量的指针。
  • Super Class:指向表示父类全限定名称的字符串常量的指针。
  • Interfaces:一个指针数组,存放了该类或父类实现的所有接口名称的字符串常量的指针。以上三项所指向的常量,特别是前两项,在我们用 ASM 从已有类派生新类时一般需要修改:将类名称改为子类名称;将父类改为派生前的类名称;如果有必要,增加新的实现接口。
  • Fields:该项对类或接口中声明的字段进行了细致的描述。需要注意的是,fields 列表中仅列出了本类或接口中的字段,并不包括从超类和父接口继承而来的字段。
  • Methods:该项对类或接口中声明的方法进行了细致的描述。例如方法的名称、参数和返回值类型等。需要注意的是,methods 列表里仅存放了本类或本接口中的方法,并不包括从超类和父接口继承而来的方法。使用 ASM 进行 AOP 编程,通常是通过调整 Method 中的指令来实现的。
  • Class attributes:该项存放了在该文件中类或接口所定义的属性的基本信息。

对于一个类字节码的查看,我们可以使用 Eclipse 的 bytecode 插件进行方便的查看,并且此插件还提供了生成相关 ASM 代码的能力。

四、ASM 的使用

有了上述基础知识的简单铺垫,接下来开始学习 ASM 字节码操纵的库的使用。

ASM 字节码处理框架是用 Java 开发的而且使用基于访问者模式生成字节码及驱动类到字节码的转换,通俗的讲,它就是对 class 文件的 CRUD,经过 CRUD 后的字节码可以转换为类。

由于 Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。

ASM 库主要由以下组成:

  • Core:为其他包提供基础的读、写、转化Java字节码和定义的API,并且可以生成Java字节码和实现大部分字节码的转换,在 访问者模式和 ASM 中介绍的几个重要的类就在 Core API 中:ClassReader、ClassVisitor 和 ClassWriter 类。
  • Tree:提供了 Java 字节码在内存中的表现。
  • Commons:提供了一些常用的简化字节码生成、转换的类和适配器。
  • Util:包含一些帮助类和简单的字节码修改类,有利于在开发或者测试中使用。
  • XML:提供一个适配器将XML和SAX-comliant转化成字节码结构,可以允许使用XSLT去定义字节码转化。

可根据需求使用,如果想全部加载可用 asm-all 进行配置。

1. 重点类查看

首先来看下一些比较重要的类

ClassVisitor 抽象类

位于包 org.objectweb.asm 下,抽象类中提供了一些方法,这些方法会对类中相应的部分进行操作,并且必须按照如下顺序:
visit [visitSource] [visitModule] [visitNestHost] [visitOuterClass] ( visitAnnotation|visitTypeAnnotation|visitAttribute) (visitNestMember|visitInnerClass|visitField|visitMethod}) visitEnd

  • void visit(int version, int access, String name, String signature, String superName, String[] interfaces)
    当扫描类时第一个调用的方法,主要用于类声明使用。下面是对方法中各个参数的示意:visit( 类版本 , 修饰符 , 类名 , 泛型信息 , 继承的父类 , 实现的接口)
  • AnnotationVisitor visitAnnotation(String desc, boolean visible)
    当扫描器扫描到类注解声明时进行调用。下面是对方法中各个参数的示意:visitAnnotation(注解类型 , 注解是否可以在 JVM 中可见)。
  • FieldVisitor visitField(int access, String name, String desc, String signature, Object value)
    当扫描器扫描到类中字段时进行调用。下面是对方法中各个参数的示意:visitField(修饰符 , 字段名 , 字段类型 , 泛型描述 , 默认值)
  • MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions)
    当扫描器扫描到类的方法时进行调用。下面是对方法中各个参数的示意:visitMethod(修饰符 , 方法名 , 方法签名 , 泛型信息 , 抛出的异常)
  • void visitEnd()
    当扫描器完成类扫描时调用

在使用时,我们需要继承此类,并指定asm api的版本。

ClassReader 类

位于包 org.objectweb.asm 下,这个类会将 .class 文件读入到 ClassReader 中的字节数组中,它的 accept() 方法接受一个 ClassVisitor 实现类,并按照顺序调用 ClassVisitor 中的方法。

此函数的构造方法接收 InputStream/byte[] 类字节码文件,或 String 类名通过 ClassLoader.getSystemResourceAsStream() 获取,最后转为 byte[]。

同时,此类还提供了一些 get 方法用来获取读入类的信息。

ClassWriter 类

位于包 org.objectweb.asm 下,ClassWriter 是一个 ClassVisitor 的子类,是和 ClassReader 对应的类,ClassReader 是将 .class 文件读入到一个字节数组中,ClassWriter 是将修改后的类的字节码内容以字节数组的形式输出。

FieldVisitor 抽象类

位于包 org.objectweb.asm 下,FieldVisitor 是一个抽象类,当 ASM 的 ClassReader 读取到 Field 时就转入 FieldVisitor 接口处理。作为 ClassVisitor.visitField 的返回值。

MethodVisitor 抽象类

位于包 org.objectweb.asm 下,当 ASM 的 ClassReader 读取到 Method 时就转入 MethodVisitor 接口处理。作为 ClassVisitor.visitMethod 的返回值。

AdviceAdapter 类

位于包 org.objectweb.asm.commons 下,AdviceAdapter 是 MethodVisitor 的子类,使用 AdviceAdapter 可以更方便的修改方法的字节码。

其中比较重要的几个方法如下:

  • void visitCode():表示 ASM 开始扫描这个方法。
  • void onMethodEnter():进入这个方法。
  • void onMethodExit():即将从这个方法出去。
  • void onVisitEnd():表示方法扫码完毕。

2. 实现流程

使用 ASM 编写一个类

这里为了举例创造一个需求,假设我们有一个类 Father。

package org.su18;

public class Father {

	protected String str = "who is your daddy?";


	public void say() {
		System.out.println("Father says:" + this.str);
	}
}

然后我们想手写一个类字节码 Son.class,实现大概如下的功能

package org.su18;

public class Son extends Father {

	public static void main(String[] args) {
		Son son = new Son();
		son.say();
	}

	@Override
	public void say() {
		super.say();
		System.out.println("Son says:I see dead people!");
	}

}

运行这个类的 main 方法显而易见的输出是:

接下来我们来手写这个类,直接上代码,每一步的注释都很详细,细节可以参考上面几章:

package org.su18;


import org.objectweb.asm.*;

public class SonClassGenerator {

	/**
	 * 生成类
	 *
	 * @return 返回类字节码
	 */
	public static byte[] generate() {

		// 实例化 ClassWriter,参数 flag 取值为0、1、2
		// 0时表示需要手动计算最大操作数栈、局部变量表、桢变化;
		// ClassWriter.COMPUTE_MAXS表示自动计算局部变量表和操作数栈,但是必须要调用visitMaxs,方法参数会被忽略。
		// 桢变化需要手动计算ClassWriter.COMPUTE_FRAMES表示全自动计算,但是必须要调用visitMaxs,方法参数会被忽略。
		// 但ClassWriter.COMPUTE_MAXS比0慢10%,比COMPUTE_FRAMES慢一倍。
		ClassWriter cw = new ClassWriter(0);


		// 声明一个类,使用JDK1.8版本,public的类,类名 org.su18.Son,不是泛型类,父类是org.su18.Father,没有实现任何接口
		cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC + Opcodes.ACC_SUPER, "org/su18/Son", null, "org/su18/Father", null);

		// 我们知道在一个对象没有构造函数时,会自动生成一个无参的构造函数,在有父类的情况下,可直接继承父类中无参构造函数
		// public 方法,方法名 <init> 相当于 Constants.ACC_CONSTRUCTOR,
		// 描述符 ()V 表示函数,无参数,无返回值,方法签名 null,方法异常的内部名称,为 null
		MethodVisitor constructor = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
		// 非抽象方法使用 visitCode() 开始访问方法,可以理解为函数体的开始
		constructor.visitCode();
		// visitXXXInsn()来填充函数,添加方法实现的字节码
		// 非 static 方法的第一个参数为 this 对象
		// 第一个参数为指令,第二个参数是局部变量表中的地址
		constructor.visitVarInsn(Opcodes.ALOAD, 0);
		// 执行父类的构造函数,调用父类方法 INVOKESPECIAL,方法所在类名,方法名,方法返回值,函数所在类是否为接口
		constructor.visitMethodInsn(Opcodes.INVOKESPECIAL, "org/su18/Father", "<init>", "()V", false);
		// 执行指令,构造函数没有返回值,所以是 RETURN
		constructor.visitInsn(Opcodes.RETURN);
		// 通过 visitMaxs 来指定局部变量表与操作数栈的大小,也可以理解为函数体的结束
		constructor.visitMaxs(1, 1);
		// 访问方法结束
		constructor.visitEnd();


		// 创建 say 方法,此方法重写父类方法,并使用 super 调用父类 say 方法
		// 创建 MethodVisitor
		MethodVisitor say = cw.visitMethod(Opcodes.ACC_PUBLIC, "say", "()V", null, null);
		say.visitCode();
		say.visitVarInsn(Opcodes.ALOAD, 0);
		// 调用父类方法
		say.visitMethodInsn(Opcodes.INVOKESPECIAL, "org/su18/Father", "say", "()V", false);
		// 获取一个java.io.PrintStream对象
		say.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
		// 将int, float或 String 型常量值从常量池中推送至栈顶
		say.visitLdcInsn("Son says:I see dead people!");
		// 添加执行的方法,执行的是参数为字符串,无返回值的println函数
		say.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
		say.visitInsn(Opcodes.RETURN);
		say.visitMaxs(2, 1);
		say.visitEnd();

		// 创建 main 方法

		MethodVisitor main = cw.visitMethod(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC, "main",
				"([Ljava/lang/String;)V", null, null);
		main.visitCode();
		//标识位置
		Label l0 = new Label();
		main.visitLabel(l0);
		// new 一个 Son 类,并赋值给对象
		main.visitTypeInsn(Opcodes.NEW, "org/su18/Son");
		// 压入栈顶
		main.visitInsn(Opcodes.DUP);
		// 我们知道 new 一个实例就是调用构造方法
		main.visitMethodInsn(Opcodes.INVOKESPECIAL, "org/su18/Son", "<init>", "()V", false);
		// 储存在局部变量表的第一个位置
		main.visitVarInsn(Opcodes.ASTORE, 1);
		// 标识位置
		Label l1 = new Label();
		main.visitLabel(l1);
		// 将局部变量加载至栈
		main.visitVarInsn(Opcodes.ALOAD, 1);
		main.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "org/su18/Son", "say", "()V", false);
		// 标识位置
		Label l2 = new Label();
		main.visitLabel(l2);

		main.visitInsn(Opcodes.RETURN);
		// 标识位置
		Label l3 = new Label();
		main.visitLabel(l3);
		// visitLocalVariable,本地变量名,描述符,签名,与该局部变量的范围相对应的第一条指令,最后一条指令,局部变量索引
		main.visitLocalVariable("args", "[Ljava/lang/String;", null, l0, l3, 0);
		main.visitLocalVariable("son", "Lorg/su18/Son;", null, l1, l3, 1);
		main.visitMaxs(2, 2);
		main.visitEnd();

		// ClassWriter 访问结束
		cw.visitEnd();

		// 返回 byte[]
		return cw.toByteArray();

	}


	public static void main(String[] args) throws Exception {

		String   className = "org.su18.Son";
		Class<?> clazz     = new MyClassLoader().defineClassForName(className, generate());
		clazz.getMethod("main",String[].class).invoke(clazz.newInstance(),new String[1]);

	}
}

在这里,我们为了方便验证,自定义了一个 ClassLoader ,使用 defineClass 直接加载类字节码并使用反射调用。

package org.su18;

public class MyClassLoader extends ClassLoader {

	public Class<?> defineClassForName(String name, byte[] data) {
		return this.defineClass(name, data, 0, data.length);
	}
}

运行结果:

这部分需要注意的是要自行计算局部变量表与操作数栈,一定要注意算准点,不然会导致溢出或无法匹配。

也可以使用 bytecode 插件快速生成 ASM 代码,可以直接拿过来用,而且用来参考还是很方便的。

第一次写这个类的时候我也很懵,感觉不知道自己在干嘛,但是实际想想,这跟直接写 Java 代码实际没差,只不过我们写 java 代码时用编辑器,运行时也由 Idea 等编辑器帮我们编译成 class 文件,这致使我们忽略了很多的细节,比如修饰符,参数顺序,对象本身等等。在调用各种函数时,我们往往也只关注封装的接口,在实际使用时,我们也应该更加关注细节部分。

使用 ASM 更改/增强一个类

在已有的类基础上,我们想要使用 agent 技术在类加载之前对 class 文件进行增强,完成自定义逻辑。
于是就使用 ASM 结合上一篇的 Instrumentation 写一个小 demo 。

我们沿用上面的 Father 和 Son,我们想在 class 文件载入 JVM 前对 Son 这个类的 main 方法和 say 方法进入和退出之前都进行 Hook ,插入我们自己的逻辑代码。

首先看下实现流程:

  1. 实现自己的 Transformer 和 premain,用于 agent ;
  2. 实现自己的 ClassVisitor,重写一些方法,实现自己的调用逻辑。
  3. 实现自己的 MethodVisitor,继承至 AdviceAdapter,因为后者实现封装了很多方法,可供我们直接调用,并且提供了 onMethodEnter()onMethodExit() 供我们重写和调用。

大致的调用链就是,使用 agent 后,通过我们自定义的 Transformer 截取到类字节码,然后使用我们自定义的 ClassVisiter 访问并解析类的相关信息,对类方法字节码的改变使用自定义的 MethodVisitor 进行增强。

代码过多就不直接粘了,已经上传至 https://github.com/JosephTribbianni/Interesting。

最后的输出结果为:

五、新手入门

在有了上述基础后,我们来看一个实战项目,本项目为园长在上古时代写的专门 Hook 并记录 Java 表达式语言执行的 Agent 小程序,用来做新手入门正好,这里读一下源码分析一下实现逻辑。
项目地址:https://github.com/anbai-inc/javaweb-expression

首先定义一个内部类 MethodHookDesc 用来储存 Hook 到的方法的信息,包括类名、方法名、方法参数描述符。

然后定义了一个 ArrayList 储存想 Hook 的类的相关信息,就是 MethodHookDesc 对象们。

premain 方法里添加的新的 ClassFileTransformer ,并自行实现 transform 方法。

如果传入的 className 与 ArrayList 里存储的类名一致的情况下,调用 ASM 进行类的访问。

流程与我们上一章写的简单的 Demo 是一致的,实例化 ClassWriter-> 自定义 ClassVisitor,重写visit 和 visitMethod 方法,在 visitMethod 方法中调用自定义的 MethodVisitor 方法,进行逻辑处理,将自己的字节码写入->调用 ClassWriter 的 accept 方法传入自己的 ClassVisitor-> 返回类字节码。

可以看到这里是在 visitCode() 进入代码体时调用了本类的 expression 方法。

打印了调用链。

六、参考链接

https://www.baidu.com/s?wd=%E5%A6%82%E4%BD%95%E9%98%B2%E8%84%B1%E5%8F%91