Featured image of post 深入 JVM(二)

深入 JVM(二)

java 字节码文件结构剖析,通过分析 java 编译后的 class 文件,了解其基本结构

本期想来聊聊关于java的编译后的class文件中到底藏了哪些内容,使得可以其支撑起了java“一次编译,到处运行”的。我们将从java自带的反解析命令javap入手,通过二进制文件查看器来查看class文件,再通过查表分析的方式,一步步分析出编译后的class文件中蕴藏了什么信息。

反编译工具

我们先来看看javap的官方定义:“javapJDK自带的反解析工具。它的作用就是根据Class字节码文件,反解析出当前类对应的code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等等信息。”,简单来说,javap可以通过反编译的方式,将Class文件从机器能够看懂的一系列指令反编译和整理成我们人类可以看懂的描述代码信息的文件,我们可以通过反编译下面的代码,查看其反编译结果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package cn.yinan.bytecode;

/**
 * @author yinan
 */
public class MyTest1 {
    private int a = 1;

    public MyTest1() {
    }

    public int getA() {
        return a;
    }

    public void setA(int a) {
        this.a = a;
    }
}


通过运行javap -c cn/yinan/bytecode/MyTest1.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
Compiled from "MyTest1.java"
public class cn.yinan.bytecode.MyTest1 {
  public cn.yinan.bytecode.MyTest1();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: iconst_1
       6: putfield      #2                  // Field a:I
       9: return

  public int getA();
    Code:
       0: aload_0
       1: getfield      #2                  // Field a:I
       4: ireturn

  public void setA(int);
    Code:
       0: aload_0
       1: iload_1
       2: putfield      #2                  // Field a:I
       5: return
}

上面的信息仅仅简单描述了Class文件中的类以及方法信息,但是没有明确出常量池中的信息,我们可以使用javap -verbose cn/yinan/bytecode/MyTest1.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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
Classfile /Users/yinan/project/usual-study/asm/target/classes/cn/yinan/bytecode/MyTest1.class
  Last modified 2019-10-26; size 485 bytes
  MD5 checksum 178333f8f7ea67193d03b13d05f42bfd
  Compiled from "MyTest1.java"
public class cn.yinan.bytecode.MyTest1
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#21         // cn/yinan/bytecode/MyTest1.a:I
   #3 = Class              #22            // cn/yinan/bytecode/MyTest1
   #4 = Class              #23            // java/lang/Object
   #5 = Utf8               a
   #6 = Utf8               I
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcn/yinan/bytecode/MyTest1;
  #14 = Utf8               getA
  #15 = Utf8               ()I
  #16 = Utf8               setA
  #17 = Utf8               (I)V
  #18 = Utf8               SourceFile
  #19 = Utf8               MyTest1.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = NameAndType        #5:#6          // a:I
  #22 = Utf8               cn/yinan/bytecode/MyTest1
  #23 = Utf8               java/lang/Object
{
  public cn.yinan.bytecode.MyTest1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_1
         6: putfield      #2                  // Field a:I
         9: return
      LineNumberTable:
        line 10: 0
        line 8: 4
        line 11: 9
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  this   Lcn/yinan/bytecode/MyTest1;

  public int getA();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: getfield      #2                  // Field a:I
         4: ireturn
      LineNumberTable:
        line 14: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcn/yinan/bytecode/MyTest1;

  public void setA(int);
    descriptor: (I)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: iload_1
         2: putfield      #2                  // Field a:I
         5: return
      LineNumberTable:
        line 18: 0
        line 19: 5
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  this   Lcn/yinan/bytecode/MyTest1;
            0       6     1     a   I
}
SourceFile: "MyTest1.java"

以上的信息便是Class文件中详细反映出的信息,而在下面的分析过程中,我们将会使用到上面反编译的信息。

类文件信息

在分析Class文件内容之前,我们先来看看jvm是如何分析和理解这些字节信息的。简单来说,反编译程序就是通过查表的方式来反解析的到这些指令信息,通过按照一定格式的指令规范,反编译程序能够依据这些规范来逐步逐步解析文件,之后通过得到的这些汇编指令,来让机器执行相关指令操作。下面我们就来说说这些“规范”。

Class文件格式

