很多时候,我们都是从代码层面去学习如何编程,却很少去看看一个个 Java 代码背后到底是什么。今天就让我们从一个最简单的 Hello World 开始看一看 Java 的类文件结构。
在开始之前,我们先写一个最简单的入门 Hello World。
接着在命令行运行javac Demo.java
命令编译这个类,这时会生成一个 Demo.class 文件。
接着我们用纯文本编辑器打开生成的 Demo.class 文件。
可以看到我们简单的 5 行代码到最后就浓缩成了上面那一长串数字字母组成的十六进制符号。而当我们运行该 Java 类时,控制台能准确地输出「Hello World」,所以们可以断定这一长串的符号必定遵守着某种规则,而这个规则其实就是:Java虚拟机规范。
Java虚拟机规范
Java 虚拟机规范中规定了 Java 虚拟机结构、Class 类文件结构、字节码指令等内容,其中对于软件开发人员来说,类文件结构是有必要了解的一个内容。
Java 虚拟机的类文件结构是一组以 8 位字节为基础的二进制流,各数据项目严格按照顺序紧凑地排列在 Class 文件之中,中间没有添加任何分隔符,这使得整个 Class 文件中存储的内容几乎全都是程序需要的数据,没有空隙存在。
如果你对 Java 虚拟机规范有兴趣,可以参考 Github 上的《Java 虚拟机规范》。
Java 虚拟机
说完了 Java 虚拟机规范,就需要了解一下 Java 虚拟机这个概念。
其实 Java 虚拟机就是一个虚拟的计算机。与真实的计算机一样,Java 虚拟机有自己完善的硬件体系,如处理器、堆栈、寄存器,还有相应的指令集系统。虚拟机与我们的电脑唯一的区别是:虚拟机的处理器、内存堆栈是用软件虚拟出来的,而我们电脑的处理器和内存则是真真实实的。
虽然名字是叫 Java 虚拟机,但 Java 虚拟机与 Java 语言没有直接关系,它只按照 Java 虚拟机规范去读取 Class 文件,并按照规定去解析、执行字节码指令,仅此而已。
如果你够牛逼,你完全可以写一个编译器,将 C 语言代码编译成符合 Java 虚拟机规范的字节码文件,那么 Java 虚拟机也是可以执行的。
准确地说,Java 虚拟机与字节码文件(Class文件)绑定。
Java类文件结构
Java 虚拟机规范中定义了许多规范,其中有一部分定义了字节码的结构和规范。Java 虚拟机规范定义了两种数据类型来表示 Class 文件格式,分别是:无符号数和表。
无符号数属于最基本的数据类型,以 u1、u2、u4、u8 六七分别代表 1 个字节、2 个字节、4 个字节、8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成的字符串值。例如下表中第一行中的 u4 表示 Class 文件前 4 个字节表示该文件的魔数,第二行的 u2 表示该 Class 文件第 5-6 个字节表示该 JDK 的次版本号。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾,表用于描述有层次关系的复合结构的数据。例如下表第 5 行表示其实一个类型为 cp_info 的表(常量池),这里面存储了该类的所有常量。
整个 Class 文件本质上就是一张表,它由表下表所示的数据项构成。
上面的表其实可以划分为以下七个部分,这七个部分组成了一个完整的 Class 字节码文件:
- 魔数与Class文件版本
- 常量池
- 访问标志
- 类索引、父类索引、接口索引
- 字段表集合
- 方法表集合
- 属性表集合
接下来我们用上面「Hello World」的字节码文件为例子,一步步分析这七部分内容。
魔数与Class文件版本
Class 文件的第 1 - 4 个字节代表了该文件的魔数(Magic Number)。它唯一的作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件,其值固定是:0xCAFEBABE(咖啡宝贝)。如果一个 Class 文件的魔数不是 0xCAFEBABE,那么虚拟机将拒绝运行这个文件。
Class 文件的第 5 - 6 个字节代表了 Class 文件的次版本号(Minor Version),即编译该 Class 文件的 JDK 次版本号。
Class 文件的第 7 - 8 个字节代表了 Class 文件的主版本号(Major Version),即编译该 Class 文件的 JDK 主版本号。
高版本的 JDK 能向下兼容以前笨笨的 Class 文件,但不能运行新版本的 Class 文件。例如一个 Class 文件是使用 JDK 1.5 编译的,那么我们可以用 JDK 1.7 虚拟机运行它,但不能用 JDK 1.4 虚拟机运行它。下表列出了各个版本 JDK 的十六进制版本号信息:
我们看看之前的 Demo 文件的 Class 文件,其前 8 个字节分别是:cafe babe 0000 0034
。那么我们可以知道,这个 Class 文件是由 JDK1.8 编译的。
常量池
Class 文件的第 9 - 10 个字节用于表示常量池常量的个数(constant_pool_count),那么紧跟着就有 constant_pool_count - 1 个常量。我们Class 文件第 9 - 10 个字节为 001d,表示有 28 个常量。
每个常量池的常量都用一个类型为 cp_info 的表表示,该表有 14 个值,分别是:
第 1 个常量。紧接着 001d 的后一个字节为 0A,表示该常量为方法引用类型(CONSTANT_MethodHandle_info)的常量。从上面的总表查阅知道,该常量项第 2 - 3 个字节表示类信息,这里是 0006 表示指向常量池第 6 个常量所表示的信息。该常量项的第 4 - 5 个字节表示名称及类描述符,这里值为 000f 表示指向常量池第 10 个常量所表示的信息。
第 2 个常量。紧接着 000f 的后一个字节为 09,表示该常量为字段引用类型(CONSTANT_Fieldref_info)的常量。从上面的总表查阅知道,该常量项第 2 - 3 个字节表示类信息,这里是 0010 表示指向常量池第 16 个常量所表示的信息。该常量项的第 4 - 5 个字节表示名称及类描述符,这里值为 0011 表示指向常量池第 17 个常量所表示的信息。
第 3 个常量。紧接着 0011 的后一个字节为 08,表示该常量为字符串引用类型(CONSTANT_String_info)的常量。从上面的总表查阅知道,该常量项第 2 - 3 个字节表示指向字符串字面量的索引,这里是 0012 表示指向常量池的第 18 个常量。
第 4 个常量。紧接着 0012 的后一个字节为 0A,表示该常量为方法引用类型(CONSTANT_MethodHandle_info)的常量。从上面的总表查阅知道,该常量项第 2 - 3 个字节表示类信息,这里是 0013 表示指向常量池第 19 个常量所表示的信息。该常量项的第 4 - 5 个字节表示名称及类描述符,这里值为 0014 表示指向常量池第 20 个常量所表示的信息。
第 5 个常量,是类信息类型常量,其指向了常量池第 21 个常量。
第 6 个常量,是类信息类型常量,其指向了常量池第 22 个常量。
第 7 个常量。这里表示 tag 的值是 01,表示该常量为一个字符串(CONSTANT_Utf8_info)的常量。从上面的总表查阅知道,该常量项第 2 - 3 个字节表示该字符串的长度,这里是 0006 表示该字符串长度为 6 个字节。这里紧接着 01 的六个字节为 3C 69 6E 69 74 3E。在 Class 文件中,字符串是使用 ASCII 码进行编码的,我们将这些十六进制字符转换成对应的 ASCII 码之后,其值为:<init>
。
第 8 个常量,是一个字符串常量,转换之后是:()V
。
第 9 个常量,是一个字符串常量,转换之后是:Code
。
第 10 个常量,是一个字符串常量,转换之后是:LineNumberTable
。
第 11 个常量,是一个字符串常量,转换之后是:main
。
第 12 个常量,是一个字符串常量,转换之后是:([Ljava/lang/String;)V
。
第 13 个常量,是一个字符串常量,转换之后是:SourceFile
。
第 14 个常量,是一个字符串常量,转换之后是:Demo.java
。
第 15 个常量。这里表示 tag 的值是 0C,表示该常量为方法引用类型(CONSTANT_NameAndType_info)的常量。从上面的总表查阅知道,该常量项第 2 - 3 个字节表示字段或方法名的索引,这里是 0007 表示指向常量池第 7 个常量所表示的信息。该常量项的第 4 - 5 个字节表示字段或方法描述符的索引,这里值为 0008 表示指向常量池第 8 个常量所表示的信息。根据我们之前的分析,可以知道第 15 个常量表示的信息其实是:"<init>":()V
。
第 16 个常量。这里表示 tag 的值是 07,表示该常量为类信息类型(CONSTANT_Class_info)的常量。从上面的总表查阅知道,该常量项第 2 - 3 个字节表示全限定名常量项的索引,这里是 0017 表示指向常量池第 23 个常量所表示的信息。
第 17 个常量。这里表示 tag 的值是 0C,表示该常量为方法引用类型(CONSTANT_NameAndType_info)的常量。从上面的总表查阅知道,该常量项第 2 - 3 个字节表示字段或方法名的索引,这里是 0018 表示指向常量池第 24 个常量所表示的信息。该常量项的第 4 - 5 个字节表示字段或方法描述符的索引,这里值为 0019 表示指向常量池第 25 个常量所表示的信息。根据我们之前的分析,可以知道第 17 个常量表示的信息其实是:out:Ljava/io/PrintStream;
。
第 18 个常量,是一个字符串常量,转换之后是:Hello World
。
第 19 个常量。这里表示 tag 的值是 07,表示该常量为类信息类型(CONSTANT_Class_info)的常量。从上面的总表查阅知道,该常量项第 2 - 3 个字节表示全限定名常量项的索引,这里是 001A 表示指向常量池第 26 个常量所表示的信息。
第 20 个常量。这里表示 tag 的值是 0C,表示该常量为方法引用类型(CONSTANT_NameAndType_info)的常量。从上面的总表查阅知道,该常量项第 2 - 3 个字节表示字段或方法名的索引,这里是 001B 表示指向常量池第 27 个常量所表示的信息。该常量项的第 4 - 5 个字节表示字段或方法描述符的索引,这里值为 001C 表示指向常量池第 28 个常量所表示的信息。
第 21 个常量,是一个字符串常量,转换之后是:Demo
。
第 22 个常量,是一个字符串常量,转换之后是:java/lang/Object
。
第 23 个常量,是一个字符串常量,转换之后是:java/lang/System
。
第 24 个常量,是一个字符串常量,转换之后是:out
。
第 25 个常量,是一个字符串常量,转换之后是:Ljava/io/PrintStream;
。
第 26 个常量,是一个字符串常量,转换之后是:java/io/PrintStream
。
第 27 个常量,是一个字符串常量,转换之后是:println
。
第 28 个常量,是一个字符串常量,转换之后是:(Ljava/lang/String;)V
。
到这里,我们常量池里 28 个常量已经全部解析完了。我们通过手动分析,了解了常量池的构成,但很多时候我们可以借助 JDK 提供的 javap 命令直接查看 Class 文件的常量池信息。
当我们运行javap -verbose Demo.class
时,控制台会打印出该 Class 文件的构成信息,其中就包括了常量池的信息。
将利用 javap 打印出的结果,与我们手动分析的结果对比一下,你会发现结果是一致的。
访问标志
在常量池结束之后,紧接着的两个字节代表访问标记(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口、是否定义为public类型、是否定义为abstract类型等。具体的标志位以及标志的含义见下表。
在这里这两个字节是 00 21,通过查看我们并没有发现有标志值是 00 21 的标志名称。这是因为这里的访问标志可能是由多个标志名称组成的,所以字节码文件中的标志值其实是多个值进行或运算的结果。
通过查阅上述表格,我们可以知道,00 21 由 00 01 和 00 20 进行或运算得来。也就是说该类的访问标志是 public 并且允许使用 invokespecial 字节码指令的新语义。
类索引、父类索引、接口索引
类索引和父类索引都是一个u2类型的数据,而接口索引集合是一组u2类型的数据的集合,Class文件中由这三项数据来确定这个类的继承关系。
类索引。类索引用于确定这个类的全限定名,它用一个 u2 类型的数据表示。这里的类索引是 00 05 表示其指向了常量池中第 5 个常量,通过我们之前的分析,我们知道第 5 个常量其最终的信息是 Demo 类。
父类索引。父类索引用于确定这个类的父类的全限定名,父类索引用一个u2类型的数据表示。这里的父类索引是 00 06 表示其指向了常量池中第 6 个常量,通过我们之前的分析,我们知道第 6 个常量其最终的信息是 Object 类。因为其并没有继承任何类,所以 Demo 类的父类就是默认的 Object 类。
接口索引。接口索引集合就用来描述哪个类实现了哪些接口,这些被实现的接口将按 implements 语句(如果这个类本身就是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。对于接口索引集合,入口第一项是 u2 类型的数据为接口计数器(interfaces_count),表示索引表的容量,而在接口计数器后则紧跟着所有的接口信息。如果该类没有实现任何接口,则该计数器值为0,后面接口的索引表不再占用任何字节。
这里 Demo 类的字节码文件中,因为并没有实现任何接口,所以紧跟着父类索引后的两个字节是0x0000,这表示该类没有实现任何接口。因此后面的接口索引表为空。
字段表集合
字段表集合用于描述接口或者类中声明的变量。这里说的字段包括类级变量和实例级变量,但不包括在方法内部声明的局部变量。
在类接口集合后的2个字节是一个字段计数器,表示总有有几个属性字段。在字段计数器后,才是具体的属性数据。字段表的每个字段用一个名为 field_info 的表来表示,field_info 表的数据结构如下所示:
因为我们并没有声明任何的类成员变量或类变量,所以在 Demo 的字节码文件中,字段计数器为 00 00,表示没有属性字段。
方法表集合
在字段表后的 2 个字节是一个方法计数器,表示类中总有有几个方法。在字段计数器后,才是具体的方法数据。方法表中的每个方法都用一个 method_info 表示,其数据结构如下:
Demo 类的字节码文件中,方法计数器的值为 00 02,表示一共有 2 个方法。
第 1 个方法。方法计数器后 2 个字节表示方法访问标识,这里是 00 01,表示其实 ACC_PUBLIC 标识,即该方法访问表示为 public。紧接着 2 个字节表示方法名称的索引,这里是 00 07 表示指向了常量池第 7 个常量,查阅可知其指向了<init>
。紧接着的 2 个字节表示方法描述符索引项,这里是 00 08 表示指向了常量池第 8 个常量,查阅可知其指向了()V
。紧接着 2 个字节表示属性表计数器,这里是 00 01 表示该方法一共有 1 个属性。紧接着的一连串就是属性表的内容。
到这里我们通过对 Hello World 的解析,从而对 Java 类文件结构有了一个全面的认识。进一步还简单了解了 Java 虚拟机以及 Java 虚拟机规范。希望读完这篇文章,大家能对 Java 类文件结构有一个深入的认识。