JAVA字节码|ASM解析

1,969 阅读14分钟

1. 一次编译,多处运行

  • Java“一次编译,多处运行”,原因有如下:
    1. JVM针对各种操作系统、平台都进行了定制,
    2. 无论在什么平台,都可以编译生成固定格式的字节码(.class文件)供JVM使用。
  • 之所以被称之为字节码,是因为字节码文件由十六进制值组成,而JVM以两个十六进制值为一组,即以字节为单位进行读取。

2. 编译.class以及反编译

  • 我们以一个.java文件为例进行分析
package com.d.hook;

public class bytecodeDome {
    static String TAG = "ByteCode";

    public static void main(String[] args) {
        System.out.println(TAG);
    }
}
  • 通过javac进行编译得到的.class内容如下
cafe babe 0000 0034 0022 0a00 0700 1309
0014 0015 0900 0600 160a 0017 0018 0800
1907 001a 0700 1b01 0003 5441 4701 0012
4c6a 6176 612f 6c61 6e67 2f53 7472 696e
673b 0100 063c 696e 6974 3e01 0003 2829
5601 0004 436f 6465 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 046d 6169
6e01 0016 285b 4c6a 6176 612f 6c61 6e67
2f53 7472 696e 673b 2956 0100 083c 636c
696e 6974 3e01 000a 536f 7572 6365 4669
6c65 0100 1162 7974 6563 6f64 6544 6f6d
652e 6a61 7661 0c00 0a00 0b07 001c 0c00
1d00 1e0c 0008 0009 0700 1f0c 0020 0021
0100 0842 7974 6543 6f64 6501 0017 636f
6d2f 642f 686f 6f6b 2f62 7974 6563 6f64
6544 6f6d 6501 0010 6a61 7661 2f6c 616e
672f 4f62 6a65 6374 0100 106a 6176 612f
6c61 6e67 2f53 7973 7465 6d01 0003 6f75
7401 0015 4c6a 6176 612f 696f 2f50 7269
6e74 5374 7265 616d 3b01 0013 6a61 7661
2f69 6f2f 5072 696e 7453 7472 6561 6d01
0007 7072 696e 746c 6e01 0015 284c 6a61
7661 2f6c 616e 672f 5374 7269 6e67 3b29
5600 2100 0600 0700 0000 0100 0800 0800
0900 0000 0300 0100 0a00 0b00 0100 0c00
0000 1d00 0100 0100 0000 052a b700 01b1
0000 0001 000d 0000 0006 0001 0000 0003
0009 000e 000f 0001 000c 0000 0026 0002
0001 0000 000a b200 02b2 0003 b600 04b1
0000 0001 000d 0000 000a 0002 0000 0007
0009 0008 0008 0010 000b 0001 000c 0000
001e 0001 0000 0000 0006 1205 b300 03b1
0000 0001 000d 0000 0006 0001 0000 0004
0001 0011 0000 0002 0012 
  • 通过javap -verbose得到反编译后的代码
  Last modified 2021-11-27; size 538 bytes
  MD5 checksum d3c1cc8d40b8d8cd645fd2e2127ac285
  Compiled from "bytecodeDome.java"
public class com.d.hook.bytecodeDome
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #7.#19         // java/lang/Object."<init>":()V
   #2 = Fieldref           #20.#21        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = Fieldref           #6.#22         // com/d/hook/bytecodeDome.TAG:Ljava/lang/String;
   #4 = Methodref          #23.#24        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = String             #25            // ByteCode
   #6 = Class              #26            // com/d/hook/bytecodeDome
   #7 = Class              #27            // java/lang/Object
   #8 = Utf8               TAG
   #9 = Utf8               Ljava/lang/String;
  #10 = Utf8               <init>
  #11 = Utf8               ()V
  #12 = Utf8               Code
  #13 = Utf8               LineNumberTable
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               <clinit>
  #17 = Utf8               SourceFile
  #18 = Utf8               bytecodeDome.java
  #19 = NameAndType        #10:#11        // "<init>":()V
  #20 = Class              #28            // java/lang/System
  #21 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #22 = NameAndType        #8:#9          // TAG:Ljava/lang/String;
  #23 = Class              #31            // java/io/PrintStream
  #24 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #25 = Utf8               ByteCode
  #26 = Utf8               com/d/hook/bytecodeDome
  #27 = Utf8               java/lang/Object
  #28 = Utf8               java/lang/System
  #29 = Utf8               out
  #30 = Utf8               Ljava/io/PrintStream;
  #31 = Utf8               java/io/PrintStream
  #32 = Utf8               println
  #33 = Utf8               (Ljava/lang/String;)V
{
  static java.lang.String TAG;
    descriptor: Ljava/lang/String;
    flags: ACC_STATIC

  public com.d.hook.bytecodeDome();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: getstatic     #3                  // Field TAG:Ljava/lang/String;
         6: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         9: return
      LineNumberTable:
        line 7: 0
        line 8: 9

  static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: ldc           #5                  // String ByteCode
         2: putstatic     #3                  // Field TAG:Ljava/lang/String;
         5: return
      LineNumberTable:
        line 4: 0
}

