ASM学习系列2:MethodVisitor

1,142 阅读9分钟

方法结构

执行模型

每个线程都有自己的虚拟机栈,用于存储栈帧:每当一个方法被调用,会创建新的栈帧并压入虚拟机栈,方法执行结束后,栈帧从堆栈中弹出。

每个栈帧都包含局部变量表操作数栈

局部变量表存储对象的this引用、方法参数以及方法内声明的变量,如果是实例方法,第0个局部变量保存this,其后跟着方法参数。

long和double类型的数据占用两个连续的局部变量,使用较小的索引值定位。

操作数栈是一个后进先出的栈结构,其最大深度编译时确定,栈帧刚初始化时,操作数栈为空。

下面是一个虚拟机栈的示意图:

image.png

字节码指令

字节码指令由一个指令码和若干个参数组成,如下:

opcode arguments

主要分为两类:

  1. 将局部变量的数据加载到操作数栈
  2. 弹出操作数栈上的数据,执行计算后将结果push到操作数栈中

详细字节码指令请参考java虚拟机规范:docs.oracle.com/javase/spec…

例子

package pkg;
public class Bean {
    private int f;
    public int getF() {
    	return this.f;
    }
    public void setF(int f) {
    	this.f = f;
    }
}

其getter方法字节码如下:

ALOAD 0
GETFIELD pkg/Bean f I
IRETURN

getter方法执行过程栈帧变化内容如下:

image.png

  • a. 栈帧初始化,局部变量表中仅包含this引用
  • b. 执行ALOAD 0之后,this被加载到操作数栈上
  • c. 执行GETFIELD pkg/Bean f I之后,先将this出栈,在把读到的字段f值入栈

异常处理

异常处理器:即catch块,如下实例:

public static void sleep(long d) {
    try {
    	Thread.sleep(d);
    } catch (InterruptedException e) {
    	e.printStackTrace();
    }
}

字节码:

TRYCATCHBLOCK try catch catch java/lang/InterruptedException
try:
    LLOAD 0
    INVOKESTATIC java/lang/Thread sleep (J)V
    RETURN
catch:
    INVOKEVIRTUAL java/lang/InterruptedException printStackTrace ()V
    RETURN

stack map frames(栈映射帧)

stack map frames是StackMapTable属性的元素,其作用于虚拟机的类型检查验证阶段,主要是加速字节码的验证,它表示在字节码指令执行之前,局部变量表的曹伟和操作数栈中值的类型。

前例中getF方法的stack map frames如下:

State of the execution frame before   Instruction
[pkg/Bean] [] 						ALOAD 0
[pkg/Bean] [pkg/Bean]				 GETFIELD
[pkg/Bean] [I] 						IRETURN

第一个方括号内表示局部变量表的类型,第二个括号内表示操作数栈的类型。

为了节省存储空间,只有以下情况才会保存stack map frames,其他情形可以根据之前的状态推断出当前的map frame:

  • 跳转指令的目标位置
  • 异常处理器
  • 无条件跳转指令的目标位置

在前面Bean类添加如下方法:

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

其完整的stack map frames如下:

State of the execution frame before 	  					  Instruction
[pkg/Bean I] [] 											ILOAD 1
[pkg/Bean I] [I] 											IFLT label
[pkg/Bean I] [] 											ALOAD 0
[pkg/Bean I] [pkg/Bean] 				 					 ILOAD 1
[pkg/Bean I] [pkg/Bean I] 				 					 PUTFIELD
[pkg/Bean I] [] 											GOTO end
[pkg/Bean I] [] 											label :
[pkg/Bean I] [] 											NEW
[pkg/Bean I] [Uninitialized(label)] 	  					  DUP
[pkg/Bean I] [Uninitialized(label) Uninitialized(label)] 	    INVOKESPECIAL
[pkg/Bean I] [java/lang/IllegalArgumentException] 			   ATHROW
[pkg/Bean I] [] 											end :
[pkg/Bean I] [] 											RETURN

其中Uninitialized(label)状态表示已经申请内存,但还未调用构造器。

根据节省存储空间规则,优化后的stack map frames如下:

    ILOAD 1
    IFLT label
    ALOAD 0
    ILOAD 1
    PUTFIELD pkg/Bean f I
    GOTO end
label:
F_SAME
    NEW java/lang/IllegalArgumentException
    DUP
    INVOKESPECIAL java/lang/IllegalArgumentException <init> ()V
    ATHROW
end:
F_SAME
    RETURN

由于这段字节码指令中仅有IFLT labelGOTO end两处跳转指令,所以实际上只要保存两个map frame即可,即在label之后和在end之后添加的F_SAME.F_SAME是asm提供的常量,表示具有与前一帧完全相同的局部变量且操作数栈为空。

接口

asm提供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();
}

其接口调用顺序如下:

visitAnnotationDefault?
( visitAnnotation | visitParameterAnnotation | visitAttribute )*
( visitCode
    ( visitTryCatchBlock | visitLabel | visitFrame | visitXxxInsn |
    	visitLocalVariable | visitLineNumber )*
    visitMaxs )?
visitEnd

因此可以用visitCodevisitMaxs检测字节码中方法的起始位置,visitCode代表开始访问方法,visitMaxs表示方法访问结束。

使用MethodVisitor操作字节码时涉及三个组件:

  • ClassReader: 读原始字节码,获取初始MethodVisitor
  • ClassWriter: 获取转换后的MethodVisitor,生成字节码
  • MethodVisitor:实现转换逻辑

ClassWriter选项

ClassWriter构造器接收如下参数:

new ClassWriter(0);
new ClassWriter(ClassWriter.COMPUTE_MAXS)
new ClassWriter(ClassWriter.COMPUTE_FRAMES)
  • 0:栈帧、局部变量和操作数栈的大小都要手动计算
  • COMPUTE_MAXS:自动计算局部变量和操作数栈的大小,单仍需调用visitMaxs,可传任意参数,其内部会忽略参数并重新计算正确的值。仍需手动计算栈帧
  • COMPUTE_FRAMES:栈帧、局部变量和操作数栈的大小都会自动计算,无需调用visitFrame,但仍要调visitMaxs(参数不重要,内部会自动计算)

COMPUTE_MAXS和COMPUTE_FRAMES用起来更方便,但计算更慢,COMPUTE_MAXS比手动计算更慢(文档写的10%,没有详细测试),COMPUTE_FRAMES又会更慢一些(文档说慢两倍)。

生成方法

考虑前文提到的Bean类:

package pkg;
public class Bean {
    private int f;
    public int getF() {
    	return this.f;
    }
    public void setF(int f) {
    	this.f = f;
    }
    public void checkAndSetF(int f) {
        if (f >= 0) {
            this.f = f;
        } else {
            throw new IllegalArgumentException();
        }
	}
}

getF方法的字节码指令如下:

ALOAD 0
GETFIELD pkg/Bean f I
IRETURN

根据前面分析其执行过程栈帧变化可知,它的局部变量表长度为1,并且运行过程中操作数栈的最大深度为1,因此可以用如下asm代码生成此字节码:

mv.visitCode();
mv.visitVarInsn(ALOAD, 0);
mv.visitFieldInsn(GETFIELD, "pkg/Bean", "f", "I");
mv.visitInsn(IRETURN);
mv.visitMaxs(1, 1);
mv.visitEnd();

checkAndSetF的情况更为复杂,通过前面的分析可以知道它的stack map frames如下:

    ILOAD 1
    IFLT label
    ALOAD 0
    ILOAD 1
    PUTFIELD pkg/Bean f I
    GOTO end
label:
F_SAME
    NEW java/lang/IllegalArgumentException
    DUP
    INVOKESPECIAL java/lang/IllegalArgumentException <init> ()V
    ATHROW
end:
F_SAME
    RETURN

因为checkAndSetF的参数是int f,并且函数内没有声明其他局部变量,因此局部变量表的大小为2。在为字段f赋值时,需要将this和参数f都加载到操作数栈上,因此操作数栈最大深度为2。其最终的代码如下:

mv.visitCode();
mv.visitVarInsn(ILOAD, 1);

Label label = new Label();
mv.visitJumpInsn(IFLT, label);
mv.visitVarInsn(ALOAD, 0);
mv.visitVarInsn(ILOAD, 1);
mv.visitFieldInsn(PUTFIELD, "pkg/Bean", "f", "I");
Label end = new Label();
mv.visitJumpInsn(GOTO, end);

mv.visitLabel(label);
mv.visitFrame(F_SAME, 0, null, 0, null);
mv.visitTypeInsn(NEW, "java/lang/IllegalArgumentException");
mv.visitInsn(DUP);
mv.visitMethodInsn(INVOKESPECIAL,
"java/lang/IllegalArgumentException", "<init>", "()V");
mv.visitInsn(ATHROW);

mv.visitLabel(end);
mv.visitFrame(F_SAME, 0, null, 0, null);
mv.visitInsn(RETURN);
mv.visitMaxs(2, 2);
mv.visitEnd();

修改方法

利用MethodVisitor修改方法字节码与ClassVisitor类似:

  • 修改调用参数实现字节码指令的修改
  • 取消调用对应方法删除指令
  • 添加新的调用来添加对应指令

考虑如下代码,统计并打印C.m()的执行时间:

package com.example.test;

public class C {
    public void m() throws Exception {
        Thread.sleep(100);
    }
}

如果手动修改代码,可以在方法开始和结束的地方添加对应的统计代码,如下:

public class C {
    public static long timer;

    public C() {
    }

    public void m() throws Exception {
        timer -= System.currentTimeMillis();
        Thread.sleep(100L);
        timer += System.currentTimeMillis();
        System.out.println("test m used " + timer + "ms");
    }
}