根据Java虚拟机规范的规定,Class文件格式采用一种类似于C语言结构体的伪结构体来存储数据,这种伪结构体中只有两种数据类型:无符号数和表,后面的解析都要以这两种数据类型为基础。

无符号数

无符号数属于基本数据类型,以u1u2u4u8来分表代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成的字符串值

表有多个无符号数或者其它表作为数据项构成的复合数据类型,所有表都习惯地以_info结尾。表用于描述有层次的复合结构的数据,整个Class文件本质上就是一张表。

整个Class文件表的文件格式如下所示:

从图中我们可以知道整个Class文件的格式和信息,比如每一个文件的头4个字节是一个名字叫magic(魔数)的字段,而紧接着这4个字节后面的2个字节表示的是minor_version(次版本),之后2字节表示的是major_version(主版本)等等;而除此之外,我们依据我们上面的的定义,文件中存在co_infofield_infomethod_info以及attribute_info四张表信息。接下来我们详细说说Class文件信息。

魔数与版本

魔数

每个Class文件头4个字节都是固定的:0xCAFEBABE(咖啡宝贝)的魔数,它主要是用来确定这个文件是否是一个能够被虚拟机接受的文件,这种确定方式除了在java领域被使用之外,像一般的图片信息等都是有类似这样的魔数,例如你可以修改图片的后缀名为png1等自定义格式,然后使用浏览器进行打开,浏览器仍然可以将其识别成一张图片,这也是魔数在其中起作用。所以,如果更改Class文件的后缀名,那么是否能够被jvm识别呢?大家可以试试。

版本

紧跟着魔数后面的第5和第6个字节是次版本号(minor_version),第7和第8个字节是主版本号(major_version)。例如上面反编译出来的文件中的major version: 52,表示的就是主版本为JDK1.8

常量池

紧跟着主版本号之后的是常量池入口,常量池可以理解为Class文件中的资源仓库。常量池主要由常量池数量常量池数组这两部分共同构成,常量池数量紧跟在主版本号后面,占据2个字节;常量池数组紧跟在常量池数量之后。常量池数组(常量池表)与一般的数组不通的是,常量池数组中不同的元素类型、结构都是不同的,长度当然也就不同;但是,每一种元素的第一个数据都是u1类型,该字节是个标志位,占据1个字节,jvm在解析常量池时,会根据这个u1类型来获取元素具体类型。值得注意的是,常量池数组中的元素个数 = 常量池数 - 1 (其中0暂时不使用),目的是满足某些常量池索引值的数据在特定情况下需要表达【不引用任何常量池】的含义,根本原因在于,索引为0也是一个常量(保留常量),只不过它不位于常量表中,这个常量就对应null值,所以,常量池的索引从1而非0开始。由于常量池中的常量有大概14种类型,且这14种类型各自均有自己的结构,具体可以查看下面的常量结构表格:

常量池

访问标志

在常量池结束之后,紧跟着的2个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等。具体标志位以及标志的含义见下表:

类索引、父类索引与接口索引集合

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合是一组u2类型的数据集合,Class文件中由这三项数据来确定这个类的继承关系,由于一个类只有一个父类但是却可以有多个接口,所以父类索引只有一个u2类型的数据,而接口索引却是一个集合。

字段表

字段表(field_info)用于描述接口或者类中声明的变量。字段(field)包括类级变量以及实例变量,但是不包括在方法内部声明的局部变量。字段表的格式如下:

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

字段表中主要由访问标志(access_flags)、字段名称索引(name_index)、描述符索引(description_index)以及属性信息(attribute_info)构成。

其中字段访问标志和类的访问标志由一点相似,具体访问标志想必大家应该都可以猜测出来,如下表所示:

字段名称索引用来记录的是字段名称位置,需要通过索引去常量池中查找对应的值。

jvm规范中,每个变量/字段都有描述信息,描述信息主要的作用是描述字段的数据类型、方法的参数列表(包括数量、类型与顺序)与返回值。根据描述符规则,基本数据类型和代表无返回值的void类型都用一个大写的字符表示,对象类型则使用字符L加对象的全限定名称来表示。为了压缩字节码文件的体积,对于基本数据类型,jvm都只使用一个大写字母来表示,如下所示:B - byte, C - char, D - double, F - float, I - int, J - long, S - short, Z - boolean, V - void, L - 对象类型, 如 Ljava/lang/String

