类文件结构
平台无关性
由于计算机只能识别 0 和 1,所以我们写的程序需要经过编译器编译成二进制格式的本地机器码(Native Code)。本地机器码依赖于平台,不同平台编译成的机器码不同。Java 打破了这种模式,加入了 Java 虚拟机这个中间者。我们只要将写好的代码经过 Java 的编译器编译,就会生成能够被 Java 虚拟机理解的 class 文件,只要任意平台安装有 Java 虚拟机,就能够执行这个编译后的 class 文件。
Java 虚拟机除了一个平台无关性,还有一个重要的特性就是语言无关性。不光是 Java,任意语言编写的程序,只要能够经过编译器编译成被 Java 虚拟机认可的 class 文件,就都可以在 Java 虚拟机上运行。时至今日,已经有大批能够在 JVM 上运行的语言和一些能够将某种语言程序翻译成 class 文件的解释器、编译器,如:Groovy、JRuby、Jython、Scala、Kotlin 等。
Class 文件结构
Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项严格按照顺序紧凑排列在 Class 文件中,中间没有任何分隔符。当遇到需要占用 8 位字节以上的数据项时,使用 Big-Endian 方式分割成若干个 8 位字节存储。
根据 Java 虚拟机规范,Class 文件格式采用一种类似于 C 语言的结构体的伪结构来存储数据,这种结构只有两种数据类型:无符号数和表。
无符号数是基本的数据类型,以 u1
、u2
、u4
、u8
分别表示 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数。无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成的字符串值。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有的表习惯性的以“_info”结尾。整个 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 文件的版本
每个 Class 文件的头部都有 4 个字节称为魔数(Magic Number),值为 0xCAFEBABE
,它的唯一作用是确定整个文件是否为一个能被虚拟机接受的 Class 文件。
紧接着魔数的是 Class 文件的版本号,第 5 个字节是 Minor Version
(次版本号),第 6 个字节是 Major Version
(主版本号)。Java 的版本号是从 45 开始的,JDK 1.1 之后每个 JDK 大版本发布,主版本号加 1。高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件。如:JDK 1.1 能支持版本号为 45.0 ~ 45.65535 的 Class 文件,但不能运行版本号为 46.0 以上的 Class 文件,即使文件格式并未发生变化。
其中 CAFEBABE 是魔数,0000 是次版本号,0034 是主版本号(JDK 8)。
常量池
由于常量池中常量的数量是不固定的,所以在常量池的入口需要设置一个 u2
类型的数据用来表示常量池的计数值 constant_pool_count
,这个值从 1 开始计算,设计者将第 0 项空出来是为了满足后边某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这个情况就可以把索引值用 0 表示。
常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic Reference)。字面量类似于 Java 语言层面的常量概念,如声明为 final 的常量值等。符号引用则属于编译原理方面的概念,包含三类常量:
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
- 方法的名称和描述符
常量池中的每一项常量都是一个表,在 JDK 7 之前共有 11 种结构不同的表,在 JDK 7 中为了更好的支持动态语言调用,又增加了 3 种。这 14 种表有一个共同的特点,表开始的第一位是一个 u1
类型的标志位,代表当前这个常量的类型。
其中 CONSTANT_Utf8_info
类型的常量,它的 length
值说明了这个 UTF-8 编码的字符串长度是多少字节,bytes
的值为长度为 length
字节的 UTF-8 缩略编码表示的字符串。由于 Class 文件中方法、字段等都需要引用 CONSTANT_Utf8_info
型常量来描述名称,所以 CONSTANT_Utf8_info
型常量的最大长度也就是 Java 中方法、字段名的最大长度,即 u2
的 65535,也就是说最大 65535 字节,即 64KB。
如上 Test.class 文件,可以看到 constant_pool_count
的值为 0x0020
,即 32,说明该类文件中一共有 31 个常量项。我们可以使用 JDK 提供的分析 Class 文件字节码的工具 javap
来查看类文件信息:
访问标志
在常量池之后的 2 个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,如一个 Class 是类还是接口;是否定义为 public 类型等等。
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 是否为 public 类型 |
ACC_FINAL | 0x0010 | 是否声明为 final,只有类可以设置 |
ACC_SUPER | 0x0020 | 是否允许使用 invokespecial 字节码指令的新语义。 invokespecial 指令的语义在 JDK 1.0.2 发生过改变,为了区别这条指令使用哪种语义,JDK 1.0.2 之后编译出来的类的这个标志都必须为真 |
ACC_INTERFACE | 0x0200 | 标识这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为 abstract 类型,对于接口或者抽象类来说,这个标志值为真,其他为假 |
ACC_SYNTHETIC | 0x1000 | 标识这个类并非由用户代码产生 |
ACC_ANNOTATION | 0x2000 | 标识这是一个注解 |
ACC_ENUM | 0x4000 | 标识这是一个枚举 |
如上 Test.class 文件,它是一个普通的类,使用 public
声明,使用 JDK 8 编译,因此 access_flags
的值应该为 0x0021
。
类索引、父类索引与接口索引集合
类索引(this_class)和父类索引(super_class)都是一个 u2
类型的数据,接口索引集合(interfaces)是一个 u2
类型的数据的集合,Class 文件中这三项来确定这个类的继承关系。
类索引用于确定这个类的全限定名。
父类索引用于确定这个类的父类的全限定名,由于 Java 不允许多继承,因此除了
java.lang.Object
外,所有的类都有父类,即所有的类的父类索引都不为 0。接口索引集合用来描述这个类实现了哪些接口,这些被实现的接口将按照
implements
(如果类本身是接口,则为extends
)的顺序排列在接口索引集合中。
类索引和父类索引用两个 u2
类型的索引值表示,它们各自指向一个 CONSTANT_Class_info
类型的类描述符常量,通过 CONSTANT_Class_info
类型的常量中的索引值可以在 CONSTANT_Utf8_info
类型的常量中找到全限定名字符串。
索引查找过程过程如下:
字段表集合
字段表(field_info)用于描述接口或类中声明的变量。字段(field)包括类变量以及实例变量(成员变量),但不包括方法内声明的局部变量。
我们可以想想 Java 中一个字段可以包含的信息:字段的作用域(public、private、protected)、是成员变量还是类变量(static)、可变性(final)、并发可见性(volatile)、是否可序列化(transient)、字段数据类型(基本类型、对象、数组)、字段名称。上述信息,字段名和字段类型是不固定的,只能用常量池中的常量表示,其他修饰符都是布尔值,很适合用标志位表示。
字段表结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | access_flags | 1 |
u2 | name_index | 1 |
u2 | descriptor_index | 1 |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
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 |
name_index
和 descriptor_index
分别代表字段的简单名称(没有类型和参数修饰的方法或字段名称)和字段、方法的描述符,它们都是对常量池的引用。
描述符标识字符及含义:
标识字符 | 含义 |
---|---|
B | 基本类型 byte |
C | 基本类型 char |
D | 基本类型 double |
F | 基本类型 float |
I | 基本类型 int |
J | 基本类型 long |
S | 基本类型 short |
Z | 基本类型 boolean |
V | 特殊类型 void |
L | 对象类型,如 Ljava/lang/Object |
对于数组类型,每一个纬度将使用一个前置的“[”字符描述,如一个定义为 java.lang.String[][]
类型的数组将被记录为:[[Ljava/lang/String
,一个整型数组 int[]
将被记录为 [I
。
用描述符描述方法时,按照先参数列表后返回值的顺序。如:
- 方法
void fn()
的描述符为()V
- 方法
java.lang.String toString()
的描述符为()Ljava/lang/String
- 方法
int indexOf(char[] source, int sourceOffset, int sourceCount, char[] target, int targetOffset, int targetCount, int fromIndex)
的描述符为([CII[CIII)I
字段表集合中不会列出从超类或接口继承而来的字段,但有可能列出原本 Java 代码中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。另外,在 Java 语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于字节码来说,如果两个字段的描述符不一致,那么字段重名是合法的。
方法表集合
方法表与字段表几乎一样。
方法表结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | access_flags | 1 |
u2 | name_index | 1 |
u2 | descriptor_index | 1 |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
访问标志:
标志名称 | 标志值 | 含义 |
---|---|---|
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_STRICTFP | 0x0800 | 方法是否是 strictfp |
ACC_SYNTHETIC | 0x1000 | 方法是否由编译器自动产生 |
如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。同时,方法表中也可能会出现由编译器自动添加的方法,典型的就是类构造器 <clinit>
方法和实例构造器 <init>
方法(当一个类中存在静态成员或静态代码块时才会生成类构造器 <clinit>
方法;<init>
方法是在一个类进行对象实例化时调用的。实例化一个类有四种途径:调用 new 操作符;调用 Class 或 java.lang.reflect.Constructor
对象的 newInstance() 方法;调用任何现有对象的 clone() 方法;通过 java.io.ObjectInputStream
类的 getObject() 方法反序列化)。
在 Java 语言中重载一个方法,除了方法名相同外,还需要方法的特征签名(即各个参数在常量池中的字段符号引用的集合)一致,但是在字节码中,特征签名的范围更大一些,只要描述符不完全一致的两个方法也可以共存,即如果两个方法有相同的名称和特征签名,但是返回值不同,它们也可以合法共存于一个 Class 中。
属性表集合
在 Class 文件、字段表、方法表中都有属性表集合(attribute_info),用于描述某些场景的专有信息。
与 Class 文件中其他数据项目要求严格的顺序、长度和内容不同,属性表集合不再要求严格的顺序,只要不与已经存在的属性名重复,任何实现的编译器都可以向属性表中写入自定义的属性信息,虚拟机运行时会忽略掉它不认识的属性。为了能正确解析 Class 文件,虚拟机规范预定义了一些属性。
属性表结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u1 | info | attribute_length |
上述是一个符合虚拟机规范的属性表,对于每个属性,它的名称要从常量池中引用 CONSTANT_Utf8_info
类型的常量来表示,属性值的结构则完全自定义,只通过一个 u4
的长度属性说明属性值所占位数。
一些常用的属性:
属性名称 | 使用位置 | 含义 |
---|---|---|
Code | 方法表 | Java 代码编译成的字节码指令 |
ConstantValue | 字段表 | final 关键字定义的常量值 |
Deprecated | 类、方法表、字段表 | 被声明为 deprecated 的方法和字段 |
Exceptions | 方法表 | 方法抛出的异常 |
EnclosingMethod | 类文件 | 仅当一个类为局部类或匿名类时才能拥有这个属性,这个属性用于标识这个类所在的外围方法 |
InnerClasses | 类文件 | 内部类列表 |
LineNumberTable | Code 属性 | Java 源码的行号与字节码指令的对应关系 |
LocalVariableTable | Code 属性 | 方法的局部变量描述 |
StackMapTable | Code 属性 | JDK 6 新增的属性,供新的类型检查验证器(Type Checker)检查和处理目标方法的局部变量和操作数栈所需要的类型是否匹配 |
Signature | 类、方法表、字段表 | JDK 5 新增的属性,用于支持泛型情况下的方法签名,在 Java 语言中,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types),则 Signature 属性会为它记录泛型签名信息。由于 Java 的泛型采用擦除法实现,在为了避免类型信息被擦除后导致签名混乱,需要这个属性记录泛型中的相关信息 |
SourceFile | 类文件 | 记录源文件名称 |
SourceDebugExtension | 类文件 | JDK 6 新增属性,用于存储额外的调试信息。譬如在进行 JSP 文件调试时,无法通过 Java 堆栈来定位到 JSP 文件的行号,JSR-45 规范为这些非 Java 语言编写,却需要编译成字节码并运行在 Java 虚拟机中的程序提供了一个进行调试的标准机制,使用该属性就可以存储这个标准所新加入的调试信息 |
Synthetic | 类、方法表、字段表 | 表示方法或字段是由编译器自动生成的 |
LocalVariableTypeTable | 类 | JDK 5 新增属性,它使用特征签名代替描述符,为了引入泛型语法之后能描述泛型参数化类型而添加 |
RuntimeVisibleAnnotations | 类、方法表、字段表 | JDK 5 新增属性,为动态注解提供支持。该属性指明哪些注解是运行时(实际上运行时就是进行反射调用)可见的 |
RuntimeInvisibleAnnotations | 类、方法表、字段表 | JDK 5 新增属性,与 RuntimeVisibleAnnotations 作用相反,用于指明哪些注解运行时不可见 |
RuntimeVisibleParameterAnnotations | 方法表 | JDK 5 新增属性,作用与 RuntimeVisibleAnnotations 作用相似,只不过作用对象为方法参数 |
RuntimeInvisibleParameterAnnotations | 方法表 | JDK 5 新增属性,作用与 RuntimeInvisibleAnnotations 作用相似,只不过作用对象为方法参数 |
AnnotationDefault | 方法表 | JDK 5 新增属性,用于记录注解类元素的默认值 |
BootstrapMethods | 类文件 | JDK 7 新增属性,用于存储 invokedynamic 指令引用的引导方法限定符 |
Code 属性
Java 代码经过编译后最终编程字节码指令存储在 Code 属性内,Code 属性在方法表的属性集合中,但并非所有的方法表都必须存在这个属性,譬如接口或者抽象类就不存在 Code 属性。
Code 属性表结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u2 | max_stack | 1 |
u2 | max_locals | 1 |
u4 | code_length | 1 |
u1 | code | code_length |
u2 | exception_table_length | 1 |
exception_info | exception_table | exception_table_length |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
attribute_name_index
是指向 CONSTANT_Utf8_info
型常量的索引,常量值为 Code
attribute_length
为属性值的长度
max_stack
为操作数栈的最大深度
max_locals
代表局部变量表所需的存储空间,这里的单位是 Slot
,是虚拟机为局部变量分配内存使用的最小单位,对于 byte
、char
、float
、int
、short
、boolean
和 returnAddress
等长度不超过 32 位的数据类型使用一个 Slot
,double
和 long
使用两个 Slot
。
code_length
是编译后字节码指令长度,code
则是存储的字节码指令。既然叫字节码指令,那么每个指令就是一个 u1
类型的单字节,一个 u1
类型的取值范围为 0x00
~`0xFF`,对应十进制的 0 到 255,所以一共能够表示 256 条指令,Java 虚拟机已经定义了 200 多条编码值对应的指令。
exception_table
是显式异常处理表。
类型 | 名称 | 数量 |
---|---|---|
u2 | start_pc | 1 |
u2 | end_pc | 1 |
u2 | handler_pc | 1 |
u2 | catch_type | 1 |
它的含义为:如果字节码在第 start_pc
行到第 end_pc
之间出现了类型为 catch_type
或者其子类的异常(catch_type
是一个指向 CONSTANT_Uft8_info
型常量的索引),则转到第 handler_pc
行继续处理。当 catch_type
值为 0 时,代表任意异常情况都需要转到 handler_pc
行进行处理。