深入理解Java虚拟机之----类文件结构

时间:2022-12-27 21:03:32

谈到类文件结构,就要从 Java 虚拟机说起。

一、Java虚拟机是一个与语言无关的平台

实现语言无关性的基础是虚拟机和字节码存储格式。 Java 虚拟机不与任何语言绑定,它只与 “Class 文件” 这种特定的二进制文件格式所关联。

任意一门语言都可以按照 Java 虚拟机规范把程序代码编译成 Class 文件,然后在虚拟机上运行。例如:使用 Java 编译器可以把 Java 代码编译成存储字节码的 Class 文件,使用 JRuby等其他语言的编译器一样可以把程序代码编译成 Class 文件,虚拟机并不关心 Class 的来源是何种语言,如下图所示:
深入理解Java虚拟机之----类文件结构

二、什么是 Class 类文件结构?

任意一个有效的类或接口所应当满足的格式称为 “Class 文件格式”,实际上它不一定以磁盘文件的形式存在。

Class 文件是一组以 8 位字节为基础单位的二进制流,各项数据项目严格按照顺序紧凑地排列在 Class 文件之中。当遇到需要占用 8 位字节以上空间的数据项时,则会按照高位在前(Big–Endian)的方式分割成若干个 8 位字节进行存储。

“Class 文件”说白了就是程序代码编码解码的中间结果。将程序代码编译成 Class 文件的过程就是编码,而虚拟机加载 Class 文件的过程就是解码的过程,编码和解码需要严格虚拟机规范。

下面开始详细的介绍 Class 文件格式(这个过程比较枯燥,但这是了解虚拟机的重要基础之一):

根据 Java 虚拟机规范的规定,Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据,这种伪结构只有两种数据类型:无符号数和表。所以这里先介绍无符号数和表这两个概念:

(1)无符号数

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

(2)表

  • 表是有多个无符号数或者其他表作为数据项构成的符合数据类型,所有表都习惯性地以 “_info” 结尾。表用于描述有层次关系的复合结构的数据。

Class 文件本质上就是一张表:
深入理解Java虚拟机之----类文件结构

由于表中存在一些不定项,所以采取了 “size + size 个数据项” 的形式来描述,如上图中的 fields_count 表示 field_info 的个数,其后跟了 fields_countfield_info

注:由于这个地方是用 u2 即 2 个字节的无符号数来表示个数,那么,假如一个类的 field 的个数超过了 2 个字节所能表示的最大值,则该类会编译失败,不过这种情况基本上不可能出现

接下来逐个理解上表中各个数据项的具体含义:

我们以最简单的一个 Java 代码为例:

package com.yhh.example;

public class TestClass {
    private int index;

    public Integer inc() {
        return index++;
    }
}

首先通过 javac TestClass.java 得到编译后的 TestClass.class 文件。

然后通过 hexdump -C ClassName.class 命令查看文件 TestClass.class 的内容(稍后会以这个为基础进行举例):

