alighters

程序、写作、人生

Java 字节码实践 - 解读

| Comments

最近刚看完 深入理解 Java 虚拟机 一书中的第 6 章 (类文件结构),便迫不及待地自己写一个小的 Demo,来自己分析一把 Java 源文件经过编译之后成为字节码文件到底是个什么东西?先由一个简单的小 Demo 开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.lighters.demo;

public class Test {

    private String name;

    public Test(String name) {
        this.name = name;
    }

    public void sayHello() {
        System.out.printf("Hello " + name);
    }
}

运行 javac Test.java,会在此目录下生成 Test.class 的文件。但是这个 class 字节码文件,是以二进制的形式存储的,我们需要以十六进制的形式进行查看。这里我使用 Vim 进行查看,在命令行模式输入:%!xxd,来采用十六进制的格式查看,得到下面的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
location: 0 1 2 3  4 5  6 7  8 9  a b  c d  e f

0000000: cafe babe 0000 0034 002c 0a00 0900 1609  .......4.,......
0000010: 000b 0017 0900 1800 1907 001a 0a00 0400  ................
0000020: 1608 001b 0a00 0400 1c0a 0004 001d 0700  ................
0000030: 1e0a 001f 0020 0700 2101 0004 6e61 6d65  ..... ..!...name
0000040: 0100 124c 6a61 7661 2f6c 616e 672f 5374  ...Ljava/lang/St
0000050: 7269 6e67 3b01 0006 3c69 6e69 743e 0100  ring;...<init>..
0000060: 1528 4c6a 6176 612f 6c61 6e67 2f53 7472  .(Ljava/lang/Str
0000070: 696e 673b 2956 0100 0443 6f64 6501 000f  ing;)V...Code...
0000080: 4c69 6e65 4e75 6d62 6572 5461 626c 6501  LineNumberTable.
0000090: 0008 7361 7948 656c 6c6f 0100 0328 2956  ..sayHello...()V
00000a0: 0100 0a53 6f75 7263 6546 696c 6501 0009  ...SourceFile...
00000b0: 5465 7374 2e6a 6176 610c 000e 0013 0c00  Test.java.......
00000c0: 0c00 0d07 0022 0c00 2300 2401 0017 6a61  ....."..#.$...ja
00000d0: 7661 2f6c 616e 672f 5374 7269 6e67 4275  va/lang/StringBu
00000e0: 696c 6465 7201 0006 4865 6c6c 6f20 0c00  ilder...Hello ..
00000f0: 2500 260c 0027 0028 0100 106a 6176 612f  %.&..'.(...java/
0000100: 6c61 6e67 2f4f 626a 6563 7407 0029 0c00  lang/Object..)..
0000110: 2a00 2b01 0016 636f 6d2f 6c69 6768 7465  *.+...com/lighte
0000120: 7273 2f64 656d 6f2f 5465 7374 0100 106a  rs/demo/Test...j
0000130: 6176 612f 6c61 6e67 2f53 7973 7465 6d01  ava/lang/System.
0000140: 0003 6f75 7401 0015 4c6a 6176 612f 696f  ..out...Ljava/io
0000150: 2f50 7269 6e74 5374 7265 616d 3b01 0006  /PrintStream;...
0000160: 6170 7065 6e64 0100 2d28 4c6a 6176 612f  append..-(Ljava/
0000170: 6c61 6e67 2f53 7472 696e 673b 294c 6a61  lang/String;)Lja
0000180: 7661 2f6c 616e 672f 5374 7269 6e67 4275  va/lang/StringBu
0000190: 696c 6465 723b 0100 0874 6f53 7472 696e  ilder;...toStrin
00001a0: 6701 0014 2829 4c6a 6176 612f 6c61 6e67  g...()Ljava/lang
00001b0: 2f53 7472 696e 673b 0100 136a 6176 612f  /String;...java/
00001c0: 696f 2f50 7269 6e74 5374 7265 616d 0100  io/PrintStream..
00001d0: 0670 7269 6e74 6601 003c 284c 6a61 7661  .printf..<(Ljava
00001e0: 2f6c 616e 672f 5374 7269 6e67 3b5b 4c6a  /lang/String;[Lj
00001f0: 6176 612f 6c61 6e67 2f4f 626a 6563 743b  ava/lang/Object;
0000200: 294c 6a61 7661 2f69 6f2f 5072 696e 7453  )Ljava/io/PrintS
0000210: 7472 6561 6d3b 0021 000b 0009 0000 0001  tream;.!........
0000220: 0002 000c 000d 0000 0002 0001 000e 000f  ................
0000230: 0001 0010 0000 002a 0002 0002 0000 000a  .......*........
0000240: 2ab7 0001 2a2b b500 02b1 0000 0001 0011  *...*+..........
0000250: 0000 000e 0003 0000 0007 0004 0008 0009  ................
0000260: 0009 0001 0012 0013 0001 0010 0000 003e  ...............>
0000270: 0003 0001 0000 0022 b200 03bb 0004 59b7  ......."......Y.
0000280: 0005 1206 b600 072a b400 02b6 0007 b600  .......*........
0000290: 0803 bd00 09b6 000a 57b1 0000 0001 0011  ........W.......
00002a0: 0000 000a 0002 0000 000c 0021 000d 0001  ...........!....
00002b0: 0014 0000 0002 0015                      ........

这里的输出还是蛮人性化的,每行开头前面的冒号前那一串是表示每行开头的第一个字符的位置索引。1 个字符是 4 位,即每两位是 1 个字节,一行则是 16 个 字节,对应十六进制表示为 0 - F。

PS: 第一行的内容为人为添加,方便定位列的索引。

可知,这里用十六进制表示,整个文件内容的大小没超过 3 位,所以这里用 3 位的十六进制,来表示地址。例如,若地址为0x000,指向的内容为 ca ;地址为 0x001,指向的内容为 fe;地址为0x011 ,指向的内容为 0b;下文都将以这样的形式来指向字节码中的内容。

准备工作做好之后,我们还需要关于的 Java 字节码的结构信息表,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ClassFile {
    u4             magic;
    u2             minor_version;
    u2             major_version;
    u2             constant_pool_count;
    cp_info        constant_pool[constant_pool_count-1];
    u2             access_flags;
    u2             this_class;
    u2             super_class;
    u2             interfaces_count;
    u2             interfaces[interfaces_count];
    u2             fields_count;
    field_info     fields[fields_count];
    u2             methods_count;
    method_info    methods[methods_count];
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

这里的 u1 、u2、 u4 表示的分别为 1 个字节,2个字节,4 个字节。接下来,我们将会跟字节码的结构信息表来一一对应在上面 Test.class 文件的解析:

魔数 - magic

在字节码结构表中,可知 magic 对应的是 4 个字节的容量,相应在 Test.class 文件位置为0x000 - 0x003 的 4 个字节,信息为 cafe babe。用来表示为这是一个 class 文件,能够被 JVM 所识别。

次版本 - minor_version

大小为两个字节,对应位置索引 0x004 - 0x005 的 2个字节,即 0x0000,表示此版本的大小为 0。

主版本 - major_version

大小为两个字节,对应位置索引为 0x006 - 0x007 的2个字节,即 0034,对应十进制的大小为 52,而初始的 Jvm 版本 1.0 支持的大小为 45,也就意味着这是由 JDK 1.8 生成的字节码,则之能由 Jvm 1.8 及以上版本才能解析上文的字节码文件。

常量池容量 - constant_pool_count

由 2 个字节来表示常量池的大小,这个大小包含自身,即其余的常量大小只能为 216 - 1,对应 Test.class 文件中的描述为 002c ,对应十进制大小为 44,表明还将有 43 个字节用来描述常量池。

常量池中主要存放两大类常量:字面量和符号引用。字面量主要指文本字符串,声明为 final 的常量值等。而符号引用属于编译原理方面的概念,主要包含类和接口的全限定名,字段的名称和描述符,方法的名称和描述符。

常量池 - constant_pool[constant_pool_count-1]

这里将会有 43 个常量。常量池包含着一组信息,不过他们的通用格式如下:

1
2
3
4
cp_info {
    u1 tag;
    u1 info[];
}

即 1 个自己的 tag描述,加上一组相应信息的描述。看到这里,我们继续接下来的字节,内容为 0x0a,对应十进制的 10,在下面的常量 tag 表中,进行查找,可知对应的常量类型为 Constant_Methodref,表示当前类方法的符号引用。

常量池 tag

接着查找 Constant_Methodref 的结构,如下:

1
2
3
4
5
CONSTANT_Methodref_info {
    u1 tag;
    u2 class_index;
    u2 name_and_type_index;
}

可知 第一个字节是为 tag 标记,这里已经确定了是刚才的 Constant_Methodref;然后是两个字节的 class_index ,指向的内容是指类在常量池中的索引;最后是两个字节的 name_and_type_index,同样也是方法的描述在常量池中的索引值。

先看 class_index,可知其内容是地址0x00b - 0x00c ,相应内容为 0x0009,即这里我们需要从 0x00b 的位置开始数,数至第 9 个常量。常量寻找定位的过程如下:

  • 常量1:CONSTANT_Methodref,一共占 5 个字节,位置为 0x00a - 0x00e
  • 常量2: CONSTANT_Fieldref,格式与常量1 methodref相同,5个字节,位置为 0x00f - 0x013
  • 常量3:CONSTANT_Fieldref,同上,位置为 0x014 - 0x018
  • 常量4:CONSTANT_Class,其格式为下述代码,3个字节,位置为 0x019 - 0x01b
1
2
3
4
CONSTANT_Class_info {
    u1 tag;
    u2 name_index;
}
  • 常量5:CONSTANT_Methodref, 5个字节,位置为 0x01c - 0x020
  • 常量6;CONSTANT_String,其格式如下代码,3个字节,位置为 0x021 - 0x023,这里的 string_index,也指向的是常量池中的内容,不过它将会指向的 是一个 CONSTANT_Utf8 的常量。
1
2
3
4
CONSTANT_String_info {
    u1 tag;
    u2 string_index;
}
  • 常量7:CONSTANT_Methodref, 5个字节,位置为 0x024 - 0x028
  • 常量8:CONSTANT_Methodref, 5个字节,位置为 0x029 - 0x02d
  • 常量9:CONSTANT_Class,3个字节,位置为 0x02e - 0x030。

到这里,可以看出第一个常量 CONSTANT_Methodref 中的 class_index 指向的是一个 CONSTANT_Class,另一个 name_and_type_index 将会指向一个 CONSTANT_NameAndType 的常量。发现这样阅读定位,实在是太费力了,好在 jdk 给我们提供了 javap 的命令工具。 用它来输出字节码的信息,来帮助我们阅读。在命令行下输入 javap -verbose Test.class,过滤其他输出,只关心我们的常量池输出,如下 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Constant pool:
   #1 = Methodref          #9.#22         // java/lang/Object."<init>":()V
   #2 = Fieldref           #11.#23        // com/lighters/demo/Test.name:Ljava/lang/String;
   #3 = Fieldref           #24.#25        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = Class              #26            // java/lang/StringBuilder
   #5 = Methodref          #4.#22         // java/lang/StringBuilder."<init>":()V
   #6 = String             #27            // Hello
   #7 = Methodref          #4.#28         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
   #8 = Methodref          #4.#29         // java/lang/StringBuilder.toString:()Ljava/lang/String;
   #9 = Class              #30            // java/lang/Object
  #10 = Methodref          #31.#32        // java/io/PrintStream.printf:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream;
  #11 = Class              #33            // com/lighters/demo/Test
  #12 = Utf8               name
  #13 = Utf8               Ljava/lang/String;
  #14 = Utf8               <init>
  #15 = Utf8               (Ljava/lang/String;)V
  #16 = Utf8               Code
  #17 = Utf8               LineNumberTable
  #18 = Utf8               sayHello
  #19 = Utf8               ()V
  #20 = Utf8               SourceFile
  #21 = Utf8               Test.java
  #22 = NameAndType        #14:#19        // "<init>":()V
  #23 = NameAndType        #12:#13        // name:Ljava/lang/String;
  #24 = Class              #34            // java/lang/System
  #25 = NameAndType        #35:#36        // out:Ljava/io/PrintStream;
  #26 = Utf8               java/lang/StringBuilder
  #27 = Utf8               Hello
  #28 = NameAndType        #37:#38        // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  #29 = NameAndType        #39:#40        // toString:()Ljava/lang/String;
  #30 = Utf8               java/lang/Object
  #31 = Class              #41            // java/io/PrintStream
  #32 = NameAndType        #42:#43        // printf:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream;
  #33 = Utf8               com/lighters/demo/Test
  #34 = Utf8               java/lang/System
  #35 = Utf8               out
  #36 = Utf8               Ljava/io/PrintStream;
  #37 = Utf8               append
  #38 = Utf8               (Ljava/lang/String;)Ljava/lang/StringBuilder;
  #39 = Utf8               toString
  #40 = Utf8               ()Ljava/lang/String;
  #41 = Utf8               java/io/PrintStream
  #42 = Utf8               printf
  #43 = Utf8               (Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream;

结合这个输出,再回过头来,查看之前的第一个常量,其类型为 Methodref,另包含指向 #9 和 #22 的索引;#9 类型为 Class,其包含一个 #30 的 utf8 的字符串描述 :java/lang/Object;# 22 指向的是 NameAndType 的索引,其指向的内容的 #14 的方法描述,及 #19 的方法参数及其返回值的描述。可知这个 Methodref 的最终指向的内容为 Objectinit 方法,()V 表达的意思是参数为空,返回值为 Void。

另外还有其他的方法,字段,类以及 utf8 的描述,而 uft8 描述的则是一组 ascii 码字符。

访问标记 - access_flags

在经过了 43 个大小的常量池,接下来便是两个字节的访问标记,其主要用来表示当前类的访问符。这里具体的取值如下表:

access_flags 从表中得到这里的取值都是数字 1 进行移位得到的结果,这样就可以通过或运算得到我们类有哪些访问标记。

访问标记对应在字节码中的位置为 0x216 - 0x217,内容为 0x0021,可知这结果是由访问标记中的 ACC_PUBLIC | ACC_SUPER 所得。ACC_SUPER 在 JDK 1.2 添加,默认类都会带上这个访问标记。

当前类 - this_class

两个字节的当前类标识,其地址为 0x218 - 0x219,内容为 0x000b。其内容表示的是在常量池中第 11 个,指向的内容为 class。通过查看之前的常量池表,可知其 class 的内容为 com/lighters/demo/Test。

父类 - super_class

其格式同上,可知其对应字节码的内容为 0x0009,在常量池表中针对的 class 内容为 java/lang/Object 。

接口数量 - interfaces_count

两个字节的表示,其地址为 0x21c - 0x21d,内容为 0x0000。表示当前类没有实现任何接口。

接口 - interfaces[interfaces_count]

这里描述的是 interfaces_count 的两个自己的接口描述。因为 interfaces_count 为零,所以这里不会有任何地址的指向。

字段数量 - fields_count

两字节的字段数量,字节码中对应地址为 0x21e - 0x21f,内容为 0x001。表示有个 1 字段。

字段 - fields[fields_count]

同样是有 fields_count 的 field_info,这里 fields_count 为 1, 我们只用分析一个即可,而 field_info 的格式如下:

1
2
3
4
5
6
7
field_info {
    u2             access_flags;
    u2             name_index;
    u2             descriptor_index;
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

第一项为两个字节的 access_flags ,字节码中对应的地址为 0x220 - 0x221,内容为 0x0002,而 acess_flags 对应的表结构定义如下;

Field access and property flags

所以,可知我们的字段为 private。接下来是两字节的 name_index,内容为 0x000c,对应常量表中的索引为 12,内容为 name。两字节的 descriptor_index,内容为 0x000d,对应常量表中的索引为13,内容为 Ljava/lang/String。

接下来则是 attributes_count,这里对应结果为 0,就不看了。

最终,我们可知道 Test 类中,有一个 private ,名称为 name,类型为 String的字段。而 attributes_count 为空,表示这里没有直接对其进行赋值。

方法数量 - methods_count

两个字节描述方法数量,字节码中地址为 0x228 - 0x229,其内容为 0x0002,表示两个方法。

方法 - methods[methods_count]

这里的方法则对应着 method_info 的的结构,如下:

1
2
3
4
5
6
7
method_info {
    u2             access_flags;
    u2             name_index;
    u2             descriptor_index;
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

这里的 method_info 跟 field_info 的结构相同。先看 access_flags,在字节码中其位置为 0x22a - 0x22b,相应内容为 0x0001。根据如下 method 的 access_flag 表,可知其相对应的为 public。

Method access and property flags

接下来的 4 个字节是 0x000e 和 0x000f,分别指向常量池中 14 和 15,对应着 方法名称 和方法描述 (Ljava/lang/String;)V。

接下来的两个字节为 0x0001, 表示 attributes_count 为1。这就要分析一下 attribute_info 是什么内容?先看它的结构:

1
2
3
4
5
attribute_info {
    u2 attribute_name_index;
    u4 attribute_length;
    u1 info[attribute_length];
}

两字节的 attribute_name_index 对应字节码表的位置为 0x232 - 0x233,表示的内容为 0x0010,其对应的是常量池的 utf8 的信息,索引内容为 16,表示相应的内容为 Code。其结构如下 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Code_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 max_stack;
    u2 max_locals;
    u4 code_length;
    u1 code[code_length];
    u2 exception_table_length;
    {   u2 start_pc;
        u2 end_pc;
        u2 handler_pc;
        u2 catch_type;
    } exception_table[exception_table_length];
    u2 attributes_count;
    attribute_info attributes[attributes_count];
}

Code 主要用来描述方法的内部实现,其中会用指令来描述方法的运行状态,另外以及异常的信息等。但是 abstarct 与 native 的 method_info 并不会有 code_info。

这里的 method_info 信息我们通过之前的 javap 对 Test.class 文件输出的信息来进行查看,可以更加清晰明了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
{
  public com.lighters.demo.Test(java.lang.String);
    descriptor: (Ljava/lang/String;)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: aload_1
         6: putfield      #2                  // Field name:Ljava/lang/String;
         9: return
      LineNumberTable:
        line 7: 0
        line 8: 4
        line 9: 9

  public void sayHello();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: new           #4                  // class java/lang/StringBuilder
         6: dup
         7: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
        10: ldc           #6                  // String Hello
        12: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        15: aload_0
        16: getfield      #2                  // Field name:Ljava/lang/String;
        19: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        22: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        25: iconst_0
        26: anewarray     #9                  // class java/lang/Object
        29: invokevirtual #10                 // Method java/io/PrintStream.printf:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream;
        32: pop
        33: return
      LineNumberTable:
        line 12: 0
        line 13: 33
}

输出信息中的 Code 可以看出方法的操作栈最大深度为 2,内部变量为 2,之后便是以 1 个字节为单位的指令描述,这里就不对指定讲解了,可参照气候的注释进行理解。

在最后的 attributes 中,存放的是 LineNumberTable。它是做什么用的?当我们需要进行断点调试的时候,它便可以用来对应我们在源文件的方法代码位置,这样更方便我们定位代码错误位置。其格式如下:

1
2
3
4
5
6
7
8
LineNumberTable_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 line_number_table_length;
    {   u2 start_pc;
        u2 line_number;  
    } line_number_table[line_number_table_length];
}

其中的 attribute_name_index,便是对应常量池索引为 17 的 LineNumberTable。主要研究 line_number_table 的数据结构:

1
2
3
4
{
   u2 start_pc;
   u2 line_number;   
}

这里的 start_pc 表示针对在 Code 块的起始位置,而 line_number 则表示相对应的在源码中的行数。所以上面第一个方法,(类构造器 init )输出 LineNumberTable :

1
2
3
4
LineNumberTable:
  line 7: 0
  line 8: 4
  line 9: 9

就相当好理解了,方法所在源码中的第 7 行对应在 Code info 块中的索引为 0;第 8 行对应 Code 中索引为 4;第 9 行对应 Code 中的索引为 9。

方法 sayHello 的格式同理,就不赘述了。有个小细节需要注意的是,在其 Code 中的索引 19 调用 invokevirtual 指令时,对应源文件中调用 + 操作符,可以在注释中,看到其相对应调用的是 StringBuilder对象的 append 方法。

说明了什么呢?当我们在调用 + 操作符时,编译器在进行编译的时候,会创建一个 StringBuilder对象,通过 append 方法进行相加操作。这样我们在多次使用 + 操作时,IDE 会给我们一个警告的提示,也就不足为怪了。

附属属性数量 - attributes_count

这里对应位置为 0x2ae- 0x2af,信息为 0x0001,表示为只存在 1 个 attribute。

附属属性 - attributes[attributes_count]

在 0x2b0 - 0x2b1 的信息为 0x0014,对应常量池的索引 20 的值,为 SourceFile。其结构如下:

1
2
3
4
5
SourceFile_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 sourcefile_index;
}

可知接下来的 4 字节表示长度,为 2 ; 接下来的 2 字节表示源文件索引,值为 0x0015,对应常量池中的索引为 21 的值,为 Test.java。

总结

在根据主线 ClassFile 的结构表一一分析之后,字节码 class 文件终于被我们完整的看完了。当然其中一些细节如其他的 attribute 结构、Code 中相应的指令操作等,并没有去深入讲解,但是这并不妨碍我们对字节码(只闻其名,不知其人)产生一个更加深入而又完整的认识。我们只需编写出符合 JVM 规范的字节码文件,即可运行与 JVM 之上,像其他的语言如 JRuby、Scala、Kotlin等就是,不过它们使用的是特定的编译器。另外,需要提及的两个命令 javac 及 javap ,需要熟练使用。当然其中的指令操作还是需要去深入研究一番,这篇也有许多不足之处,也欢迎小伙伴一起深入探讨。

参考资料

版权归作者所有,转载请注明原文链接:/blog/2016/09/16/read-java-bytecode/

给 Ta 个打赏吧...

Comments