ASM 基础使用之代码修改

一、前言

本篇我们将介绍如何用ASM工具对代码进行插入和修改。先说下我们这章节的任务,如下:

对已有的Class文件的方法里插入方法执行时间的统计

二、加载Class文件

想要修改一个Class文件的内容,那肯定要先把Class文件加载进内存,不然你修改个&…(%¥。正好,在ASM工具里,有一个ClassReader类就可以帮助我们读取Class文件。接下来让我们看看如何使用

2.1 准备一个Class文件
准备一个Class文件,你可以先写一个Java文件,通过编译器编译或者自己用javac命令都可以。

 2.2 读取Class文件

 直接上代码(具体路径看自己Class文件所在)

public class ASMLoadDemo {
 
    public static void main(String[] args) throws Exception {
     insertCodeByLoadClass("D:\\20210426\\code\\JavaProjec\\JMMDemo\\out\\production\\JMMDemo\\asm\\InjectTest.class");
    }
 
    /**
     * 加载Class插入代码
     *
     * @param path
     */
    public static void insertCodeByLoadClass(String path) {
        try {
            //1.获取Class文件
            FileInputStream fis = new FileInputStream(path);
            //2.读取Class文件
            ClassReader classReader = new ClassReader(fis);
            //3.准备
            ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
            //4.重点!! 开始插桩 对代码进行扫描,进行代码插入。
            classReader.accept(new MyClassVisitor(Opcodes.ASM9, classWriter), ClassReader.EXPAND_FRAMES);
            //5.插桩结束 输出字节码
            byte[] bytes = classWriter.toByteArray();
            //6.保存文件
            FileOutputStream fos = new FileOutputStream("src/asm/InjectTest2.class");
            fos.write(bytes);
            fos.close();
            fis.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}


 MyClassVisitor代码

  

    /**
     * 用来观察(扫描)类信息,包括变量、方法
     */
    static class MyClassVisitor extends ClassVisitor {
 
        public MyClassVisitor(int api) {
            super(api);
        }
 
        public MyClassVisitor(int api, ClassVisitor classVisitor) {
            super(api, classVisitor);
        }
 
        /**
         * 当有一个方法,就执行这个回调一次,类中有多个方法,这里就会执行多次
         */
        @Override
        public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
            System.out.println("读到一个方法visitMethod: " + name);
            MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
            return new MyMethodVisitor(api, methodVisitor, access, name, descriptor);
        }
 
    }


 MyMethodVisitor代码

    

    /**
     * 用来观察(扫描)方法里的代码
     */
    static class MyMethodVisitor extends AdviceAdapter {
 
        protected MyMethodVisitor(int api, MethodVisitor methodVisitor, int access, String name, String descriptor) {
            super(api, methodVisitor, access, name, descriptor);
        }
 
        int startTimeIndex;//startTime在局部变量表的位置
        int endTimeIndex;//endTime在局部变量表的位置
 
        /**
         * 方法进入的时候调用
         */
        @Override
        protected void onMethodEnter() {
            super.onMethodEnter();
            //调用System.currentTimeMillis方法,调用完后将返回值推到栈顶
            invokeStatic(Type.getType("Ljava/lang/System;"), new Method("currentTimeMillis", "()J"));
            //获取当前局部变量表可插入的位置,不能乱传,不然会影响下一个值的索引获取,这里你传DOUBLE_TYPE或者LONG_TYPE,都是一样的
            startTimeIndex = newLocal(Type.LONG_TYPE);
            //把栈顶的值(也就是System.currentTimeMillis的返回值)保存到局部变量表startTimeIndex位置
            storeLocal(startTimeIndex);
            System.out.println("onMethodEnter getName: " + getName() + " startIndex " + startTimeIndex);
        }
 
        /**
         * 当方法退出的时候调用
         */
        @Override
        protected void onMethodExit(int opcode) {
            super.onMethodExit(opcode);
            //调用System.currentTimeMillis方法,调用完后将返回值推到栈顶
            invokeStatic(Type.getType("Ljava/lang/System;"), new Method("currentTimeMillis", "()J"));
            //获取当前局部变量表可插入的位置
            endTimeIndex = newLocal(Type.LONG_TYPE);
            //把栈顶的值(也就是System.currentTimeMillis的返回值)保存到局部变量表endTimeIndex位置
            storeLocal(endTimeIndex);
            //调用System.out,调用完后栈顶会有一个
            getStatic(Type.getType("Ljava/lang/System;"), "out", Type.getType("Ljava/io/PrintStream;"));
            //new 一个 StringBuilder();并将其引用值压入栈顶
            newInstance(Type.getType("Ljava/lang/StringBuilder;"));
            //复制栈顶数值并将复制值压入栈顶(对象StringBuilder的引用)
            dup();
            //调用StringBuilder的构造方法
            invokeConstructor(Type.getType("Ljava/lang/StringBuilder;"), new Method("<init>", "()V"));
            //将"execute time=" 从常量池中推送至栈顶
            visitLdcInsn("execute time=");
            //调用StringBuilder的append方法,将栈顶的值添加进StringBuilder
            invokeVirtual(Type.getType("Ljava/lang/StringBuilder;"), new Method("append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;"));
            //把局部变量表中的endTimeIndex位置元素,压入栈顶
            loadLocal(endTimeIndex);
            //把局部变量表中的startTimeIndex位置元素,压入栈顶
            loadLocal(startTimeIndex);
            //将栈顶两元素进行相减,把结果再压入栈顶
            math(SUB, Type.LONG_TYPE);
            //调用append方法,将栈顶值添加
            invokeVirtual(Type.getType("Ljava/lang/StringBuilder;"), new Method("append", "(J)Ljava/lang/StringBuilder;"));
            //将" ms" 从常量池中推送至栈顶
            visitLdcInsn(" ms");
            //调用StringBuilder的append方法,将栈顶的值添加进StringBuilder
            invokeVirtual(Type.getType("Ljava/lang/StringBuilder;"), new Method("append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;"));
            //调用StringBuilder的toString方法,调用完后,将结果推到栈顶
            invokeVirtual(Type.getType("Ljava/lang/StringBuilder;"), new Method("toString", "()Ljava/lang/String;"));
            //调用println,打印栈顶的值
            invokeVirtual(Type.getType("Ljava/io/PrintStream;"), new Method("println", "(Ljava/lang/String;)V"));
        }
    }

先看看效果

可以看到,每个方法都插入方法执行时间的统计(如何优化的问题留到最后后面🤭)。

看完结果,我们先抛开MyClassVisitor和MyMethodVisitor流程很简单,总共就三步

  1. 读取Class文件
  2. 使用ClassVisitor类对Class文件进行代码扫描,这一步就可以进行代码的修改和插入了。
  3. 代码修改插入结束后,自然就可以重新输出Class文件,这里我们给Class改个名为InjectTest2,方便观察。

可以看到,最重要的其实就是第二步,对字节码进行插桩(你也可以叫代码观察,代码扫描,你喜欢就行😀),如果你有第一节ASM如何生成代码的经验,上面的代码唯一不懂的也就是MyClassVisitor,其实这就一个对类里面代码进行扫描的类。相应的,还会MethodVisitor,FiledVisitor,知道了ClassVisitor是什么意思后,这两个也不难理解。

接下来让我们一起看看ClassVisitor和MethodVisitor的用法解释。如果知道的朋友,可以直接跳到优化章节了。

三、ClassVisitor

看名字就能看出来,这是一个对Class文件进行观察(扫描)的工具类。因为它是一个抽象类,所以我们只能对其进行继承重写。

并且ClassVisitor所有的调用都是由ClassReader来进行回调,就是我们前面accept方法。而这个方法里,执行了对ClassVisitor源码扫描的回调,截图如下:

接下来我们看看ClassVisitor的构造方法

3.1 构造方法

protected ClassVisitor(final int api, final ClassVisitor classVisitor)

参数解释

  • api:代表是ASM API的版本 ,必须是如下的版本号,默认填最新(ASM9)即可。

注意:编写代码和代码读取的版本号最好保持一致,不然可能会有一些兼容性的错误,例如下面,一些API版本的判断。

  • classVisitor:传一个ClassVisitor的子类,对字节码修改的内容进行保存。

上面这个解释可能会让你感到不可思议,我们可以先看我们上面代码这个参数传的是ClassWriter的实例,也就是说ClassWriter继承于ClassVisitor。其实也不难理解,这里ClassWriter的作用就是保存我们修改的信息,然后重新输出字节码,因为ClassVisitor就是一个纯粹的代码扫描类(可以自行去看他的源码,没有保存修改代码的地方)。那么这个字段的作用其实就是用来对字节码修改进行保存的,我们简单来看下ClassVisitor某个方法的回调。例如:visitMethod方法,因为我们就是在这里进行字节码修改的

  public MethodVisitor visitMethod(
      final int access,
      final String name,
      final String descriptor,
      final String signature,
      final String[] exceptions) {
    if (cv != null) {
      return cv.visitMethod(access, name, descriptor, signature, exceptions);
    }
    return null;
  }


可以看到,方法最终的调用都给了cv,而这个cv就是我们传进来的ClassWriter 。不仅是这个方法,在ClassVisitor中,除了构造方法外,其他所有的方法最终的执行都是给了cv,也就是说,ClassVisitor就是一个代理类。

接下来介绍几个常用回调方法。

3.2 visitMethod

作用:回调Class中的每一个方法

源代码如下:

        @Override
        public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
            return super.visitMethod(access, name, descriptor, signature, exceptions);
        }

参数的解释这里就不过说明了,在上章节中有具体说明,这里说下如何使用来修改字节码。

其实也很简单,固定用法,删除某个方法

        @Override
        public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
            System.out.println("读到Class的一个方法visitMethod: " + name);
            if (name.equals("test")){
                //删除test方法
                return null;
            }
            return  super.visitMethod(access, name, descriptor, signature, exceptions);
        }