➜  example git:(master) ✗ hexdump -C TestClass.class
00000000  ca fe ba be 00 00 00 34  00 19 0a 00 05 00 10 09  |.......4........|
00000010  00 04 00 11 0a 00 12 00  13 07 00 14 07 00 15 01  |................|
00000020  00 05 69 6e 64 65 78 01  00 01 49 01 00 06 3c 69  |..index...I...<i|
00000030  6e 69 74 3e 01 00 03 28  29 56 01 00 04 43 6f 64  |nit>...()V...Cod|
00000040  65 01 00 0f 4c 69 6e 65  4e 75 6d 62 65 72 54 61  |e...LineNumberTa|
00000050  62 6c 65 01 00 03 69 6e  63 01 00 15 28 29 4c 6a  |ble...inc...()Lj|
00000060  61 76 61 2f 6c 61 6e 67  2f 49 6e 74 65 67 65 72  |ava/lang/Integer|
00000070  3b 01 00 0a 53 6f 75 72  63 65 46 69 6c 65 01 00  |;...SourceFile..|
00000080  0e 54 65 73 74 43 6c 61  73 73 2e 6a 61 76 61 0c  |.TestClass.java.|
00000090  00 08 00 09 0c 00 06 00  07 07 00 16 0c 00 17 00  |................|
000000a0  18 01 00 19 63 6f 6d 2f  79 68 68 2f 65 78 61 6d  |....com/yhh/exam|
000000b0  70 6c 65 2f 54 65 73 74  43 6c 61 73 73 01 00 10  |ple/TestClass...|
000000c0  6a 61 76 61 2f 6c 61 6e  67 2f 4f 62 6a 65 63 74  |java/lang/Object|
000000d0  01 00 11 6a 61 76 61 2f  6c 61 6e 67 2f 49 6e 74  |...java/lang/Int|
000000e0  65 67 65 72 01 00 07 76  61 6c 75 65 4f 66 01 00  |eger...valueOf..|
000000f0  16 28 49 29 4c 6a 61 76  61 2f 6c 61 6e 67 2f 49  |.(I)Ljava/lang/I|
00000100  6e 74 65 67 65 72 3b 00  21 00 04 00 05 00 00 00  |nteger;.!.......|
00000110  01 00 02 00 06 00 07 00  00 00 02 00 01 00 08 00  |................|
00000120  09 00 01 00 0a 00 00 00  1d 00 01 00 01 00 00 00  |................|
00000130  05 2a b7 00 01 b1 00 00  00 01 00 0b 00 00 00 06  |.*..............|
00000140  00 01 00 00 00 03 00 01  00 0c 00 0d 00 01 00 0a  |................|
00000150  00 00 00 27 00 04 00 01  00 00 00 0f 2a 59 b4 00  |...'........*Y..| 00000160 02 5a 04 60 b5 00 02 b8 00 03 b0 00 00 00 01 00 |.Z.`............| 00000170 0b 00 00 00 06 00 01 00 00 00 07 00 01 00 0e 00 |................| 00000180 00 00 02 00 0f |.....| 00000185
1. 魔数和 Class 文件的版本

每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。使用魔数而不是拓展名来进行识别主要是基于安全方面的考虑,因为文件拓展名可以随意更改。

魔数的值为 0xCAFEBABE

紧接着魔数的 4 个字节是 Class 文件的版本号:第 5 和第 6 个字节是次版本号(Minor Version),第 7 和第 8 个字节是主版本号(Major Version)。Java 的版本号是从 45 开始的,JDK 1.1 之后的每个 JDK 大版本发布主版本号向上加 1(JDK 1.0 ~ 1.1 使用了 45.0 ~ 45.3 的版本号),高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的 Class 文件。

TestClass.class 为例:

00000000  ca fe ba be 00 00 00 34

可以看到,头 4 个字节为 0xcafebabe ,然后是次版本号 0x0000 ,而主版本号为 0x0034,即十进制的52,即 Class 文件的十进制版本号为:52.0,说明该文件是使用 JDK 1.8 编译的 Class 文件。

2. 常量池

紧接着主次版本号之后的是常量池入口,常量池可以理解为 Class 文件之中的资源仓库,它是 Class 文件结构中与其他项目关联最多的数据类型,也是占用 Class 文件空间最大的数据项目之一。

由于常量池中常量的数量是不固定的,所以需要有一个 u2 类型的数据来表示常量池容量计数值( constant_pool_count )。这里需要注意的是,常量池容量计数值是从 1 而不是 0 开始的,这是因为第 0 项常量空出来是有特殊考虑的,这样做的目的在于满足在特定情况下表达 “不引用任何一个常量池项目” 的含义,这种情况可以把索引值置为 0 来表示。 Class 文件结构中只有常量池的容量计数是从 1 开始,对于其他集合类型,都是从 0 开始的。

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于 Java 语言层面的常量概念,如文本字符串、声明为 final 的常量值等。而符号引用则属于编译原理方面的概念,包括了下面三类常量:

1)类和接口的全限定名
2)字段的名称和描述符
3)方法的名称和描述符

常量池中每一项常量都是一个表,它们有一个共同点:就是表开始的第一位是一个 u2 类型的标志位,代表当前这个常量属于哪种类型。常量类型所代表的具体含义见下图:
深入理解Java虚拟机之----类文件结构