对于数组类型来说,每一个维度使用一个前置的[ 来表示, 如int[] 被记录为[I, String[][] 被记录为[[Ljava/lang/String。用描述符来描述方法的时候,按照先参数列表、后返回值的顺序来描述;参数列表按照参数的严格顺序放在一组()之内,如方法:String getRealNameByIdAndNickname(int id, String name)的描述符为:(I, Ljava/lang/String;)Ljava/lang/String;

方法表

如果理解了上一节关于字段表的内容,那关于方法表的内容将会变得很简单。Class文件存储格式中对应方法的描述与对字段的描述几乎采用类完全一致的方式,方法表结构如同字段表一样,依次包括了访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)和属性表集合(attributes)几项。具体方法表结构如下:

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

方法表中的访问标志随着方法和字段的差异性,也随之增加和减少了一些访问标志,具体访问标志信息如下表:

行文至此,也许大家会产生疑问,方法的定义可以通过访问标志、名称索引、描述符索引来表示清楚,但是方法最需要用到的方法体的代码到哪里去了?方法里面的Java代码,经过编译器编译成字节码之后,存放在了方法的属性表集合中,在方法表的集合中,有一个名为Code的属性,在这个属性中保存了方法的方法体信息。我们将在后续进行讲解。

属性表

属性表(attribute_info)在前面的讲解中已经出现多次,在Class文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。

Class文件中其它的数据项目要求严格的顺序、长度和内容不容,属性表集合的限制稍微宽松了一些,不再要求各个属性表具有严格的顺序,并且只要不与已有属性名重复,任何人实现的编译器都可以像属性表中写入自己定义的属性信息,Java虚拟机在运行时会忽略掉它不认识的属性。

在《Java虚拟机规范(第2版)》中预定义了9项虚拟机实现应当能识别的属性,而之后随着Java版本升级,预定义的属性又随着增加了很多,这里我们介绍一下刚刚提到的Code属性,以及LineNumberTableLocalVariableTable属性,方便之后我们针对Class文件分析时使用。

Code

Java程序方法体在代码经过编译后,最终变为字节码指定存储在Code属性内。Code属性出现在方法表的属性集合之中,单并非所有的方法表都必须存在这个属性,比如接口或者抽象类中的方法就不一定存在Code属性。

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];
}

attribute_name_index:指向常量池的索引,常量值固定为Code

max_stack:代表了操作数栈(Operand Stacks)深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧中的操作栈深度。

max_locals:代表了局部变量所需存储的存储空间。

code_lengthcode:用来存储Java源程序编译后生成的字节码指令。code_length代表字节码长度,code是用于存储字节码指令的一系列字节流。

exception_table_lengthexception_table:方法体中异常信息的纪录

LineNumberTable

LineNumberTable属性用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。

LineNumberTable属性表的结构如下所示:

1
2
3
4
5
6
7
8
9
LineNumberTable_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 line_number_table_length;
    {
        u2 start_pc;//字节码行号
        u2 end_pc;//java源码行号
    } line_number_info line_number_table;
}

LocalVariableTable

LocalVariableTable属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系,默认会生成到Class文件中,可以在Javac中分别使用-g:none-g:vars选项来取消或要求生成这项信息。

LocalVariableTable属性表的结构如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
LocalVariableTable_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 local_variable_table;
    {
        u2 start_pc;//局部变量的生命周期开始的字节码偏移量
        u2 length;//字节码作用范围覆盖长度
        u2 name_index;//局部变量名称
        u2 descript_index;//局部变量描述符
        u2 index;
    }
}

Class文件分析

说了这么久的理论知识,下面我们开始实战,以一个简单的java代码入手,然后来尝试读一读字节码文件,看看能不能翻译出这个文件中的内容。

首先我们以上面提到的的java代码为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package cn.yinan.bytecode;

/**
 * @author yinan
 */
public class MyTest1 {
    private int a = 1;

    public int getA() {
        return a;
    }

    public void setA(int a) {
        this.a = a;
    }
}

我们使用Hex Fiend工具打开上面代码的字节码文件,看到了一系列的十六进制数据: 字节码文件

依靠上文的分析步骤,我们来看看这些十六进制数据表示什么含义