想对方法体修改:

  MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
            return new MyMethodVisitor(api, methodVisitor, access, name, descriptor);

为什么需要拿到super.visitMethod呢? 

因为这个拿到的methodVisitor不就是我们传入的ClassWriter的MethodVisitor,不拿到它,最后怎么把代码合成一份。

3.3 visitField

作用:回调Class中的每一个变量

使用如下,删除/修改一个变量
 

        public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
            System.out.println("读到Class的一个变量visitField: " + name);
            if (name.equals("a")){
                //删除a变量
                return null;
            }
            if (name.equals("b")){
                //修改b变量名词为d,值为10
                return super.visitField(access, "d", descriptor, signature, 10);
            }
            return super.visitField(access, name, descriptor, signature, value);
        }

   添加一个变量    

        @Override
        public void visitEnd() {
            super.visitEnd();
            FieldVisitor fv = cv.visitField(Opcodes.ACC_PRIVATE, "e", "I", null, null);
            if (fv != null) {
                fv.visitEnd();
            }
        }
3.4 visitAnnotation

作用:回调Class上的每一个注解

        @Override
        public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
            return super.visitAnnotation(descriptor, visible);
        }

参数解释:

  • descriptor:注解的描述符
  • visible:如果注解是运行时(也就是注解上的@Retention(RetentionPolicy.RUNTIME))返回true,否则就是false 