CONSTANT_Class_info 的结构比较简单,见下图:
深入理解Java虚拟机之----类文件结构

tag 是标志位,代表它是属于哪种常量类型;name_index 是一个索引值,它指向常量池中一个 CONSTANT_Utf8_info 类型常量。

CONSTANT_Utf8_info型常量的结构如下:
深入理解Java虚拟机之----类文件结构

tag 同上;length 代表这个 UTF-8 编码的字符串长度是多少字节,后面紧跟着的长度为 length 字节的连续数据是一个使用 UTF-8 缩略编码表示的字符串。

由于 Class 文件中方法、字段等都需要引用 CONSTANT_Utf8_info 型常量来描述名称,所以 CONSTANT_Utf8_info 型常量的最大长度也就是 Java 中方法、字段名的最大长度。如果 Java 程序中如果定义了超过这个最大长度的变量名或方法名,将会无法编译。

下图是常量池中所有常量项的结构总表:
深入理解Java虚拟机之----类文件结构
深入理解Java虚拟机之----类文件结构

TestClass.class 为例:

                                   00 19 0a 00 05 00 10 09  |.......4........|
00000010  00 04 00 11 0a 00 12 00  13 07 00 14 07 00 15 01  |................|
00000020  00 05 69 6e 64 65 78 01  00 01 49 01 00 06 3c 69  |..index...I...<i|
00000030  6e 69 74 3e 01 00 03 28  29 56 01 00 04 43 6f 64  |nit>...()V...Cod|
00000040  65 01 00 0f 4c 69 6e 65  4e 75 6d 62 65 72 54 61  |e...LineNumberTa|
00000050  62 6c 65 01 00 03 69 6e  63 01 00 15 28 29 4c 6a  |ble...inc...()Lj|
00000060  61 76 61 2f 6c 61 6e 67  2f 49 6e 74 65 67 65 72  |ava/lang/Integer|
00000070  3b 01 00 0a 53 6f 75 72  63 65 46 69 6c 65 01 00  |;...SourceFile..|
00000080  0e 54 65 73 74 43 6c 61  73 73 2e 6a 61 76 61 0c  |.TestClass.java.|
00000090  00 08 00 09 0c 00 06 00  07 07 00 16 0c 00 17 00  |................|
000000a0  18 01 00 19 63 6f 6d 2f  79 68 68 2f 65 78 61 6d  |....com/yhh/exam|
000000b0  70 6c 65 2f 54 65 73 74  43 6c 61 73 73 01 00 10  |ple/TestClass...|
000000c0  6a 61 76 61 2f 6c 61 6e  67 2f 4f 62 6a 65 63 74  |java/lang/Object|
000000d0  01 00 11 6a 61 76 61 2f  6c 61 6e 67 2f 49 6e 74  |...java/lang/Int|
000000e0  65 67 65 72 01 00 07 76  61 6c 75 65 4f 66 01 00  |eger...valueOf..|
000000f0  16 28 49 29 4c 6a 61 76  61 2f 6c 61 6e 67 2f 49  |.(I)Ljava/lang/I|
00000100  6e 74 65 67 65 72 3b

第一项 u2 类型 0x0019 为常量池的容量,即十进制的 25 ,代表一共有 24(第 0 项空出来有特殊用途)项常量。然后是一位 u1 类型为 tag, 0x0a,代表是类中方法的符号引用( CONSTANT_Class_info ),然后是 u2 类型,index0x0005 ,表示声明方法的类描述符的为常量池第 5 项常量。接下来还是 u2 类型, index0x0010 ,表示名称即类型描述符为常量池第 16 项常量。然后第一项常量就结束了,继续第二个常量。tag, 0x09 ,代表类中字段的符号引用( CONSTANT_Fieldref_info ),然后 u2 类型, index0x0004 ,表示声明字段的类或接口描述符为常量池第 4 项常量。 0x0011 , 表示字段的描述符为常量池中第 17 项常量,第二项常量结束。然后继续按照此方法,可以得到剩下的 22 项常量。

3. 访问标志

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

深入理解Java虚拟机之----类文件结构

TestClass.class 为例:

00000108  00  21