3. 字节码结构

  • JVM严格规范定义了字节码文件的结构。分为十个部分,且顺序固定:
    • 魔数:Magic Number
    • 版本号:Version
    • 常量池:Constant Pool
    • 访问表示:Access Flag
    • 当前类名:This Class
    • 父类名:Super Class
    • 接口信息:Interfaces
    • 字段表:fieIds
    • 方法表:methods
    • 附加属性:Attributes

3.1 魔数

  • .class文件的前四字节是固定魔数:CAFE BABE。
    • JVM根据魔数来验证是否是.class文件

3.2 版本号

  • 魔数之后的四字节是版本号:0000 0034
    • 前两字节是次版本号:0000->10进制=0
    • 后两字节是主版本号:0034->10进制=52->1.8

3.3 常量池

3.3.1. 常量池结构

  • 版本号之后是常量池,首2个字节是常量池内容的数量,之后是常量池表
  • 常量池数组中的元素个数=常量池数-1(其中0暂时不使用)
  • 索引为 0 也是一个常量(保留常量),只不过它不位于常量表中,这个常量就对应 null 值;所以,常量池的索引从 1 开始而非 0 开始。

3.3.2. 常量类型类型

  • 字面量:类似于JAVA之中的常量
  • 符号引用:其本质就是字符串,但是包含足够的信息,以供实际使用时可以找到相应的位置。包含信息如下:
    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符

3.3.3. 描述信息

  • 每个变量 / 字段都有描述信息,用于描述字段的数据类型、方法的参数列表(包括数量、类型与顺序)与返回值。规则如下:

  • 基本数据类型和代表无返回值的 void 类型都用一个大写字符来表示,

    • B-byte,C-char,D-double,F-float,I-int,J-long,S-short,Z-boolean,V-void
  • 对象类型则使用字符工加对象的全限定名称来表示。

    • Ljava/lang/String
  • 为了压缩字节码文件的体积,对于基本数据类型,JVM 都只使用一个大写字母来表示

  • 描述方法时,按照先参数列表,后返回值的顺序来描述。参数列表按照参数的严格顺序放在一组 () 之内

    • String name(int id,String name) ->:(I,Ljava/lang/String)Ljava/lang/String

3.3.4 常量池数据结构类型

类型标志描述
CONSTANT_Utf8_info1UTF-8 编码的字符串
CONSTANT_Integer_info3整型字面量
CONSTANT_Float_info4浮点型字面量
CONSTANT_Long_info5长整型型字面量
CONSTANT_Double_info6双精度浮点型字面量
CONSTANT_Class_info7类或接口的符号引用
CONSTANT_String_info8字符串类型字面量
CONSTANT_Fieldref_info9字段的符号引用
CONSTANT_Methodref_info10类中方法的符号引用
CONSTANT_InterfaceMethodref_info11接口中方法的符号引用
CONSTANT_NameAndType_info12字段或方法的部分符号引用
CONSTANT_MethodHandle_info15表示方法句柄
CONSTANT_MethodType_info16表示方法类型
CONSTANT_InvokeDynamic_info18表示一个动态方法调用点

3.3.5. 常量池总结

  • 常量池用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
    • JAVA 是在虚拟机加载 Class 文件的时候进行动态连接。并不会在 Class 文件中保存各个方法、字段的最终内存布局信息。
    • 因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。
    • 当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
    • 运行一次之后,符号引用会被替换为直接引用,通过直接引用虚拟机可以直接在该类的内存区域中找到方法字节码的起始位置。

3.4 访问标志

  • 常量池之后的两个字节,描述该Class是类还是接口,以及是否被Public、Abstract、Final等修饰符修饰

  • 需要注意的是,JVM是使用按位或操作来进行描述的,

    • 如修饰符为Public Final,则为ACC_PUBLIC | ACC_FINAL->0x0001 | 0x0010=0x0011。
  • 这里引用下ASM内部的访问标识

int ACC_PUBLIC = 0x0001; // class, field, method
int ACC_PRIVATE = 0x0002; // class, field, method
int ACC_PROTECTED = 0x0004; // class, field, method
int ACC_STATIC = 0x0008; // field, method
int ACC_FINAL = 0x0010; // class, field, method, parameter
int ACC_SUPER = 0x0020; // class
int ACC_SYNCHRONIZED = 0x0020; // method
int ACC_OPEN = 0x0020; // module
int ACC_TRANSITIVE = 0x0020; // module requires
int ACC_VOLATILE = 0x0040; // field
int ACC_BRIDGE = 0x0040; // method
int ACC_STATIC_PHASE = 0x0040; // module requires
int ACC_VARARGS = 0x0080; // method
int ACC_TRANSIENT = 0x0080; // field
int ACC_NATIVE = 0x0100; // method
int ACC_INTERFACE = 0x0200; // class
int ACC_ABSTRACT = 0x0400; // class, method
int ACC_STRICT = 0x0800; // method
int ACC_SYNTHETIC = 0x1000; // class, field, method, parameter, module *
int ACC_ANNOTATION = 0x2000; // class
int ACC_ENUM = 0x4000; // class(?) field inner
int ACC_MANDATED = 0x8000; // parameter, module, module *
int ACC_MODULE = 0x8000; // class