四、MethodVisitor

类似于ClassVisitor,MethodVisitor是对方法进行扫描,包括方法上的注解,方法内的所有代码。

在本文中,我们使用的是继承于AdviceAdapter的类,其实它最终也是继承于MethodVisitor,相比于MethodVisitor,它有更多封装好的功能,方便我们对代码进行查看修改(具体可以看源码),所以这里主要介绍AdviceAdapter的用法。

当然,你也可以直接继承MethodVisitor使用。

4.1 onMethodEnter

作用:方法进入时调用,可以在方法执行前插入代码

用法:根据JVM指令,使用下列指令进行代码插件修改

4.2 onMethodExit

作用:方法退出时调用,可以在方法执行后插入代码

用法:根据JVM指令,使用下列指令进行代码插件修改

 4.3  invokeStatic

作用:调用一个静态方法,也就是static。实际调用的还是MethodVisitor的visitMethodInsn

例如:Java代码,调用系统静态方法

long start = System.currentTimeMillis();

对应的JVM字节码

    INVOKESTATIC java/lang/System.currentTimeMillis ()J
    LSTORE 1

注意1:如果调用的方法有返回值,会自动压入栈顶!! 

注意2:Long和Double类型的值保存进局部变量表会占用2个位置,也就是两个slot,一个slot是32位。slot是局部变量表的单位

用法如下:

invokeStatic(Type.getType("Ljava/lang/System;"), new Method("currentTimeMillis", "()J"));
int startIndex = newLocal(Type.LONG_TYPE);
storeLocal(startIndex);
//上面两句等同于下面这一句,不推荐下面这种
storeLocal(1)

注意1:这里newLocal只是为了方便类统计局部变量表的索引,传Type_LONG_TYPE和随便一个值他的返回值都是一样的,影响的只会是下一个调用的人,所以说要传正确的值类型。

同理,invokeConstructor,invokeVirtual,invokeDynamic,invokeInterface调用的还是MethodVisitor的visitMethodInsn。

4.4 newLocal

作用:创建一个局部变量的类型,类会自动加上类型的Size,返回值是局部变量表里的索引。

用法如下: 

newLocal(Type.LONG_TYPE);

提示:如果此时局部变量表是空的,第一次调用上面语句后,会返回0的索引,下一次再调用上面语句,就是返回2的索引,因为Long占用两个slot。

4.5 storeLocal

作用:将栈顶的值保存到局部变量表的指令,实际调用的还是MethodVisitor的visitVarInsn,最好就是搭配newLocal方法使用

        例如上面的invokeStatic 例子,当调用完inkeStatic命令完,栈顶有值,就可以执行storeLocal命令。如下:

invokeStatic(Type.getType("Ljava/lang/System;"), new Method("currentTimeMillis", "()J"));
int startIndex = newLocal(Type.LONG_TYPE);
storeLocal(startIndex);

 提示:也不一定要调用newLocal,不过就需要你手动计算索引,填入正确的索引

