手摸手增加字节码往方法体内插代码

1,821 阅读5分钟

本文动态增加字节码是直接使用的ASM,有关ASM的内容可以看下我之前的一篇文章:ASM 操作字节码初探

话不多说,先看本次想实现怎样的效果:

 public static class Bazhang {
    private long f(int n, String s, int[] arr) {
        return 0;
    }
    private void hi(double a, List<String> b) {
    }
    public void newFunc(String str) {
        System.out.println(str);
        for (int i = 0; i < 100; i++) {
            if (i % 10 == 0) {
                System.out.println(i);
            }
        }
    }
    }

这是一个自定义的类,里面有三个方法,我需要在不改变原有写好的代码的基础上,往newFunc(String str)这个方法内收尾增加两个方法,打印输入start和end,也就是如下:

public static void newFunc(String str) {
    System.out.println("========start=========");
    System.out.println(str);
    for (int i = 0; i < 100; i++) {
        if (i % 10 == 0) {
            System.out.println(i);
        }
    }
    System.out.println("========end=========");
    }

那么我们直接操作字节码,往方法体内首尾增加相应字节码就好了。

这里先安利一个IntelliJ的插件,叫做ASM Bytecode Outline,他可以直接显示java代码对应的字节码和ASM相应的操作代码,这个插件一定程度上帮助我们写接下来的代码。

下面进入正题,手摸手开始:

自定义一个ClassVisitor

static class TestClassVisitor extends ClassVisitor {
    public TestClassVisitor(final ClassVisitor cv) {
        super(Opcodes.ASM5, cv);
    }
    
    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        if (cv != null) {
            cv.visit(version, access, name, signature, superName, interfaces);
        }
    }
    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        //如果methodName是newFunc,则返回我们自定义的TestMethodVisitor
        if ("newFunc".equals(name)) {
            MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
            return new TestMethodVisitor(mv);
        }
        if (cv != null) {
            return cv.visitMethod(access, name, desc, signature, exceptions);
        }
        return null;
    }
    }

确保只有newFunc方法才会走我们的套路。

自定义TestMethodVisitor

static class TestMethodVisitor extends MethodVisitor {
        public TestMethodVisitor(MethodVisitor mv) {
            super(Opcodes.ASM5, mv);
        }
        @Override
        public void visitCode() {
            //方法体内开始时调用
            super.visitCode();
        }
        @Override
        public void visitInsn(int opcode) {
            //每执行一个指令都会调用
            super.visitInsn(opcode);
        }
    }
    

我们注释了两个方法,一个是visitCode(),一个是visitInsn(int opcode),这两个方法一个为我们接下来插入start,一个插入end。

使用ASM Bytecode Outline插件做简单分析

这一步并不是必须的,如果你对字节码足够的熟练,完全可以随便撸。

先随便写个类,然后实现我们最后需要变成的newFunc(String str)方法,然后用插件查看ASMifield,可以得到如下片段:

// 1
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("========start=========");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
...
// 2
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitVarInsn(ALOAD, 0);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
...
   
// 3
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("========end=========");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
...
// 4
mv.visitInsn(RETURN);

我把中间很多代码给删掉了,这样结构就很清楚了,可以看到我留下了四个部分:

注释1:这是我们要插入的System.out.println(“========start=========”);转成相对应的ASM提供的方法,其中visitLdcInsn可以在JVM指令表中查到Ldc表示将int, float或String型常量值从常量池中推送至栈顶,那么其实ASM提供了方法让我们继续通过java代码转成相对应的字节码,那么注释1中的三行代码所对应的字节码就是:

GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
LDC "========start========="
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V

注释2:这是原方法内的代码System.out.println(str);,表明我们注释1确实是插在了newFunc方法体的最上方,其中mv.visitVarInsn(ALOAD, 0);的ALOAD对应JVM指令的意思是将指定的引用类型本地变量推送至栈顶,因为这个String是参数传过来的。

注释3:显而易见,转换前的java代码就是我们的System.out.println(“========end=========”);,这里不再赘述。

注释4:RETURN对应着从当前方法返回void

这样一来,ASM Bytecode Outline插件帮我们直接生成了相对应的ASM代码,那么我们接下来粘贴复制就行了。

插头

先从简单的开始,插头部。我们在之前的自定义TestMethodVisitor中已经复写了visitCode方法,那么我们就在代码注释的地方插入ASM代码:

@Override
public void visitCode() {
    mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
    mv.visitLdcInsn("========start=========");
    mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
    super.visitCode();
    }

插尾

这个比插头复杂点,但是也很简单,visitInsn方法会在每个指令被执行时都会调用,所以我们需要判断指令是否到了RETURN即可,在RETURN前插入我们的代码:

if (opcode == Opcodes.RETURN) {
    mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
    mv.visitLdcInsn("========end=========");
    mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
    }

校验

这样一来,我们的头尾的插好了,校验一番:

public static void main(String[] args) throws Exception {
    ClassReader cr = new ClassReader(Bazhang.class.getName());
    ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);
    ClassVisitor cv = new TestClassVisitor(cw);
    cr.accept(cv, Opcodes.ASM5);
    // 获取生成的class文件对应的二进制流
    byte[] code = cw.toByteArray();
    //将二进制流写到out/下
    FileOutputStream fos = new FileOutputStream("out/Bazhang223.class");
    fos.write(code);
    fos.close();
    }

可以看到如下结果:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
import java.util.List;
public class Test$Bazhang {
    public Test$Bazhang() {
    }
    private long f(int n, String s, int[] arr) {
        return 0L;
    }
    private void hi(double a, List<String> b) {
    }
    public void newFunc(String str) {
        System.out.println("========start=========");
        System.out.println(str);
        for(int i = 0; i < 100; ++i) {
            if(i % 10 == 0) {
                System.out.println(i);
            }
        }
        System.out.println("========end=========");
    }
    }

看来导出的.class是没问题了,那么利用反射来执行一下我们的修改类:

Test loader = new Test();
Class hw = loader.defineClass("Test$Bazhang", code, 0, code.length);
Object o = hw.newInstance();
Method method = o.getClass().getMethod("newFunc", String.class);
method.invoke(o, "巴掌菜比");

最后控制台打印出来的结果是:

========start=========
巴掌菜比
0
10
20
30
40
50
60
70
80
90
========end=========

尾语

这样一来我们的目的都达到了,之后便可以更加进一步做点有意思的事,比如统计方法耗时。

整的来说,修改字节码达到本次想要的效果是个很cool的方式,很多时候我们是可以通过hook或者动态代理来做一些类似本文的操作,那么就要结合实际情况进行选择了。