我们需要做以下三件事情来自动生成上面的代码:

  1. 添加long类型的类字段timer
  2. 在方法m开始时添加代码timer -= System.currentTimeMillis();
  3. 在方法结束时添加timer += System.currentTimeMillis();System.out.println("test m used " + timer + "ms");

因此我们需要自定义ClassVisitor和MethodVisitor:

public class TimerVisitor extends ClassVisitor {
    protected TimerVisitor(ClassVisitor classVisitor) {
        super(Opcodes.ASM9, classVisitor);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
        if ("m".equals(name) && "()V".equals(descriptor)) {
            return new TimerMethodTransform(mv);
        }
        return mv;
    }

    @Override
    public void visitEnd() {
        super.visitEnd();
        FieldVisitor fv = cv.visitField(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "timer", "J", null, null);
        if (fv != null) {
            fv.visitEnd();
        }
    }
}

TimerVisitor在visitEnd时通过visitField新增timer字段,在visitMethod中,将方法m的MethodVisitor替换为TimerMethodTransform:

m()方法转换后的字节码如下(在idea系ide中可借助ASM ByteCode Viewer插件辅助查看):

GETSTATIC com/example/test/CTimer.timer : J
INVOKESTATIC java/lang/System.currentTimeMillis ()J
LSUB # --1
PUTSTATIC com/example/test/CTimer.timer : J

LDC 100
INVOKESTATIC java/lang/Thread.sleep (J)V

GETSTATIC com/example/test/CTimer.timer : J
INVOKESTATIC java/lang/System.currentTimeMillis ()J
LADD  # --2
PUTSTATIC com/example/test/CTimer.timer : J
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
NEW java/lang/StringBuilder
DUP
INVOKESPECIAL java/lang/StringBuilder.<init> ()V
LDC "test m used "
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
GETSTATIC com/example/test/CTimer.timer : J
INVOKEVIRTUAL java/lang/StringBuilder.append (J)Ljava/lang/StringBuilder;
LDC "ms"
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
RETURN
LOCALVARIABLE this Lcom/example/test/CTimer;
MAXSTACK = 4
MAXLOCALS = 1

由于修改后的代码没有新增局部变量,因此局部变量表MAXLOCALS仍为1;仔细分析上面的字节码指令,会发现用到最多操作数栈的指令是 --1 --2处,其中

GETSTATIC com/example/test/CTimer.timer : J
INVOKESTATIC java/lang/System.currentTimeMillis ()J

连续把两个long类型的数值压入操作数栈,因此其最大的操作数栈为MAXSTACK4。

根据如上字节码指令,我们可以得到如下代码,重写visitCode方法表示在方法前插入字节码指令。方法通常由一系列的RETURN指令或ATHROW指令介绍,因此在visitInsn中判断当前的操作码,如果是RETURN指令或ATHROW指令,则插入代码:

public class TimerMethodTransform extends MethodVisitor {
    protected TimerMethodTransform(MethodVisitor methodVisitor) {
        super(Opcodes.ASM9, methodVisitor);
    }

    @Override
    public void visitCode() {
        super.visitCode();
        if (mv != null) {
            mv.visitFieldInsn(Opcodes.GETSTATIC, "com/example/test/C", "timer", "J");
            mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
            mv.visitInsn(Opcodes.LSUB);
            mv.visitFieldInsn(Opcodes.PUTSTATIC, "com/example/test/C", "timer", "J");
        }
    }

    @Override
    public void visitInsn(int opcode) {
        if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN || opcode == Opcodes.ATHROW) && mv != null) {
            mv.visitFieldInsn(Opcodes.GETSTATIC, "com/example/test/C", "timer", "J");
            mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
            mv.visitInsn(Opcodes.LADD);
            mv.visitFieldInsn(Opcodes.PUTSTATIC, "com/example/test/C", "timer", "J");

            mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
            mv.visitTypeInsn(Opcodes.NEW, "java/lang/StringBuilder");
            mv.visitInsn(Opcodes.DUP);
            mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);
            mv.visitLdcInsn("test m used ");
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
            mv.visitFieldInsn(Opcodes.GETSTATIC, "com/example/test/C", "timer", "J");
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false);
            mv.visitLdcInsn("ms");
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
        }
        super.visitInsn(opcode);
    }

    @Override
    public void visitMaxs(int maxStack, int maxLocals) {
        super.visitMaxs(4, maxLocals);
    }
}

结语

这篇文章总结了与Method相关的字节码虚拟机知识,并通过几个简单的例子对MethodVisitor相关api进行熟悉,统计方法执行时间的例子是一种无状态的方法转换,更为复杂的方法转换设计字节码指令的状态,在做这类转换时需要仔细设计状态机,由于暂时没有相关的转换需求,这里就不进行实例讲解了。

参考

asm4-guide