通过分析代码,只有 0x00010x0020 两个标志位为真,即它的 access_flags 的值应为 0x0021 ,通过前面的计算,可以知道 access_flags 标志(偏移地址: 0x00000108 )的确为 0x0021

4. 类索引、父类索引和接口索引集合

类索引(this_class)和父类索引(super_class)都是一个 u2 类型的数据,而接口索引集合(interfaces)是一组 u2 类型的数据的集合,Class 文件通过这三项数据来确定这个类的继承关系。

类索引、父类索引和接口索引集合都按照顺序排列在访问标志之后,类索引和父类索引引用两个 u2 类型的索引值表示,它们各自指向一个类型为 CONSTANT_Class_info 的类描述符常量,从而得到全限定名字符串。

而接口索引集合,第一项为接口计数器,表示索引表的容量。如果该类没有实现任何接口,则该计数器值为 0。

TestClass.class 为例:

0000010a  00 04 00 05 00 00

第一项 u2 类型 0x0004 ,表示该类的全限定名为常量池中第 4 项常量( com/yhh/example/TestClass ),第二项 u2 类型 0x0005 ,表示该类的父类全限定名为常量池中第 5 项常量( java/lang/Object ),紧接着一项 u2 类型 0x0000 表示接口索引集合大小为 0 。

5. 字段表集合

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

字段表结构如下:
深入理解Java虚拟机之----类文件结构

字段修饰符放在 access_flags 项目中,可以设置的标志位及含义如下:、
深入理解Java虚拟机之----类文件结构

跟随 access_flags 标志的是两个索引值:name_index 和 descriptor_index。它们都是对常量池的引用,分别代表着字段的简单名称以及字段和方法的描述符。

关于字段和方法的描述符的标识字符及含义如下:
深入理解Java虚拟机之----类文件结构

对于数组类型,每一维度将使用一个前置的 “[” 字符来描述,如一个定义为 “java.lang.String[][]” 类型的二维数组表示为:”[[Ljava/lang/String”,一个整型数组 “int[]” 将被记录为 “[I”。

描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号 “()” 之内。如方法 void inc() 的描述符为 “()V”,方法 java.lang.String toString() 的描述符为 “()Ljava/lang/String;”。

TestClass.class 为例:

0000010a                                                00  |nteger;.!.......|
00000110  01 00 02 00 06 00 07 00  00 

第一项 u2 类型值为 0x0001 ,代表集合中只有一个方法,第二项 u2 类型为 access_flags ,值为 0x0002 ,表示方法是 private 的,第三项 u2 类型为 name_index ,值为 0x0006 ,即字段的简单名称指向常量池中第六项常量,第三项 u2 类型为 descriptor_index ,值为 0x0007 ,即字段和方法的描述符指向常量池第七项常量。接下来一个 u2 类型值为 0x0000 ,说明没有需要额外描述的内容。

6. 方法表集合

方法表(method_info)结构和字段表结构是一模一样的,除了访问标志和属性表集合的可选项有所区别。
深入理解Java虚拟机之----类文件结构

因为 volatile 关键字和 transient 关键字不能修饰方法,所以方法表的访问标志中没有了 ACC_VLOLATILE 标志和 ACC_TRANSIENT 标志。与之对应的,新增了 synchronizednativestrictfpabstract 关键字的访问标志。具体标志位及其取值见下表:
深入理解Java虚拟机之----类文件结构

TestClass.class 为例:

0000011a                              00 02 00 01 00 08 00  |................|
00000120  09 00 01 00 0a 00 00 00  1d 00 01 00 01 00 00 00  |................|
00000130  05 2a b7 00 01 b1 00 00  00 01 00 0b 00 00 00 06  |.*..............|
00000140  00 01 00 00 00 03 00 01  00 0c 00 0d 00 01 00 0a  |................|
00000150  00 00 00 27 00 04 00 01  00 00 00 0f 2a 59 b4 00  |...'........*Y..| 00000160 02 5a 04 60 b5 00 02 b8 00 03 b0 00 00 00 01 00 |.Z.`............| 00000170 0b 00 00 00 06 00 01 00 00 00 07

方法表和字段表没有太大区别, 0x0002 代表有两个方法,第一个方法访问标志是 0x0001 ,即是 public 的, name_index0x0008 ,指向常量池中第 8 项常量, descriptor_index0x0009 ,指向常量池中第 9 项常量; 下一个 u2 类型为 attributes_count ,值为 0x0001 ,说明有一个属性值,紧接着是 attribute_name_index0x000a ,即指向常量池中第 10 项常量,通过查找得到,第 10 项常量为 Code ,下一个 u4 类型为 attribute_length ,值为 0x0000001d ,即 29 ,下一个 u2 类型为 max_stack ,值为 0x0001 ,表示操作数栈深度的最大值,下一个 u2 类型为 max_locals ,值为 0x0001 ,代表了局部变量表所需的存储空间;接下来是 code_lengthcode ,用来存储 Java 源程序编译后生成的字节码指令,第一个 u4 类型为 code_length ,值为 0x00000005 ,表示字节码区域共占 5 个字节,然后读入紧随的 5 个字节,并根据字节码指令表翻译出对应的字节码指令。

翻译 2a b7 00 01 b1 的过程为:
1)读入 2a ,查表得 0x2a 对应的指令为 aload_0 ,这个指令的含义是将第 0 个 Slot 中为 reference 类型的本地变量推送到操作数栈顶。
2)读入 b7 ,查表得 0xb7 对应的指令为 invokespecial ,其后有一个 u2 类型的参数说明调用哪一个方法。
3)读入 0001 ,是 invokespecial 指令的参数,指向常量池中第 1 个常量。
4)读入 b1 ,查表得 0xb1 对应的指令为 return ,含义是返回此方法,并且返回值为 void ,方法结束。