4.6  push

作用:将给定的值压入栈顶,一般要搭配storeLocal使用,实际调用的还是MethodVisitor的visitInsn

例如:Java代码,创建一个变量

String a = "hhhh";
long b = 2L;
double c = 2.22D;

对应的JVM字节码

   L0
    LINENUMBER 6 L0
    LDC "hhhh"
    ASTORE 1
   L1
    LINENUMBER 7 L1
    LDC 2
    LSTORE 2
   L2
    LINENUMBER 8 L2
    LDC 2.22
    DSTORE 4

 用法如下:

push("hhhh");
storeLocal(newLocal(Type.CHAR_TYPE));
 
push(2L);
storeLocal(newLocal(Type.LONG_TYPE));
 
push(2.22);
storeLocal(newLocal(Type.LONG_TYPE));

问题:我试过push(1),但是写入Class里的内容却是Boolean,没搞懂,有知道的小伙伴欢迎评论。

4.7  getStatic

作用:获取一个静态的实例变量,实际调用的还是MethodVisitor的visitFieldInsn

例如:

getStatic(Type.getType("Ljava/lang/System;"), "out", Type.getType("Ljava/io/PrintStream;"));

同理,getFiled就是获取非静态的实例变量,也就是获取类的变量。

相应的,putStatic和putFiled就是给静态实例变量赋值和非静态变量赋值。

提示:不是直接调用就能赋值的,赋值前栈顶需要有对应的变量和值。

4.8  newInstance

作用:创建一个对象,并将其引用值压入栈顶,实际调用的还是MethodVisitor的visitTypeInsn

4.9  dup

作用:从栈顶的对象赋值一个引用,并将其引用值压入栈顶,实际调用的还是MethodVisitor的visitInsn

例如下面代码:

StringBuilder stringBuilder = new StringBuilder();

用法:

//new 一个 StringBuilder();并将其引用值压入栈顶
newInstance(Type.getType("Ljava/lang/StringBuilder;"));
//复制栈顶元素(对象StringBuilder的引用)
dup();

4.10  visitLdcInsn

作用:将传入的值从常量池中推送至栈顶

4.11  loadLocal

作用:把局部变量表中的元素,压入栈顶

4.12  math

作用:将栈顶的两个元素进行计算(例如:加减乘除等),并把结果值重新压入栈顶。

五、FieldVisitor

类似于ClassVisitor,FieldVisitor是对变量的信息进行扫描,包括变量上的注解,变量的参数。

5.1 visitAnnotation

作用:回调变量上的每一个注解

源代码:

public AnnotationVisitor visitAnnotation(String descriptor, boolean visible)

参数解释:

  • descriptor:注解的描述符
  • visible:如果注解是运行时(也就是注解上的@Retention(RetentionPolicy.RUNTIME))返回true,否则就是false 

六、代码优化

可以看到,生成出来的Class文件对每个方法都进行了代码插入,这很明显不是我们需要的。解决方法如下:

第一种,进行方法过滤
 
     @Override
        public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
            System.out.println("读到Class的一个方法visitMethod: " + name);
            if (!name.equals("test")) {
                return super.visitMethod(access, name, descriptor, signature, exceptions);
            }
            MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
            return new MyMethodVisitor(api, methodVisitor, access, name, descriptor);
        }

 这种虽然很方便,但是好像也太Low了。我们再来看第二种

第二种,注解方式

很简单,创建一个注解(@ASMTarget),然后在对应需要插入代码的方法上写上我们注解。例如:

ASMTarget代码

@Retention(RetentionPolicy.CLASS)
@Target({ElementType.METHOD, ElementType.FIELD,ElementType.ANNOTATION_TYPE})
public @interface ASMTarget {
}

提前在需要插入代码的方法上写上@ASMTarget

    @ASMTarget
    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(1234);
    }

然后我们看看MethodVisitor代码要如何修改,如下::

 boolean needInsertCode = false;//标记是否需要插入代码     
 @Override
 public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
     if ("Lasm/ASMTarget;".equals(descriptor)) {
         needInsertCode = true;
     }
     return super.visitAnnotation(descriptor, visible);
 }

 然后在onMethodEnter和onMethodExit进行拦截即可,代码如下。

@Override
protected void onMethodEnter() {
    super.onMethodEnter();
    if (!needInsertCode) {
        return;
    }
}

@Override
protected void onMethodExit(int opcode) {
    super.onMethodExit(opcode);
    if (!needInsertCode) {
        return;
    }
}

代码地址:JMMDemo: 一些关于JMM,javassist,asm学习和一些案例。。。。。。。。。。。。。。。。。。。 - Gitee.com

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值