ASM简介(四)

作者: 千里山南 | 来源:发表于2016-07-31 11:04 被阅读1776次

    函数

    我们在使用ASM相关API对函数进行操作之前,我们需要了解函数在字节码的存储格式及其执行模型。

    执行模型

    我们需要简单了解Java虚拟机的执行模型。Java代码是在线程中执行的,每个Java程序可能包含多个执行线程,而每个线程都有一个执行栈。这些执行栈是由许多frame(帧)组成的,每一个frame都代表一个函数调用。每当一个函数被调用的时候,一个frame就被推到执行线程的栈顶。当函数执行完毕(无论是正常还是异常返回)这个frame就会弹栈,然后执行下一个frame.
    每一个frame都包含两个部分,一个本地的符号表和一个操作栈。符号表存储了可以被自由取值的变量,操作栈主要用于在执行时存储字节码指令。本地符号表大小及操作栈大小是由函数本身大小决定的。因此二者是在编译期由编译器计算并存储在字节码中的。
    当每个frame创建的时候,其操作栈是空的,符号表由this对象和函数参数组成。符号表和操作栈中每一个item都可以防止任意的除long和double以外的Java对象。这是因为long和double需要两个item才能存下。

    字节码指令

    字节码指令是由一个opcode和几个固定的参数组成
    opcode 是无符号byte类型。因此一般用助记符代替,例如一般使用NOP代替0.参数一般是静态的值,用于说明opcode的操作对象。参数是静态存储在class文件里的。opcode只有运行时才知道。
    总体来说opcode主要分为两大类,其中一小部分主要用于将数据由符号表推送到操作栈或者相反。其余的指令只是操作操作栈上的对象。他们可以从操作栈弹出几个对象,根据这些对象计算出一些值,然后将结果再送回操作栈。
    ILOAD LLOAD FLOAD DLOAD ALOAD 指令用于将符号表中的变量推动到操作栈上。他们接收变量在符号表的索引值。ILOAD用于操作boolean byte char short int. LOAD ,FLOAD DLOAD用于long,float,double的操作。ALOD主要用于非基本数据类型的操作。对应 ISTORE,LSTORE,FSTORE,DSTORE ASTORE 从操作站弹出并将其存储在符号表。字节码指令基本都是类型相关的。字节码指令一般可以分为:

    • Stack(栈操作相关) 主要用于操作操作栈上的数据:POP,DUP(push复制栈顶的数据)SWAP(交换栈顶的两个元素)
    • Constants(常量相关) 将一个常量push到栈顶ACONST_NULL(pushes null), ICONST_0(push 0) FCONST_0(push 0f) DCONST_0 BIPUSH b(push byte 类型的 b)SIPUSH s(push short 类型 s) LDC(push 任意类型 int float long double String 或者是class 常量等)
    • Arithmentic and logic(逻辑运算相关) 弹出栈顶几个元素进行运算将结果push到栈顶。xADD xSUB xDIV xREM 分别代表 + - * / % 运算。x可以是‘I’ ‘L’ 'F' 'D' 类似还有和<< >> >>> | ^ & 等相对应的指令。
    • Casts(类型转换相关) 这些指令将栈顶元素弹出,转换类型后入栈。它和java中类型转换相对应。I2F F2D L2D 等 CHECKCAST t将一个引用类型的对象转换为t类型
    • Objects(对象相关)这些指令用于创建对象,锁定对象,检查类型等 NEW type 会将一个type类型的对象入栈。
    • Fields(取值赋值相关) GETFIELD owner name desc 弹出对象引用,将其name对应的变量的值入栈。PUTFIELD将对象弹栈将其值存储在name对应的存储区。这两个指令中弹出的对象必须是owner类型,field的类型必须是desc类型。GETSTATIC和PUTSTATIC是类似的。不过其用于操作静态变量。
    • Methods(方法相关) 这些指令可以调用一个函数或者构造函数。他们会弹出函数参数的个数加上1(调用者对象)个对象,将函数结果入栈。INVOKEVIRTUAL owner name desc 会调用定义在owner类中函数签名为desc名称为name的函数。INVOKESTATIC 用于调用静态方法,INVOKESPECIAL 用于private及构造函数。INVOKEINTERFACE 用于调用定义在接口中的方法。对于java7而言INVOKEDYNAMIC 是用于动态方法调用。
    • ARRAYS(数组相关) 这些指令主要用于读写数组。xALOAD指令会弹出索引和数组,并且将数组中索引对应的值入栈。xSTORE会弹出一个值,索引和数组,将值存储在数组的索引位置。x可以是I L F D A B C S
    • Jumps(跳转指令) 这些指令会在指定的条件为true或者false的时候跳转到任意指定的指令接着执行。他们对应于高级语言的if for do while break continue 等流程控制语句。IFEQ label会弹出int值,如果该值为0就跳转到label指定的指令。其他的跳转指令也类似:IFNE IFGE ... TABLESWITCH LOOKUPSWITCH 对应于java语言中switch语句。
    • Return(返回)xRETURN 和 RETURN指令被用于终止函数的执行,返回相关值给函数调用者。RETURN对应于 return void, xRETURN用于返回其他值。

    对于以下简单的函数,其对应的指令如下:

    package pkg;
    public class Bean {
        private int f;
        public int getF() {
            return this.f;
        }
        public void setF(int f) {
            this.f = f;
        }
    }
    
    
     // getF函数对应的指令如下
     ALOAD 0   //将this入栈 
     GETFIELD pkg/Bean f I //this弹出,将this.f入栈
             IRETURN // 返回 this.f
     // setF函数对应的指令      
     ALOAD 0  // this 入栈
     ILOAD 1  // 将索引为1(类型为int)的变量入栈
     PUTFIELD pkg/Bean f I // 弹出两个值,并且将栈顶的元素及this弹出,并将其值赋给this.f
             RETURN  // RETURN
    

    Bean对象会有默认的构造函数,其对应的指令为:ALOAD 0 INVOKESPECIAL java/lang/Object <init> ()V RETURN
    构造函数在字节码中的名称为<init>
    一个复杂的例子如下:

    public void checkAndSetF(int f) {
        if (f >= 0) {
            this.f = f;
        } else {
            throw new IllegalArgumentException();
        }
    }
    // 其对应的字节码指令
    ILOAD 1 // f入栈
    IFLT label // f弹栈,如果小于0,跳转到label
    ALOAD 0 // this 入栈
    ILOAD 1 // f入栈
    PUTFIELD pkg/Bean f I // this.f = f
    GOTO end //跳转到end
    label:
    NEW java/lang/IllegalArgumentException //生成IllegalArgumentException对象
            DUP // 赋值对象
    INVOKESPECIAL java/lang/IllegalArgumentException <init> ()V // 弹栈并调用初始化方法
            ATHROW // 抛出栈底异常,指令执行结束
    end:
    RETURN
    

    异常处理

    并没有catch对应的字节码指令,不过函数会和一系列exception handler(异常处理代码)相关联。当抛出指定的异常时对应的handler代码就会执行。因此exception handler就和try catch代码块类似。

    public static void sleep(long d) {
        try {
            Thread.sleep(d);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
    //对应的指令如下
    
    TRYCATCHBLOCK try catch catch java/lang/InterruptedException // 声明handler,如果try: 和 catch: 之间代码发生异常,则生成InterruptedException实例,跳转到catch:
    try:
    LLOAD 0
    INVOKESTATIC java/lang/Thread sleep (J)V
    RETURN
    catch:
    INVOKEVIRTUAL java/lang/InterruptedException printStackTrace ()V
    RETURN
    

    Frames(帧)
    Java6及以上版本编译的class文件,包含一系列stack map frames来加速jvm对class的校验。它们甚至在运行前就能告知jvm某个frame的符号表及操作栈的详细信息。为此,我们可以为frame中的每一个指令创建一个frame来查看其运行时的状态

    //运行前的state frame     对应的指令
    [pkg/Bean] []           ALOAD 0
    [pkg/Bean] [pkg/Bean]   GETFIELD
    [pkg/Bean] [I]          IRETURN
    
    //对于 throw new IllegalArgumentException的代码:
    [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
    

    上述的Uninitialized只存在于stack map frame中,代表内存分配完毕但是构造函数还没调用。UNINITIALIZED_THIS 代表被初始化为0 TOP代表未定义类型 NULL代表null
    对于编译后的class为了节省空间,实际上并不是每一个指令都对应一个state frame,而是只有跳转指令和异常处理handler 和无条件跳转后面的第一个指令包含state frame. 而其他指令可以从已有的state frames推断出来。为了进一步节省空间,每一个frame只有在和上一个frame不同的时候才会被存储。初始帧由于可以很容易从函数参数中推断,因此不会存储,而后续的帧如果和初始帧相同只需存储 F_SAME即可

    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
    

    接口及组件

    函数的生成改写需要使用MethodVisitor(在ClassVisitor的visitMethod函数中返回),生成函数时各个部分的调用顺序也是固定的:

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

    注解和参数需要首先被生成,然后是方法体,最后需要调用visitMax。 visitCode和visitMax可以看做函数体的开始和结束。最后需要调用visitEnd代表事件结束。

    ClassVisitor cv = ...;
    cv.visit(...);
    MethodVisitor mv1 = cv.visitMethod(..., "m1", ...);
    mv1.visitCode();
    mv1.visitInsn(...);
    ...
            mv1.visitMaxs(...);
    mv1.visitEnd();
    MethodVisitor mv2 = cv.visitMethod(..., "m2", ...);
    mv2.visitCode();
    mv2.visitInsn(...);
    ...
            mv2.visitMaxs(...);
    mv2.visitEnd();
    cv.visitEnd();
    

    于我们而言计算某个方法的stack frame绝非易事。幸好ASM可以帮我们计算,当你声明一个ClassWriter的时候你可以声明让ASM自动计算这些值。例如:new ClassWriter(ClassWriter.COMPUTE_MAX) 符号表及操作栈的大小会自动帮你计算。但你仍然需要调用visitMax,此时你传什么值都可以(它们会被忽略),但你仍要自己计算frames。而new ClassWriter(ClassWriter.COMPUTE_FRAMES) 所有的都会自动被计算,但仍要调用visitMax.但是COMPUTE_MAX会使得ClassWriter慢10%左右,而COMPUTE_FRAMES会慢一倍。

    //生成getF的代码如下
    mv.visitCode();
    mv.visitVarInsn(ALOAD, 0);
    mv.visitFieldInsn(GETFIELD, "pkg/Bean", "f", "I");
    mv.visitInsn(IRETURN);
    mv.visitMaxs(1, 1);
    mv.visitEnd();
    
    //
    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(); // 这里又创建一个label,虽然不创建直接用前一个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即可。而MethodVisitor可以包含多分枝:

    public MethodVisitor visitMethod(int access, String name,
                                     String desc, String signature, String[] exceptions) {
        MethodVisitor mv1, mv2;
        mv1 = cv.visitMethod(access, name, desc, signature, exceptions);
        mv2 = cv.visitMethod(access, "_" + name, desc, signature, exceptions);
        return new MultiMethodAdapter(mv1, mv2);
    }  
    

    测量类中所有方法的执行时间

    public class C {
        public static long timer;
        public void m() throws Exception {
            timer -= System.currentTimeMillis();
            Thread.sleep(100);
            timer += System.currentTimeMillis();
        }
    }
    

    我们可以使用TraceClassVisitor来查看该类生成的字节码:使用Textifier或者使用ASMifier

    GETSTATIC C.timer : J
    INVOKESTATIC java/lang/System.currentTimeMillis()J
            LSUB
    PUTSTATIC C.timer : J
    LDC 100
    INVOKESTATIC java/lang/Thread.sleep(J)V
    GETSTATIC C.timer : J
    INVOKESTATIC java/lang/System.currentTimeMillis()J
            LADD
    PUTSTATIC C.timer : J
            RETURN
    MAXSTACK = 4
    MAXLOCALS = 1
    

    我们看到我们需要在函数开始的时候插入4行代码结束的时候插入另外4行代码。我们还需要更新最大操作栈的大小,因此我们在visitCode中添加如下代码

    public void visitCode() {
        mv.visitCode();
        mv.visitFieldInsn(GETSTATIC, owner, "timer", "J");
        mv.visitMethodInsn(INVOKESTATIC, "java/lang/System",
                "currentTimeMillis", "()J");
        mv.visitInsn(LSUB);
        mv.visitFieldInsn(PUTSTATIC, owner, "timer", "J");
    }
    

    我们需要添加另外4条指令,在return或者xRETURN、ATHROW之前。这些指令都没有参数,都是使用visitInsn方法访问的

      public void visitInsn(int opcode) {
        if ((opcode >= IRETURN && opcode <= RETURN) || opcode == ATHROW) {
            mv.visitFieldInsn(GETSTATIC, owner, "timer", "J");
            mv.visitMethodInsn(INVOKESTATIC, "java/lang/System",
                    "currentTimeMillis", "()J");
            mv.visitInsn(LADD);
            mv.visitFieldInsn(PUTSTATIC, owner, "timer", "J");
        }
        mv.visitInsn(opcode);
    }
    

    最后我们需要更新最大操作栈的大小。由于我们向操作栈添加了两个long型变量,因此最坏情况maxsize+4

    public void visitMaxs(int maxStack, int maxLocals) {
        mv.visitMaxs(maxStack + 4, maxLocals);
    }
    

    当然我们也可以依赖COMPUTE_MAX来计算。
    我们的ClassVisitor可以这样来写:

    public class AddTimerAdapter extends ClassVisitor {
        private String owner;
        private boolean isInterface;
    
        public AddTimerAdapter(ClassVisitor cv) {
            super(ASM4, cv);
        }
    
        @Override
        public void visit(int version, int access, String name,
                          String signature, String superName, String[] interfaces) {
            cv.visit(version, access, name, signature, superName, interfaces);
            owner = name;
            isInterface = (access & ACC_INTERFACE) != 0;
        }
    
        @Override
        public MethodVisitor visitMethod(int access, String name,
                                         String desc, String signature, String[] exceptions) {
            MethodVisitor mv = cv.visitMethod(access, name, desc, signature,
                    exceptions);
            if (!isInterface && mv != null && !name.equals("<init>")) {
                mv = new AddTimerMethodAdapter(mv);
            }
            return mv;
        }
    
        @Override
        public void visitEnd() {
            if (!isInterface) {
                FieldVisitor fv = cv.visitField(ACC_PUBLIC + ACC_STATIC, "timer",
                        "J", null, null);
                if (fv != null) {
                    fv.visitEnd();
                }
            }
            cv.visitEnd();
        }
    }
    

    对于label和frames而言,我们知道visitLabel的调用是在其关联代码前的。如果代码跳转到ICONST_0 IADD ,当我们把这两个指令删除后跳转后直接执行后续的代码,这正是我们期望的。而如果是跳转到IADD 我们就不能直接删除相关指令了,不过这样的话ICONST_0和IADD之前肯定有一个label.如果在两个指令之间我们访问了stack map frame, 我们也不能删除这些指令。这些case都可以通过把label和frame当做需要match的指令(我们要记录指令出现的位置及后续指令的状态)来处理。编译后的class文件同时包含了其对应源代码中的行号信息用于异常栈的处理。
    很多时候,我们需要记录某一指令调用时所处的状态才能识别固定的pattern,例如如果我们想找出class中自赋值语句(f=f this.f = this.f)等就需要记录ALOAD 0的状态

    class RemoveGetFieldPutFieldAdapter extends PatternMethodAdapter {
        private final static int SEEN_ALOAD_0 = 1;
        private final static int SEEN_ALOAD_0ALOAD_0 = 2;
        private final static int SEEN_ALOAD_0ALOAD_0GETFIELD = 3;
        private String fieldOwner;
        private String fieldName;
        private String fieldDesc;
    
        public RemoveGetFieldPutFieldAdapter(MethodVisitor mv) {
            super(mv);
        }
    
        @Override
        public void visitVarInsn(int opcode, int var) {
            switch (state) {
                case SEEN_NOTHING: // S0 -> S1
                    if (opcode == ALOAD && var == 0) {
                        state = SEEN_ALOAD_0;
                        return;
                    }
                    break;
                case SEEN_ALOAD_0: // S1 -> S2
                    if (opcode == ALOAD && var == 0) {
                        state = SEEN_ALOAD_0ALOAD_0;
                        return;
                    }
                    break;
                case SEEN_ALOAD_0ALOAD_0: // S2 -> S2
                    if (opcode == ALOAD && var == 0) {
                        mv.visitVarInsn(ALOAD, 0);
                        return;
                    }
                    break;
            }
            visitInsn();
            mv.visitVarInsn(opcode, var);
        }
    
        @Override
        public void visitFieldInsn(int opcode, String owner, String name,
                                   String desc) {
            switch (state) {
                case SEEN_ALOAD_0ALOAD_0: // S2 -> S3
                    if (opcode == GETFIELD) {
                        state = SEEN_ALOAD_0ALOAD_0GETFIELD;
                        fieldOwner = owner;
                        fieldName = name;
                        fieldDesc = desc;
                        return;
                    }
                    break;
                case SEEN_ALOAD_0ALOAD_0GETFIELD: // S3 -> S0
                    if (opcode == PUTFIELD && name.equals(fieldName)) {
                        state = SEEN_NOTHING;
                        return;
                    }
                    break;
            }
            visitInsn();
            mv.visitFieldInsn(opcode, owner, name, desc);
        }
    
        @Override
        protected void visitInsn() {
            switch (state) {
                case SEEN_ALOAD_0: // S1 -> S0
                    mv.visitVarInsn(ALOAD, 0);
                    break;
                case SEEN_ALOAD_0ALOAD_0: // S2 -> S0
                    mv.visitVarInsn(ALOAD, 0);
                    mv.visitVarInsn(ALOAD, 0);
                    break;
                case SEEN_ALOAD_0ALOAD_0GETFIELD: // S3 -> S0
                    mv.visitVarInsn(ALOAD, 0);
                    mv.visitVarInsn(ALOAD, 0);
                    mv.visitFieldInsn(GETFIELD, fieldOwner, fieldName, fieldDesc);
                    break;
            }
            state = SEEN_NOTHING;
        }
    }
    

    工具类

    ClassVisitor中介绍的工具类在这里仍然是适用的。

    • Type xLOAD xADD xRETURN 均是一些类型相关的指令,而Type提供了getOpcode可以用于获取适当的指令。t.getOpcode(IMUL)会返回FMUL如果t是Type.FLOAT_TYPE

    • TraceClassVisitor 和之前用法一样,不过如若你指向打印某个方法的指令,你可以使用TraceMethodVisitor

      public MethodVisitor visitMethod(int access, String name,
                                     String desc, String signature, String[] exceptions) {
        MethodVisitor mv = cv.visitMethod(access, name, desc, signature,
                exceptions);
        if (debug && mv != null && ...) { // if this method must be traced
            Printer p = new Textifier(ASM4) {
                @Override public void visitMethodEnd() {
                    print(aPrintWriter); // print it after it has been visited
                }
            };
            mv = new TraceMethodVisitor(mv, p);
        }
        return new MyMethodAdapter(mv);
      }
      
    • CheckClassAdapter,同样你也可以选择CheckMethodAdapter

    • ASMifier 可以用于生成产生某个类的ASM代码

    • AnalyzerAdapter 这个adapter主要用于计算stack map frames,其基于visitFrame.它帮助我们做frame的压缩,包括删除可快速推断出来的及重复的。

    • LocalVariablesSorter 这个adapter会重新计算本地符号表的大小。

    • AdviceAdapter 当需要在函数开始或者结束的时候插入代码可以使用这个Adapter。它会自动处理构造函数构造函数开始到调用完super是不允许插入代码的。

    相关文章

      网友评论

        本文标题:ASM简介(四)

        本文链接:https://www.haomeiwen.com/subject/wowtsttx.html