后面就都是根据类似的方法推出得到。

7. 属性表集合

在 Class 文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。

TestClass.class 为例:

0000017b                                    00 01 00 0e 00  |................|
00000180  00 00 02 00 0f                                    |.....|
00000185

包含一个 SourceFile 的属性,sourcefile_index 值为 0x000f ,指向常量池第 15 个常量。

至此,TestClass.class 文件字节码分析结束。

最后使用 javap 工具来得到一个完整的 TestClass.class 文件字节码内容如下:

➜  example git:(master) ✗ javap -verbose TestClass.class
Classfile /Users/yanghaihui/project/github/code/java/src/main/java/com/yhh/example/TestClass.class
  Last modified Jun 9, 2018; size 389 bytes
  MD5 checksum 510375b08360425cddc8b51216c5155d
  Compiled from "TestClass.java"
public class com.yhh.example.TestClass
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #5.#16         // java/lang/Object."<init>":()V
   #2 = Fieldref           #4.#17         // com/yhh/example/TestClass.index:I
   #3 = Methodref          #18.#19        // java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
   #4 = Class              #20            // com/yhh/example/TestClass
   #5 = Class              #21            // java/lang/Object
   #6 = Utf8               index
   #7 = Utf8               I
   #8 = Utf8               <init>
   #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               inc
  #13 = Utf8               ()Ljava/lang/Integer;
  #14 = Utf8               SourceFile
  #15 = Utf8               TestClass.java
  #16 = NameAndType        #8:#9          // "<init>":()V
  #17 = NameAndType        #6:#7          // index:I
  #18 = Class              #22            // java/lang/Integer
  #19 = NameAndType        #23:#24        // valueOf:(I)Ljava/lang/Integer;
  #20 = Utf8               com/yhh/example/TestClass
  #21 = Utf8               java/lang/Object
  #22 = Utf8               java/lang/Integer
  #23 = Utf8               valueOf
  #24 = Utf8               (I)Ljava/lang/Integer;
{
  public com.yhh.example.TestClass();
    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 java.lang.Integer inc();
    descriptor: ()Ljava/lang/Integer;
    flags: ACC_PUBLIC
    Code:
      stack=4, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field index:I
         5: dup_x1
         6: iconst_1
         7: iadd
         8: putfield      #2                  // Field index:I
        11: invokestatic  #3                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        14: areturn
      LineNumberTable:
        line 7: 0
}
SourceFile: "TestClass.java"

参考文献:《深入理解java虚拟机》周志明 著.