首先,java字节码文件的头4个字节是魔数,且值为固定的:0xCAFEBABE 魔数

后面的依次两两共四个字节分别表示版本号,这里是00 00 00 34,其中前两个字节表示的是次版本号(minor_version),这里是0,后两个自己表示主版本号(major_version),这里是3*16+4=52,因为52表示的是jdk8,所以这里的版本是1.8.0版本

跟在版本号之后的,便是常量池的一些信息,从上面的分析,我们可以知道,常量池中前2个字段表示的是常量池数量,我们这里看到的值是:00 18,所以常量池元素个数应该是:16+8-1=23,具体原因上面已经提到过,这里不再赘述。 常量数量 下面我们来看看到底有哪23个常量。

首先,每个常量的第一个字节表示的都是一个tag值,通过这个值可以找到当前常量的类型,从而确认该常量结构,我们可以看到这个tag值是:0A,即10,所以它的类型是CONSTANT_Methodref_info类型,通过查表得出该常量结构类型如下图: CONSTANT_Methodref_info

CONSTANT_Methodref_info型常量的第二个数据项为index,类型是u2index存储的是一个索引值,从class文件中查得该值为0x0004=4,即它指向常量池中第4个常量;第三个数据项也是索引其值为0X0014=20,指向常量池种第20个常量。 tag_index_index

到此为止,第一个常量项是CONSTANT_Methodref_info型常量项,该类型常量项用来表示类中方法的符号引用,其内容为tag=10,index1=4,index2=20,因为其表示的是类中方法的符号引用,所以index中存放的不是一个具体得内容,而是一个索引位置,所以说其具体内容存放在另一个常量项中。下面我们就来看看其索引指向的常量项(即第4个常量项)的内容到底是什么?

找第4个常量项之前需要知道第4个常量项的开始位置,所以需要知道前3个常量项所占字节数。那好就看第2个常量项,由于第一个常量项共占了5个字节,则紧接着的字节就为第2个常量项的tag,如下图可得其值为0x09=9,说明第2个常量项得项目类型为CONSTANT_Fieldref_info。查表得其该类型得字节长度固定占5个字节。 第二个常量

依次类推查的第3,4个常量项为CONSTANT_Class_info型。如下图: 第三四

下面就看第四个常量项CONSTANT_Class_info的内容0X070017CONSTANT_Class_info存放的是指向类或接口的符号引用。

根据CONSTANT_Class_info项常量项的结构可知其index数据项又是一个索引项,指向全限定名常量项索引,index数据项的值为0x17=23,表示指向第23个常量项,正好在最后一个,但是要知道23个常量项必须知道前22个常量项所占字节,这里就不一一找了,最后找到第23个常量项CONSTANT_Utf8_infoclass文件中包含的内容如下: 23个 常量类型

根据tag等于1得第23项是CONSTANT_Utf8_info型,该类型存储UTF-8编码的字符串,在MyTest1.class文件种该常量项种个数据项的内容如下:

  • length(u2):表示UTF-8编码的字符串占用的字节数,值为0x0010=16
  • bytes(u1):表示长度为lengthUTF-8编码的字符串。
  • 因为length=16,所以length后面紧跟的长度为16个字节的连续数据是一个使用UTF-8缩略编码表示的字符串。后面紧跟的第一个字节为0x6A=106,那该编码代表的字符为j,我们发现106其实就是字符j对应的ASCII码。后面16个字节代表的字符就是: java/lang/Object

根据上面的找法我们就可以找出常量池中包含的内容:字面量和符号引用。所以我们可以发现,其实整个查找过程就是查表,我们依据现有的表,就可以很容易地找到我们需要的数据,而最终查找到的结果就是我们在整篇文章开头所使用的命令:javap -verbose cn/yinan/bytecode/MyTest1.class 所获取到的结果;后续的字节码翻译就不再继续下去,只要找到相关的表,就可以找到对应的结果值。

总结

整篇文章我们从开始讲述了java字节码文件的结构,以及这些结构中的几张非常重要的表结构,之后通过一个实例来和大家一起动手去翻译了一部分字节码信息,相信通过这篇文章的学习能够更加理解java字节码相关的信息,为自己打下牢固的基础。

参考

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus