美文网首页
2019-12-12 解密字节码

2019-12-12 解密字节码

作者: QuinnSun | 来源:发表于2019-12-11 13:18 被阅读0次

    1 字节码

    1.1 字节码

    Java之所以可以“一次编译,到处运行”,一是因为JVM针对各种操作系统、平台都进行了定制,二是因为无论在什么平台,都可以编译生成固定格式的字节码(.class文件)供JVM使用。之所以被称之为字节码,是因为字节码文件由十六进制值组成,而JVM以两个十六进制值为一组,即以字节为单位进行读取。在Java中一般是用javac命令编译源代码为字节码文件,一个.java文件从编译到运行的示例如图1所示。


    image.png

    了解字节码可以更准确、直观地理解Java语言中更深层次的东西,字节码增强技术在Spring AOP、各种ORM框架、热部署中常被使用。此外,由于JVM规范的存在,只要最终可以生成符合规范的字节码就可以在JVM上运行,因此这就给了各种运行在JVM上的语言(如Scala、Groovy、Kotlin)一种契机,可以扩展Java所没有的特性或者实现各种语法糖。

    1.2 字节码结构

    .java文件通过javac编译后将得到一个.class文件,比如编写一个简单的ByteCodeDemo类,如下图2的左侧部分:


    image.png

    编译后生成ByteCodeDemo.class文件,打开后是一堆十六进制数,按字节为单位进行分割后展示如上图右侧部分所示。JVM规范要求每一个字节码文件都要由十部分按照固定的顺序组成,整体结构:


    image.png
    (1) 魔数
    所有的.class文件的前四个字节都是魔数,魔数的固定值为:0xcafebabe。魔数放在文件开头,JVM可以根据文件的开头来判断这个文件是否可能是一个.class文件,如果是,才会继续进行之后的操作。

    (2)版本号
    魔数之后的4个字节为版本号,前两个字节表示次版本号(Minor Version),后两个字节表示主版本号(Major Version)。上图中版本号为“00 00 00 34”,次版本号转化为十进制为0,主版本号转化为十进制为52,序号52对应的主版本号为1.8,所以编译该文件的Java版本号为1.8.0。
    (3)常量池
    版本号之后为常量池区,这部分内容将在类加载后进入内存的运行时常量池中存放。常量池中存储两类数据:字面量和符号引用。
    字面量:(1)文本字符串 (2)八种基本类型的值 (3)被声明为final的常量等;
    符号引用:类和接口的全局限定名、字段的名称和描述符、方法的名称和描述符等;


    image.png
    • 常量池计数器(constant_pool_count):由于常量的数量不固定,所以需要先放置两个字节来表示常量池容量计数值。示例代码的字节码前10个字节如下图所示,将十六进制的24转化为十进制值为36,去掉下标“0”(常量池区从01开始计数,计数器统计数据从0开始统计需要减1为实际常量数),即这个类文件中共有35个常量。


      image.png
    • 常量池数据区:数据区是由(constant_pool_count-1)个cp_info结构组成,一个cp_info结构对应一个常量。在字节码中共有14种类型的cp_info,每种类型的结构都是固定的。



      具体以CONSTANT_utf8_info为例。首先一个字节“tag”,它的值取自上图中对应项的Tag,由于它的类型是utf8_info,所以值为“01”。接下来两个字节标识该字符串的长度Length,然后Length个字节为这个字符串具体的值。表示为:该常量类型为utf8字符串,长度为一字节,数据为“a”。


    我们可以通过javap -verbose ByteCodeDemo命令,查看JVM反编译后的完整常量池


    或者使用idea工具jclasslib

    (4)访问标志
    常量池区之后的两个字节描述该Class是类还是接口,以及是否被Public、Abstract、Final等修饰符修饰。JVM规范规定了如下图9的访问标志(Access_Flag)。需要注意的是,JVM并没有穷举所有的访问标志,而是使用按位或操作来进行描述的,比如某个类的修饰符为Public Final,则对应的访问修饰符的值为ACC_PUBLIC | ACC_FINAL,即0x0001 | 0x0010=0x0011。
    image.png
    (5)当前类名
    访问标志后的两个字节,描述的是当前类的全限定名。这两个字节保存的值为常量池中的索引值,根据索引值就能在常量池中找到这个类的全限定名。
    (6)父类名称
    当前类名后的两个字节,描述父类的全限定名,同上,保存的也是常量池中的索引值。
    (7)接口信息
    父类名称后为两字节的接口计数器,描述了该类或父类实现的接口数量。紧接着的n个字节是所有接口名称的字符串常量的索引值。
    (8)字段表
    字段表用于描述类和接口中声明的变量,包含类级别的变量以及实例变量,但是不包含方法内部声明的局部变量。字段表也分为两部分,第一部分为两个字节,描述字段个数;第二部分是每个字段的详细信息fields_info。字段表结构如下图所示:

    示例中字段的访问标志如下图,0002对应为Private。通过索引下标在图8中常量池分别得到字段名为“a”,描述符为“I”(代表int)。综上,就可以唯一确定出一个类中声明的变量private int a。

    (9)方法表
    字段表结束后为方法表,方法表也是由两部分组成,第一部分为两个字节描述方法的个数;第二部分为每个方法的详细信息。方法的详细信息较为复杂,包括方法的访问标志、方法名、方法的描述符以及方法的属性,如下图所示:

    通过javap -verbose来分析方法对属性部分。包括以下三部分:
    • Code区:源代码对应的JVM指令操作码,在进行字节码增强时重点操作的就是“Code区”这一部分。
    • LineNumberTable:行号表,将Code区的操作码和源代码中的行号对应,Debug时会起到作用(源代码走一行,需要走多少个JVM指令操作码)。
    • LocalVariableTable:本地变量表,包含This和局部变量,之所以可以在每一个方法内部都可以调用This,是因为JVM将This作为每一个方法的第一个参数隐式进行传入。当然,这是针对非Static方法而言。

    Code区的红色编号0~17,就是.java中的方法源代码编译后让JVM真正执行的操作码。为了帮助人们理解,反编译后看到的是十六进制操作码所对应的助记符,十六进制值操作码与助记符的对应关系,以及每一个操作码的用处可以查看Oracle官方文档进行了解,在需要用到时进行查阅即可。比如上图中第一个助记符为iconst_2,对应到图2中的字节码为0x05,用处是将int值2压入操作数栈中。


    (10)附加属性表
    字节码的最后一部分,该项存放了在该文件中类或接口所定义属性的基本信息。
    方法表后为两字节的属性表个数,第二部分是属性表的详细信息attribute_info

    • attribute_name_index:占2个字节,表示属性名字的索引,指向常量池。
    • attribute_length:占4个字节,表示属性的长度。
    • info[attribute_length]:占1个字节,表示具体的信息。

    1.3 操作数栈和字节码

    JVM的指令集是基于栈而不是寄存器,基于栈可以具备很好的跨平台性(因为寄存器指令集往往和硬件挂钩),但缺点在于,要完成同样的操作,基于栈的实现需要更多指令才能完成(因为栈只是一个FILO结构,需要频繁压栈出栈)。另外,由于栈是在内存实现的,而寄存器是在CPU的高速缓存区,相较而言,基于栈的速度要慢很多,这也是为了跨平台性而做出的牺牲。

    https://pic2.zhimg.com/v2-ac42012daa48396d66eda1e9adcdb8c5_b.webp

    2 字节码增强

    字节码增强技术就是一类对现有字节码进行修改或者动态生成全新字节码文件的技术。


    image.png

    2.1 ASM

    对于需要手动操纵字节码的需求,可以使用ASM,它可以直接生产 .class字节码文件,也可以在类被加载入JVM之前动态修改类行为。ASM的应用场景有AOP(Cglib就是基于ASM)、热部署、修改其他jar包中的类等。建议先对访问者模式进行了解。访问者模式主要用于修改或操作一些数据结构比较稳定的数据,字节码文件的结构是由JVM固定的,所以很适合利用访问者模式对字节码文件进行修改。


    image.png

    demo

    public class Base {
        public void process(){
            System.out.println("process");
        }
    }
    
    public class MyClassVisitor extends ClassVisitor implements Opcodes {
        public MyClassVisitor(ClassVisitor cv) {
            super(ASM5, 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);
        }
        @Override
        public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
            MethodVisitor mv = cv.visitMethod(access, name, desc, signature,
                    exceptions);
            //Base类中有两个方法:无参构造以及process方法,这里不增强构造方法
            if (!name.equals("<init>") && mv != null) {
                mv = new MyMethodVisitor(mv);
            }
            return mv;
        }
        class MyMethodVisitor extends MethodVisitor implements Opcodes {
            public MyMethodVisitor(MethodVisitor mv) {
                super(Opcodes.ASM5, mv);
            }
    
            @Override
            public void visitCode() {
                super.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);
            }
            @Override
            public void visitInsn(int opcode) {
                if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN)
                        || opcode == Opcodes.ATHROW) {
                    //方法在返回之前,打印"end"
                    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);
                }
                mv.visitInsn(opcode);
            }
        }
    }
    
    public class Generator {
        public static void main(String[] args) throws Exception {
            //读取
            ClassReader classReader = new ClassReader("test.asm.Base");
            ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
            //处理
            ClassVisitor classVisitor = new MyClassVisitor(classWriter);
            classReader.accept(classVisitor, ClassReader.SKIP_DEBUG);
            byte[] data = classWriter.toByteArray();
            //输出
            File f = new File("/Users/sunqiuxiang/Desktop/code/scfclient/target/classes/test/asm/Base.class");
            FileOutputStream fout = new FileOutputStream(f);
            fout.write(data);
            fout.close();
            System.out.println("now generator cc success!!!!!");
        }
    }
    

    利用这个类就可以实现对字节码的修改。详细解读其中的代码,对字节码做修改的步骤是:

    • 首先通过MyClassVisitor类中的visitMethod方法,判断当前字节码读到哪一个方法了。跳过构造方法后,将需要被增强的方法交给内部类MyMethodVisitor来进行处理。
    • 接下来,进入内部类MyMethodVisitor中的visitCode方法,它会在ASM开始访问某一个方法的Code区时被调用,重写visitCode方法,将AOP中的前置逻辑就放在这里。
    • MyMethodVisitor继续读取字节码指令,每当ASM访问到无参数指令时,都会调用MyMethodVisitor中的visitInsn方法。我们判断了当前指令是否为无参数的“return”指令,如果是就在它的前面添加一些指令,也就是将AOP的后置逻辑放在该方法中。
    • 综上,重写MyMethodVisitor中的两个方法,就可以实现AOP了,而重写方法时就需要用ASM的写法,手动写入或者修改字节码。通过调用methodVisitor的visitXXXXInsn()方法就可以实现字节码的插入,XXXX对应相应的操作码助记符类型,比如mv.visitLdcInsn("end")对应的操作码就是ldc "end",即将字符串“end”压入栈。

    2.2 Javassist

    ASM是在指令层次上操作字节码的,接下来再简单介绍另外一类框架:强调源代码层次操作字节码的框架Javassist。
    利用Javassist实现字节码增强时,可以无须关注字节码刻板的结构,其优点就在于编程简单。直接使用java编码的形式,不需要了解虚拟机指令,就能动态改变类的结构或者动态生成类。其中最重要的是ClassPool、CtClass、CtMethod、CtField这四个类:

    • CtClass(compile-time class):编译时类信息,它是一个class文件在代码中的抽象表现形式,可以通过一个类的全限定名来获取一个CtClass对象,用来表示这个类文件。
    • ClassPool:从开发视角来看,ClassPool是一张保存CtClass信息的HashTable,key为类名,value为类名对应的CtClass对象。当我们需要对某个类进行修改时,就是通过pool.getCtClass("className")方法从pool中获取到相应的CtClass。
    • CtMethod、CtField:这两个比较好理解,对应的是类中的方法和属性。
      demo
    public class JavassistTest {
        public static void main(String[] args) throws Exception {
            ClassPool cp = ClassPool.getDefault();
            CtClass cc = cp.get("test.javassist.Base");
            CtMethod m = cc.getDeclaredMethod("process");
            m.insertBefore("{ System.out.println(\"start\"); }");
            m.insertAfter("{ System.out.println(\"end\"); }");
            Class c = cc.toClass();
            Base h = (Base) c.newInstance();
            h.process();
        }
    }
    

    2.3 Instrument

    2.4 使用场景

    字节码增强技术的可使用范围就不再局限于JVM加载类前了。通过上述几个类库,我们可以在运行时对JVM中的类进行修改并重载了。通过这种手段,可以做的事情就变得很多了:

    热部署:不部署服务而对线上服务做修改,可以做打点、增加日志等操作。
    Mock:测试时候对某些服务做Mock。
    性能诊断工具:比如bTrace就是利用Instrument,实现无侵入地跟踪一个正在运行的JVM,监控到类和方法级别的状态信息。

    参考:
    Java ByteCode
    java字节码指令
    常量池、字符串字面量、JAVA编译
    ASM

    相关文章

      网友评论

          本文标题:2019-12-12 解密字节码

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