Java 虚拟机不与任何程序语言绑定,它只与 Class 文件这种特定的二进制文件格式所关联。Class 文件是一组以 8 个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中,并且中间没有添加任何分隔符,这使得整个 Class 文件中存储的内容几乎全部是程序运行的必要数据。当遇到需要占用 8 个字节以上空间的数据项时,则会按照高位在前的方式分割成若干个 8 个字节进行存储。
根据《Java 虚拟机规范》的规定,Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数 和 表,后面的解析都要以这两种数据类型为基础:
- 无符号数:属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1、2、4、8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值。
- 表:由多个无符号数或其他表作为数据项构成的复合数据类型,用于描述有层次关系的复合结构的数据,整个 Class 文件本质上也可以看作是一张表,这张表由下面的数据项按严格顺序排列构成。
类型 | 名称 | 数量 |
---|---|---|
u4 | magic | 1 |
u2 | minor_version | 1 |
u2 | major_version | 1 |
u2 | constant_pool_count | 1 |
cp_info | constant_pool | constant_pool_count - 1 |
u2 | access_flags | 1 |
u2 | this_class | 1 |
u2 | super_class | 1 |
u2 | interfaces_count | 1 |
u2 | interfaces | interfaces_count |
u2 | fields_count | 1 |
field_info | fields | fields_count |
u2 | methods_count | 1 |
method_info | methods | methods_count |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
由于 Class 结构没有任何分隔符号,所以在上表中的数据项,无论是顺序还是数量都是被严格限定的,哪个字节代表什么含义,长度是多少,以及先后顺序都是不允许改变的。
下面我们来分析下该表中各个数据项的具体含义,使用 JDK 8 编译下面这个简单的代码示例,然后查看编译后的 Class 文件结构:
public class TestClass {
private int m;
public int inc() {
return m + 1;
}
}
魔数
每个 Class 文件的头 4 个字节称为 魔数(Magic Number),它固定为一个特定值,唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意改动。如果 Class 文件不以魔数开头,虚拟机在进行文件校验时会直接抛出错误。
Class 文件版本
紧接着魔数的 4 个字节存储的是 Class 文件的版本号:第 5、6 字节是 次版本号(Minor Version),第 7、8 字节是 主版本号(Major Version)。版本号表示的是当前 Class 文件是由哪个版本的编译器编译产生的。高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行在其之后版本的 Class 文件,因为《Java 虚拟机规范》在 Class 文件校验部分明确要求了即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的 Class 文件。
使用十六进制编译器打开编译后的 Class 文件,可以看到开头 4 个字节的十六进制表示的是 0xCAFEBABE,代表次版本号的第 5、6 个字节值为 0x0000,而主版本号的值为 0x0034,即十进制的 52。该版本号说明这个是可以被 JDK 8 或以上版本虚拟机执行的 Class 文件。
常量池
紧接着主、次版本号之后的是常量池入口,常量池 可以理解为 Class 文件中的资源仓库,它是 Class 文件结构中与其他项目关联最多的数据类型,通常也是占用 Class 文件空间最大的数据项目之一。
由于 常量池 中常量的数量是不固定的,所以在常量池的入口需要放置一项 u2 类型的数据,来表示常量池容量计数值(constant_pool_count)。容量计数是从 1 而不是 0 开始的,如下图所示,常量池容量为十六进制数 0x0013,即十进制的 19,即代表常量池中有 18 项常量,索引值范围为 1~18。设计者将第 0 项常量空出来是为了如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,则可以把索引值设置为 0 来表示。
Class 文件结构中只有常量池的容量计数是从 1 开始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都是从 0 开始的。
1. 存储内容
常量池 中主要存放两大类常量:
字面量:比较接近于 Java 语言层面的常量概念,如文本字符串、被声明为 final 的常量值等
符号引用:属于编译原理方面的概念,主要包括
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
- 方法句柄和方法类型
Class 文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
2. 常量表
常量池中每一项常量都是一个 表,截至 JDK 13 常量表中分别有 17 种不同类型的常量。这 17 类表都有一个共同的特点,表结构起始的第一位是个 u1 类型的标志位,代表着当前常量属于哪种常量类型。17 种常量类型所代表的具体含义如下表所示:
类型 | 标志 | 描述 |
---|---|---|
CONSTANT_Utf8_info | 1 | UTF-8 编码的字符串 |
CONSTANT_Integer_info | 3 | 整型字面量 |
CONSTANT_Float_info | 4 | 浮点型字面量 |
CONSTANT_Long_info | 5 | 长整型字面量 |
CONSTANT_Double_info | 6 | 双精度浮点型字面量 |
CONSTANT_Class_info | 7 | 类或接口的符号引用 |
CONSTANT_String_info | 8 | 字符串类型字面量 |
CONSTANT_Fieldref_info | 9 | 字段的符号引用 |
CONSTANT_Methodref_info | 10 | 类中方法的符号引用 |
CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 字段或方法的部分符号引用 |
CONSTANT_MethodHandler_info | 15 | 表示方法句柄 |
CONSTANT_MethodType_info | 16 | 表示方法类型 |
CONSTANT_Dynamic_info | 17 | 表示一个动态计算常量 |
CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点 |
CONSTANT_Module_info | 19 | 表示一个模块 |
CONSTANT_Package_info | 20 | 表示一个模块中开放或导出的包 |
从上面的十六进制编辑器中可以看到常量池中的第一项常量,它的标志位是 0x0A,即十进制的 10,由上表可知这个常量属于 CONSTANT_Methodref_info 类型,此类型的常量代表一个类中方法的符号引用:
CONSTANT_Methodref_info 类型常量的结构
名称 | 类型 | 描述 |
---|---|---|
tag | u1 | 标志位,用于区分常量类型,值为 10 |
index | u2 | 指向声明方法的类描述符 CONSTANT_Class_info 的索引项 |
index | u2 | 指向名称及类型描述符 CONSTANT_NameAndType 的索引值 |
本例中第一个 index 的值为 0x0004,也就是指向了常量池中的第四项常量(第四项常量为一个 CONSTANT_Class_info 类型的常量);第二个 index 的值为 0x000f,即十进制的 15,也就是指向了常量池中的第 15 项常量。
剩下的 16 种结构不再赘述,后面的常量表分析我们直接借助 javap 工具来计算:
从上图中可以看到一共 18 个常量,并且第一个是 CONSTANT_Methodref_info 类型,第一个 index 指向第四项,第二个 index 指向第 15 项,说明之前的分析是正确的。但其中有些常量似乎从来没有在代码中出现过,如
顺便提一下,由于 Class 文件中方法、字段等都需要引用 CONSTANT_Utf8_info 型常量来描述名称,所以 CONSTANT_Utf8_info 的最大长度也就是 Java 中方法、字段名的最大长度。而 CONSTANT_Utf8_info 使用 u2 类型来表示长度,所以长度最长为 65535。 所以 Java 程序中如果定义了超过 64KB 英文字符的变量或方法名,即使规则和全部字符都是合法的,也会无法编译。
访问标志
在常量池结束后,紧接着的 2 个字节代表 访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,具体定义如下表所示:
标志名称 | 标志值 | 描述 |
---|---|---|
ACC_PUBLIC | 0x0001 | 是否定义为 public 类型 |
ACC_FINAL | 0x0010 | 是否被声明为 final,只有类可设置 |
ACC_SUPER | 0x0020 | 指向名称及类型描述符 CONSTANT_NameAndType 的索引值 |
ACC_INTERFACE | 0x0200 | 标识这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否定义为 abstract 类型,对于接口或抽象类来说,此标志值为真 |
ACC_SYNTHETIC | 0x1000 | 标识这个类并非由用户代码产生的 |
ACC_ANNOTATION | 0x2000 | 标识这是一个注解 |
ACC_ENUM | 0x4000 | 标识这是一个枚举 |
ACC_MODULE | 0x8000 | 标识这是一个模块 |
在我们上面的代码示例中,TestClass 是一个普通 Java 类,不是接口、枚举、注解,被 public 关键字修饰但没有被声明为 final 和 abstract,因此它的 ACC_PUBLIC、ACC_SUPER 标志应当为真,而其余的七个标志应当为假,因此 access_flags 值应为:0x0001 | 0x0020=0x0021。
类索引、父类索引与接口索引集合
类索引(this_class)和 父类索引(super_class)都是一个 u2 类型的数据,而 接口索引集合(interfaces)是一组 u2 类型的数据的集合,Class 文件中由这三项数据来确定该类型的继承关系。
- 类索引用于确定这个类的全限定名
- 父类索引用于确定这个类的父类的全限定名。
- 接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按 implements 关键字后的接口顺序从左到右排列在接口索引集合中。
类索引、父类索引和接口索引集合都按顺序排列在访问标志之后,类索引 和 父类索引 用两个 u2 类型的索引值表示,它们各自指向一个类型为 CONSTANT_Class_info 的类描述符常量,通过 CONSTANT_Class_info 类型的常量中的索引值可以找到定义在 CONSTANT_Utf8_info 类型的常量中的全限定名字符串。
从图中可以看到类索引的值为 0x0003,表示指向常量池中的第三项常量,该常量又指向了常量池中第 17 项常量,即 ClassTest,正好对应我们的类名称:
对于 接口索引集合,入口的第一项 u2 类型的数据为接口计数器(interfaces_count),表示索引表的容量。如果该类没有实现任何接口,则该计数器值为 0,后面接口的索引表不再占用任何字节。
字段表集合
字段表(field_info)用于描述接口或者类中声明的变量。Java 语言中的字段(Field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。
字段可以包括的修饰符有字段的作用域(public、private、protected)、是实例变量还是类变量(static)、可变性(final)、并发可见性(volatile)、可否被序列化(transient)、字段数据类型(基本类型、对象、数组)、字段名称。上述这些信息中,各个修饰符都是布尔值,要么有要么没有,很适合使用标志位来表示。而字段名称、字段的数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。
字段表(field_info)结构
类型 | 名称 | 数量 | 描述 |
---|---|---|---|
u2 | access_flags | 1 | 字段修饰符 |
u2 | name_index | 1 | 字段的简单名称 |
u2 | descriptor_index | 1 | 字段的描述符索引 |
u2 | attributes_count | 1 | 属性表个数 |
attribute_info | attributes | attributes_count | 属性表集合,用于存储额外信息,比如初始化值 |
1. 字段修饰符
字段修饰符(access_flags)设置的标志位和含义如下表所示:
标志名称 | 标志值 | 描述 |
---|---|---|
ACC_PUBLIC | 0x0001 | 字段是否 public |
ACC_PRIVATE | 0x0002 | 字段是否 private |
ACC_PROTECTED | 0x0004 | 字段是否 protected |
ACC_STATIC | 0x0008 | 字段是否 static |
ACC_FINAL | 0x0010 | 字段是否 final |
ACC_VOLATILE | 0x0040 | 字段是否 volatile |
ACC_TRANSIENT | 0x0080 | 字段是否 transient |
ACC_SYNTHETIC | 0x1000 | 字段是否由编译器自动产生 |
ACC_ENUM | 0x4000 | 字段是否 enum |
很明显,由于语法规则的约束,ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED 三个标志最多只能选择其一,并且 ACC_FINAL、ACC_VOLATILE 不能同时选择。接口之中的字段必须有 ACC_PUBLIC、ACC_STATIC、ACC_FINAL 标志,这些都是由 Java 本身的语言规则所导致的。
2. 索引
跟随 access_flags 标志的是两项索引值:name_index 和 descriptor_index。它们都是对常量池项的引用,分别代表着字段的简单名称以及字段和方法的描述符。这里解释一下:简单名称、描述符以及全限定名这三种特殊字符串的概念。
- 全限定名:com/xx/xx/ClassTest,仅仅是把类全名中的 “.” 替换成了 “/“ 而已。
- 简单名称:没有类型和参数修饰的方法或字段名称,即代码中的 inc 和 m。
- 描述符:描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。
根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的 void 类型都用一个大写字符来表示,而对象类型则用字符 L 加对象的全限定名来表示,详见下表。
描述符标识字符含义
标识字符 | 含义 | 标识字符 | 含义 |
---|---|---|---|
B | 基本类型 byte | J | 基本类型 long |
C | 基本类型 char | S | 基本类型 short |
D | 基本类型 double | Z | 基本类型 boolean |
F | 基本类型 float | V | 特殊类型 void |
I | 基本类型 int | L | 对象类型,如 Ljava/lang/Object; |
对于数组类型,每一维度将使用一个前置的 “[“ 字符来描述,如一个定义为 String[][] 类型的二维数组将在字节码中被记录成 [[Ljava/lang/String;,一个整型数组 int[] 将被记录成 [I。
用描述符来描述方法时,按照先参数列表、后返回值的顺序描述,参数列表按参数的顺序放在一组小括号 () 之内。如方法 void inc() 的描述符为 ()V,方法 java.lang.String toString() 的描述符为 ()Ljava/lang/String;,方法int indexOf(char[] a, int b, char[] c, int d) 的描述符为 ([CI[CI)I。
分析下我们的示例代码可以看到:fields_count 的值为 0x0001,说明这个类只有一个字段表。后面紧跟着的是 access_flags 标志,值为 0x0002,表示字段的修饰符为 private。代表字段名称的 name_index 的值为 0x0005,表示指向常量池中的第五项常量,其值为 m。代表字段描述符的 descriptor_index 的值为 0x0006,指向常量池中的第六项常量,其值为 I。由此我们可以推断出原代码定义的字段为 private int m;。
3. 属性表
字段表 所包含的固定数据项目到 descriptor_index 为止就全部结束了,不过在 descriptor_index 之后跟随着一个属性表集合,用于存储一些额外的信息,字段表可以在属性表中附加描述零至多项的额外信息。由于本例中的字段 m 并没有初始化的赋值操作,所以它的属性表计数器为 0。
字段表集合中不会列出从父类或者父接口中继承而来的字段,但有可能出现原本 Java 代码中不存在的字段,譬如在内部类中为了保持对外部类的访问性,编译器就会自动添加指向外部类实例的字段。另外,在 Java 语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于 Class 文件格式来讲,只要两个字段的描述符不是完全相同,那字段重名就是合法的。
方法表集合
Class 文件存储格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,方法表 的结构与字段表的结构一样,其中数据项的含义也与字段表中的非常类似,仅在访问标志和属性表集合的可选项中有所区别。
方法表(method_info)结构
类型 | 名称 | 数量 | 描述 |
---|---|---|---|
u2 | access_flags | 1 | 方法修饰符 |
u2 | name_index | 1 | 方法的简单名称 |
u2 | descriptor_index | 1 | 方法的描述符索引,表示方法参数、描述符等 |
u2 | attributes_count | 1 | 属性表个数 |
attribute_info | attributes | attributes_count | 属性表集合,用于存储额外信息,比如方法字节码 |
因为 volatile 和 transient 关键字不能修饰方法,所以 方法表 的访问标志去掉了这两个标志。相反,native、synchronized、strictfp 和 abstract 关键字可以修饰方法,因此方法表的访问标志中也相应地增加了这四个标志。方法表的所有标志位及其取值如下表所示。
标志名称 | 标志值 | 描述 |
---|---|---|
ACC_PUBLIC | 0x0001 | 方法是否为 public |
ACC_PRIVATE | 0x0002 | 方法是否为 private |
ACC_PROTECTED | 0x0004 | 方法是否为 protected |
ACC_STATIC | 0x0008 | 方法是否为 static |
ACC_FINAL | 0x0010 | 方法是否为 final |
ACC_SYNCHRONIZED | 0x0020 | 方法是否为 synchronized |
ACC_BRIDGE | 0x0040 | 方法是不是由编译器产生的桥接方法 |
ACC_VARARGS | 0x0080 | 方法是否接受不定参数 |
ACC_NATIVE | 0x0100 | 方法是否为 native |
ACC_ABSTRACT | 0x0400 | 方法是否为 abstract |
ACC_STRICT | 0x0800 | 方法是否为 strictfp |
ACC_SYNTHETIC | 0x1000 | 方法是否由编译器自动产生 |
方法的定义可以通过访问标志、名称索引、描述符索引来表达清楚,但方法里面的代码去哪里了?方法里的 Java 代码,经过 Javac 编译器编译成字节码指令之后,存放在方法属性表集合中一个名为 Code 的属性里面,属性表作为 Class 文件格式中最具扩展性的一种数据项目,我们在下一篇会详细讲解。
与字段表集合相对应地,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样地,有可能会出现由编译器自动添加的方法,最常见的便是类构造器
在 Java 中重载(Overload)一个方法,除了与原方法具有相同的简单名称外,还要求必须拥有一个与原方法不同的特征签名。特征签名是指一个方法中各个参数在常量池中的字段符号引用的集合,正因为返回值不会包含在特征签名中,所以 Java 里是无法仅依靠返回值的不同来对一个已有方法进行重载的。但 Class 文件格式中的特征签名范围要更大一些,只要描述符(名称、参数、返回值)不是完全一致的两个方法就能共存。
如下图所示,方法表集合 中的 methods_count 的值为 0x0002,表示有两个方法,这两个方法为编译器添加的实例构造器