3.5. 当前类名

  • 访问标识后的两个字节,用于描述当前类的全限定名,也就是对应常量池的索引值

3.6. 父类名

  • 当前类名后的两个字节,用于描述当前类的父类全限定名,也就是对应常量池的索引值

3.7. 接口信息

  • 父类名后的两个字节,用于描述当前类或父类实现的接口数量,之后的字节就是常量池中接口的索引值

3.8. 字段表

  • 接口信息后是字段表,用于描述类和接口中的变量以及实例变量。
    • 不包含方法内的变量和局部变量

3.8.1.字段表结构

  • 首两个字节描述字段个数,之后跟着字段的详细信息。
    • 详细信息如下:
      • 访问标识
      • 字段名称
      • 字段描述符
      • 字段属性个数
      • 属性列表

3.9. 方法表

  • 字段表后是方法表

3.9.1. 方法表结构

  • 首两个字节描述方法个数,后面是方法的详细信息
    • 详细信息如下:
      • 方法访问标识
      • 方法名称
      • 方法描述
      • 方法属性:分为不同区域
        • Code区:源代码对应的JVM指令操作码,可以通过动态植入字节码实现扩展
        • LineNumberTable:行号表,将Code区的操作码和源代码中的行号对应,Debug时会根据标识实现源代码走一行,需要走多少个JVM指令操作码
        • LocalVariableTable:本地变量表,包含This和局部变量,之所以可以在每一个方法内部都可以调用This,是因为JVM将This作为每一个方法的第一个参数隐式进行传入。

3.10. 附加属性

  • 字节码的最后一部分,存放了在该文件中类或接口所定义属性的基本信息。

3.11.0. 操作数栈和字节码

  • JVM的指令集是基于栈而不是寄存器,基于栈可以具备很好的跨平台性(因为寄存器指令集往往和硬件挂钩),但缺点在于,要完成同样的操作,基于栈的实现需要更多指令才能完成(因为栈只是一个FILO结构,需要频繁压栈出栈)。
  • 由于栈是在内存实现的,而寄存器是在CPU的高速缓存区,相较而言,基于栈的速度要慢很多,这也是为了跨平台性而做出的牺牲。
  • 操作数栈的每一个元素可以是任意Java数据类型,32位的数据类型占一个栈容量,64位的数据类型占2个栈容量,且在方法执行的任意时刻,操作数栈的深度都不会超过max_stacks中设置的最大值。
  • 当一个方法刚刚开始执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。一个完整的方法执行期间往往包含多个这样出栈/入栈的过程。

4. ASM

  • ASM是一个通用的 Java 字节码操作和分析框架。
  • 可用于直接以二进制形式修改现有类或动态生成类。
  • 提供了一些常见的字节码转换和分析算法,可以从中构建自定义的复杂转换和代码分析工具。
  • ASM 提供与其他 Java 字节码框架类似的功能,但侧重于性能。因为它被设计和实现得尽可能小和尽可能快,所以它非常适合在动态系统中使用(但当然也可以以静态方式使用,例如在编译器中)。

4.1. visitor模式

  • ASM是基于访问者模式进行开发实现的动态AOP
  • 通过这种的方式,就能在不修改元素的条件下,添加对于元素访问的操作,只需要让元素类接受一个新的访问者就行。
  • 这样通过接受转发行为让元素类和操作类进行了解耦,所以这种模式这种模式对于添加新的访问者的操作是符合“开闭原则”的
  • 但是如果我们一旦要增加新的元素时,就会导致所有的访问者类都需要增加相应的访问方法,这是明显违反设计模式,所以访问者模式并不适合元素类频繁变动的场景,这也是访问者模式自身最大的缺陷。

4.2. ASM core

  • ClassReader:用于读取已经编译好的.class文件。
  • ClassWriter:用于重新构建编译后的类,如修改类名、属性以及方法,也可以生成新的类的字节码文件。
  • 各种Visitor类:如上所述,CoreAPI根据字节码从上到下依次处理,对于字节码文件中不同的区域有不同的Visitor,比如用于访问方法的MethodVisitor、用于访问类变量的FieldVisitor、用于访问注解的AnnotationVisitor等。为了实现AOP,重点要使用的是MethodVisitor。

5.其他

  • 灵活使用ASM的前提是对字节码十分的属性,ASM本身以及非常强大,难度也不大,只要字节码知识足够深厚,可以实现非常多动态注入的代码

  • 具体编写的时候需要参考jdk操作表以及ASM手册

  • ASM Doc,

  • Java® Virtual Machine Specification