Java字节码介绍及动态修改类

前言

对于Java字节码,它是在Java类的编译过程产生的,即由.java源文件到.class二进制字节码文件的过程。而Java类的加载又是通过类的名字获取二进制字节流,然后在内存中将字节流生成类对象。所以动态修改类的时机在于修改.class文件,只要通过修改.class文件的字节码,即可达到修改类的目的。修改字节码可以通过ASM这个开源框架实现,ASM是一个Java字节码引擎的库,具体可以查看官网,它可以通过操作字节码实现修改类或者生成类。

介绍

Java字节码的执行操作主要是在虚拟机的栈执行,这个栈主要有局部变量表,操作数栈等几个部分。

(一)局部变量表

主要用来保存方法中的局部变量,基本的存储单位为slot(32位的存储空间),所以long double的数据类型需要两个slot, 当方法被调用时,参数会传递从0开始的局部变量表的索引位置上,所以局部变量最大的大小是在编译期就决定的,特别需要注意的是如果调用的是实例方法,局部变量第0个位置是实例对象的引用

(二)操作数栈

主要用来当作字节码指令操作的出栈入栈的容器,例如变量的出栈入栈都是在操作数栈里面进行的。

(三)指令

指令主要是由操作码+操作数组成的,指令包括加载和存储指令,运算指令和类型转换指令,方法调用指令等等。指令所需要的操作,调用方法,赋值等,都是在操作数栈进行的。

过程

首先是导包,包的版本关系可以查看发布版本,这里我导入的是implementation "org.ow2.asm:asm:6.2"。修改字节码主要需要以下这几个类:ClassReader, ClassWriter, ClassVisitor, MethodVisitor。各个类的作用如下:

  1. ClassReader: 读取类文件
  2. ClassWriter: 继承ClassVisitor 主要用来生成修改类之后的字节
  3. ClassVisitor: 用于访问修改类
  4. MethodVisitor: 用于访问修改类的方法

一般用法如下:

    try {
      String classPath = "asmdemo/ModifyInstanceClass";
      ClassReader classReader = new ClassReader(classPath);
      ClassWriter classWriter = new ClassWriter(classReader, 0);
      ClassVisitor classVisitor = new ClassVisitorDemo(classWriter);
      classReader.accept(classVisitor, 0);

      File file = new File(ROOT_SUFFIX + "ClassDynamicLoader/ASMProject/build/classes/java/main/asmdemo/ModifyInstanceClass.class");
      FileOutputStream output = new FileOutputStream(file);
      output.write(classWriter.toByteArray());
      output.close();
    } catch (IOException e) {
      e.printStackTrace();
    }



  private static class ClassVisitorDemo extends ClassVisitor {
    ClassVisitorDemo(ClassVisitor classVisitor) {
      super(Opcodes.ASM5, classVisitor);
    }

    @Override
    public void visitEnd() {

      cv.visitField(Opcodes.ACC_PRIVATE, "timer", Type.getDescriptor(long.class), null, null);

      MethodVisitor methodVisitor = cv.visitMethod(Opcodes.ACC_PUBLIC, "newFunc","()V", null,null);
      methodVisitor.visitInsn(Opcodes.RETURN);
      methodVisitor.visitMaxs(0,1);

      super.visitEnd();
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
      MethodVisitor methodVisitor = super.visitMethod(access, name, desc, signature, exceptions);

      if (name.equals("print") && desc.equals("()V")) {
        methodVisitor = new MethodVisitorHub.FirstMethodVisitor(methodVisitor);
      } else if(name.equals("print") && desc.equals("(Ljava/lang/String;)V")) {
        methodVisitor = new MethodVisitorHub.SecondMethodVisitor(methodVisitor);
      } else if (name.equals("connectStr")) {
        methodVisitor = new MethodVisitorHub.ThirdMethodVisitor(methodVisitor);
      }
      return methodVisitor;
    }


  }

先利用ClassReader读取待修改的类文件,然后基于Reader创建了对应的ClassWriter,再基于ClassWriter创建了对应的ClassVisitor, 再接着ClassReader委托ClassVisitor去读取修改类,最后,创建文件输出流,利用ClassWriter生成的字节,将重新生成的字节码写回build目录生成的class文件,替换编译生成的class文件,这样就可以达到修改类的目的。

Created with Raphaël 2.1.2 开始 Reader读取类 MethodVisitor修改类 获取Writer修改之后的字节码 文件输出流将字节码写回类生成的build路径,替换

用法

   对于类的修改,主要关注ClassVisitor和MethodVisitor这两个类即可,ClassVistor可以实现成员变量和方法的增加,MethodVisitor用于修改类方法的实现。在修改类方法的时候,我是先通过把原先的方法修改为预期的方法,然后通过javap命令对预期的方法产生的类文件进行反编译,查看编译器产生的字节码。命令如下:javap -v .class文件路径。 通过反编译之后可以得到修改后的类的操作数栈和局部变量表的最大大小,还有具体的字节码指令。下面开始看具体的使用。

   MethodVIsitor一般通过实现visitCode visitInsan visitMaxs方法来实现类的修改。visitCode是方法的访问开始;visitInsn可以访问方法的操作指令,一般应用于在return指令之前插入代码;vistiMax则用于复写操作数栈和局部变量表的大小,因为类被修改,所以所需的栈和变量表大小可能会增加。下面是几个具体的例子:

1. 在print()空方法中插入一行输出 System.out.print("Hello World");

首先利用javap -v 编译修改前的print方法,如下
![这里写图片描述](https://img-blog.csdn.net/20180906151907501?watermark/2/text/aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3Rhb3N6dQ==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70)
接着在print()方法增加 `    System.out.print("Hello World");`再执行javap -c反编译

Java字节码介绍及动态修改类_第1张图片
可以发现多了三个指令,并且stack即操作数栈增加了2。所以代码如下:

  public static class FirstMethodVisitor extends MethodVisitor {
    public FirstMethodVisitor(MethodVisitor mv) {
      super(Opcodes.ASM5, mv);
    }

    /**
     * 进入方法 插入System.out.print("hello world")这行代码
     */
    @Override
    public void visitCode() {
      super.visitCode();
      mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
      mv.visitLdcInsn("hello world");
      mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "print", "(Ljava/lang/String;)V", false);
    }

    @Override
    public void visitMaxs(int maxStack, int maxLocals) {
      mv.visitMaxs(2,1);
    }
  }

  上面的代码主要覆写了visitMaxs,stack local数值是通过反编译得到的,visitCode则是添加了三个指令。分析System.out.print可知,其实是通过System这个类获取out这个变量,然后通过out调用print这个方法输出“hello world”这个变量。
  所以首先要获取out,out是一个静态变量,第一个指令是visitFieldInsn,顾名思义就是访问成员的指令,第一个参数是操作码,第二个参数是调用成员的类,第三个参数是成员的名称,第四个参数是成员的类型,对号入座第一个指令,操作码是获取静态变量,调用类是“java/lang/System”, 成员名是“out”, 类型通过反编译可知是“Ljava/io/PrintStream;”。所以得出结论,第一个指令是通过System这个类获取out这个静态变量并且把变量入栈。
  接着第二个指令visitLdcInsn是把常量推到操作数栈,这里是把“hello world”入栈,
  最后就是第三个指令visitMethodInsn,还是顾名思义是访问方法的指令,第一个参数是操作码,第二个参数是调用方法等的类,第三个参数是方法名,第四个参数是方法的返回类型和参数类型,第五个参数是调用方法的类是否是接口,对号入座,Opcodes.INVOKEVIRTUAL指的是调用的是实例方法,调用的类是out即“java/io/PrintStream”这个类,方法名是print,返回值是void对应“V”,参数是String对应“Ljava/lang/String; ”, 这些参数的对应类型都可以从反编译得到。第三个指令需要两个操作数,一个是执行方法的主体即out,第二个是参数即“hello world”,使用visitMethodInsn指令的时候,out “”hello world“依次从操作数栈出栈,刚刚好对应指令调用的参数顺序。
拦截方法的入口在ClassVisitor,如下:

      private static class ClassVisitorDemo extends ClassVisitor {
    ClassVisitorDemo(ClassVisitor classVisitor) {
      super(Opcodes.ASM5, classVisitor);
    }

    @Override
    public void visitEnd() {

      cv.visitField(Opcodes.ACC_PRIVATE, "timer", Type.getDescriptor(long.class), null, null);

      MethodVisitor methodVisitor = cv.visitMethod(Opcodes.ACC_PUBLIC, "newFunc","()V", null,null);
      methodVisitor.visitInsn(Opcodes.RETURN);
      methodVisitor.visitMaxs(0,1);

      super.visitEnd();
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
      MethodVisitor methodVisitor = super.visitMethod(access, name, desc, signature, exceptions);

      if (name.equals("print") && desc.equals("()V")) {
        methodVisitor = new MethodVisitorHub.FirstMethodVisitor(methodVisitor);
      } else if(name.equals("print") && desc.equals("(Ljava/lang/String;)V")) {
        methodVisitor = new MethodVisitorHub.SecondMethodVisitor(methodVisitor);
      } else if (name.equals("connectStr")) {
        methodVisitor = new MethodVisitorHub.ThirdMethodVisitor(methodVisitor);
      }
      return methodVisitor;
    }


  }

在visitMethod中判断方法名为print,则进行拦截注入自己创建的MethodVisitor即可。
  到这里已经分析完成,可以自信满满地运行代码了,但是要切记,不能在修改之前使用该类,如果使用了之后,类已经被加载,那么修改之后的类不会被再次加载,也就无法发挥作用了。

2. 在print(String s )空方法中插入一行输出 System.out.print(s)
   分析的方法和上面的一样,这里的关键是读取参数的值,反编译之后可以发现使用了ALOAD这个指令,这个指令的作用是从局部变量表读取变量入栈,指令代码如下:

 mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
 mv.visitVarInsn(Opcodes.ALOAD, 1);
 mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "print", "(Ljava/lang/String;)V", false);

   visitVarInsn是读取参数的指令,操作码是ALOAD,后面的参数是指变量表的索引,上面也提到,如果是实例方法,局部变量表的0索引是实例对象,所以这里取了索引1。

3. 在connectStr()空方法打印执行消耗时间
修改前代码如下:

  public void connectStr() {
    String s = "";
    for (int i = 0; i < 10000; i ++) {
      s += i;
    }
  }

修改后代码如下:

  public void connectStr() {
    this.timer = -System.currentTimeMillis();
    String s = "";

    for(int i = 0; i < 10000; ++i) {
      s = s + i;
    }

    this.timer += System.currentTimeMillis();
    System.out.println(this.timer);
  }

   这里的关键是在return前插入代码, 还有增加变量timer。具体的反编译过程就不展示了,直接上代码:

  public static class ThirdMethodVisitor extends MethodVisitor {
    public ThirdMethodVisitor(MethodVisitor mv) {
      super(Opcodes.ASM5, mv);
    }

    /**
     * 进入方法
     */
    @Override
    public void visitCode() {
      super.visitCode();
      mv.visitVarInsn(Opcodes.ALOAD, 0);
      mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
      mv.visitInsn(Opcodes.LNEG);
      mv.visitFieldInsn(Opcodes.PUTFIELD, "asmdemo/ModifyInstanceClass", "timer", "J");
    }

    /**
     * return前插入代码
     */
    @Override
    public void visitInsn(int opcode) {
      if (opcode == Opcodes.RETURN) {
        mv.visitVarInsn(Opcodes.ALOAD, 0);
        mv.visitInsn(Opcodes.DUP);

        mv.visitFieldInsn(Opcodes.GETFIELD, "asmdemo/ModifyInstanceClass", "timer", "J");
        mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
        mv.visitInsn(Opcodes.LADD);
        mv.visitFieldInsn(Opcodes.PUTFIELD, "asmdemo/ModifyInstanceClass", "timer", "J");

        mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        mv.visitVarInsn(Opcodes.ALOAD, 0);
        mv.visitFieldInsn(Opcodes.GETFIELD, "asmdemo/ModifyInstanceClass", "timer", "J");
        mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(J)V", false);
      }
      super.visitInsn(opcode);
    }


    @Override
    public void visitMaxs(int maxStack, int maxLocals) {
        mv.visitMaxs(5, 3);
    }
  }

   首先看visitCode方法, 做的事情就是this.timer = System.currentTimeMillis(),对这行代码进行拆分,就是获取时间戳赋值给timer,对应底下的指令mv.visitVarInsn(Opcodes.ALOAD, 0) 先将实例对象入栈即我们用的变量this,接着访问方法获取系统时间戳然后执行LNEG取反入栈,最后在执行访问方法的指令PUTFIELD把值赋给timer,需要的参数是时间戳和this变量,this变量用于访问timer,时间戳则是赋值的变量。
   接着看visitInsn方法,visitInsn可以拦截方法执行的指令做一些插入操作,在这里我们需要做的事在return之前插入时间戳的计算和打印, 代码比较长如下:

    if (opcode == Opcodes.RETURN) {
        mv.visitVarInsn(Opcodes.ALOAD, 0);
        mv.visitInsn(Opcodes.DUP);

        mv.visitFieldInsn(Opcodes.GETFIELD, "asmdemo/ModifyInstanceClass", "timer", "J");
        mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
        mv.visitInsn(Opcodes.LADD);
        mv.visitFieldInsn(Opcodes.PUTFIELD, "asmdemo/ModifyInstanceClass", "timer", "J");

        mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        mv.visitVarInsn(Opcodes.ALOAD, 0);
        mv.visitFieldInsn(Opcodes.GETFIELD, "asmdemo/ModifyInstanceClass", "timer", "J");
        mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(J)V", false);
      }

   老规矩,拆解代码 this.timer += System.currentTimeMillis(), 需要取出timer的值,获取时间戳,进行加法操作,然后结果赋值到timer,这里需要用到两个this变量,因为要访问timer两次,所以可以看到一个新的指令,DUP,DUP的意思就是复制栈顶变量然后入栈,也就是说拷贝多一份this变量,底下的指令已经分析过了,就不再赘述。
   到这里,还没完成,因为timer变量还没生成呢,类变量的生成就要依赖ClassVisitor了, 拦截ClassVisitor的visitEnd方法,动态增加变量,如下:

   @Override
    public void visitEnd() {
      cv.visitField(Opcodes.ACC_PRIVATE, "timer", Type.getDescriptor(long.class), null, null);
      super.visitEnd();
    }

代码已经上传 AsmDemo

总结

   这里介绍的只是动态修改类的冰山一角,动态生成类的应用场景很多,像市面上的路由框架,热修复框架,很多都是利用了动态修改类的方式进行代码的注入,所以路还很长,还需更加努力。

参考链接

动态生成类的一些错误
字节码指令介绍
字节码原理

你可能感兴趣的:(Android)