Chapter 6 - 类文件结构
Created by : Mr Dk.
2020 / 01 / 28 16:57 🧨🧧
Ningbo, Zhejiang, China
6.2 无关性的基石
Java 的口号:Write Once, Run Anywhere.。
Java 平台无关性的基石:
- 各种不同平台的 JVM
- 所有平台统一支持的程序存储格式 - 字节码 (Byte Code)
在 Java 技术发展之初,设计者们考虑并实现了让其它语言运行在 JVM 之上的可能性,Java 的规范被拆分为:
- Java 语言规范
- Java 虚拟机规范
目前,除 Java 外,发展出了大批运行在 JVM 上的语言
- Kotlin
- Clojure
- Groovy
- JRuby
- JPython
- Scala
任何语言都可以把 JVM 作为语言的运行基础,以 Class 文件作为语言执行的交付媒介。JVM 不关心 Class 文件的来源是什么语言。Java 语言中的各种语法、关键字等语义最终都会由多条字节码指令组合来表达。
6.3 Class 类文件的结构
Class 文件结构相当稳定,基本上只会在原有结构基础上新增内容、扩充功能。以 8 个字节为基础单位的二进制流,中间没有任何分隔符,只有两种数据类型:
- 无符号数 -
u1u2u4u8 - 表 - 由多个无符号数或表构成
当需要描述同一类型但数量不定的多个数据时,前置一个容量计数器,后面紧跟多个数据项。
文件结构:
- magic - 魔数
- minor_version - 文件次版本
- major_version - 文件主版本
- constant_pool_count - 常量池计数值
- constant_pool - 常量池
- access_flags - 访问标志
- this_class - 类索引
- super_class - 父类索引
- interfaces_count - 接口索引计数值
- interfaces - 接口索引
- fields_count - 字段表计数值
- fields - 字段表
- methods_count - 方法表计数值
- methods - 方法表
- attributes_count - 属性表计数值
- attributes - 属性表
6.3.1 魔数与 Class 文件的版本
魔数占头四个字节 - 0xCAFEBABE,紧接的四个字节是版本号:
- 前两个字节是次版本号
- 后两个字节是主版本号 (从
45(JDK 1.1) 开始)
6.3.2 常量池
是 Class 文件中的资源仓库,与 Class 文件结构中其它项目关联最多的数据。其数量不固定,因此放置了一个 u2 类型的常量池计数值。
常量池的计数是从 1 开始的 (如 22),那么常量池中有 1 - 21 项常量。第 0 项代表不引用任何常量池项目。
常量池存放两类常量:
- 字面量 (Literal)
- 符号引用 (Symbolic References)
- 包 (Package)
- 类和接口的全限定名 (Fully Qualified Name)
- 字段的名称和描述符
- 方法的名称和描述符
- 方法句柄和方法类型
- 动态调用点和动态常量
常量池中的每一项常量都是一个表。表结构的起始第一位是一个 u1 类型的标志,代表常量的类型。之后是常量的具体内容。不同的常量类型有着不同的结构:
CONSTANT_Utf8_infoCONSTANT_Interger_infoCONSTANT_Float_info- ...
Oracle 提供了
javap工具用于分析字节码内容。
6.3.3 访问标志
识别类或接口层次的信息:
- 是类还是接口?
- 是否为
public - 是否为
abstract - 是否被声明为
final - ...
6.3.4 类索引、父类索引与接口索引集合
Class 文件通过这三项数据确定该类型的继承关系:
- 类索引确定该类的全限定名
- 父类索引确定该类父类的全限定名 (Java 不允许多继承)
除了 java.lang.Object 外,父类索引都不为 0。
接口索引描述该类实现了哪些接口,按 implement 关键字后的接口顺序从左到右排列在接口索引集合中。实现的接口数量不定,所以存在接口索引集合开头有一个计数器。索引指向常量池中的类描述符常量。
6.3.5 字段表集合
描述接口或类中声明的变量:
- 类级变量
- 实例级变量
字段的结构体定义为:
access_flags- 作用域
publicprivateprotected - 是实例变量还是类变量
static - 可变性
final - 并发可见性
volatile(是否强制从主内存读写) - 是否可被序列化
transient - ...
- 作用域
name_index- 指向常量池中的字段简单名称 (没有类型和参数修饰的字段名称)
descriptor_index- 指向常量池中的描述符
attributes_count(属性表,可选)attributes
字段表集合不会列出从父类继承而来的字段。
6.3.6 方法表集合
描述类或接口中定义的函数:
access_flags- 作用域
publicprivateprotected - 是实例变量还是类变量
static - 可变性
final synchronizednativestrictfpabstract
- 作用域
name_index- 指向常量池中的函数简单名称 (没有类型和参数修饰的字段名称)
descriptor_index- 指向常量池中的描述符
attributes_count(属性表,可选)attributes
函数中的 Java 代码经过 javac 编译为字节码后,存放在方法属性表的 Code 属性下。如果父类方法在子类中没有被 override,方法表中就不会出现来自父类的方法信息。另外,在 Java 中,如果要重载一个函数,需要满足:
- 与原方法具有相同的简单名称
- 要与原方法有不同的 特征签名
- 指一个方法中各个参数在常量池中的字段符号引用的集合
- 返回值不包含在特征签名中
因此 Java 无法只通过返回值来对一个已有的方法进行重载。
6.3.7 属性表集合
每个属性项的结构:
- attribute_name_index
- 属性名称
- 从常量池中引用一个
CONSTANT_Utf8_info类型的常量
- attribute_length
- 属性值的长度
- info
- 属性值
- 结构可以完全自定义
使用这些属性项的位置也不同。有些在方法表中使用,有些在字段表中使用...
6.3.7.1 Code 属性
并非所有的方法都有该属性 (比如接口或抽象类)
attribute_name_index- 指向
CONSTANT_Utf8-info型常量索引 (固定为Code)
- 指向
attribute_length- 属性值长度max_stack- 操作数栈深度的最大值
max_locals- 局部变量表所需的存储空间
- 单位是变量槽 (JVM 为局部变量分配内存所使用的最小单位)
code_lengthcode- 字节码exception_table_lengthexception_table- 显式异常处理表attributes_countattributes
其中的异常表用于处理 try-catch-finally。异常表的结构为:
start_pcend_pchandler_pccatch_type
含义为,当字节码 start_pc 到 end_pc 之间出现 catch_type 或其子类的异常,则转到 handler_pc 行处理;当 catch_type 的值为 0 时,任意异常情况都要转到 handler_pc 处理。
一个例子:
public int inc() {
int x;
try {
x = 1;
return x;
} catch (Exception e) {
x = 2;
return x;
} finally {
x = 3;
}
}
- 如果程序正常运行,执行
x = 1;后保存在返回值的变量槽中- 执行
x = 3;后,将返回值变量槽中的1读入操作栈顶并返回
- 执行
- 如果程序异常
- 转入
x = 2;后保存在返回值的变量槽中 - 执行
x = 3;后,将返回值变量槽中的2读入操作栈顶并返回
- 转入
6.3.7.2 Exceptions 属性
与 Code 属性中的异常表不同,作用是列举出函数中可能抛出的受查异常 (Checked Exceptions),也就是函数描述时 throws 关键字后面列举的异常。
6.3.7.3 LineNumberTable 属性
描述 Java 源码行号与字节码行号之间的对应关系,用于在抛出异常时,显示在 Java 程序中的出错行号。
6.3.7.4 LocalVariableTable 及 LocalVariableTypeTable 属性
描述栈帧中局部变量表中的变量与 Java 源代码中定义变量之间的关系。如果没有这个属性,当别人引用这个方法时,所有的参数名称将会丢失:比如 IDE 中自动填充的 arg0 arg1。
6.3.7.5 SourceFile 及 SourceDebugExtension 属性
记录生成这个 Class 文件的源码文件名称。如果没有这个属性,抛出异常时,堆栈中将不会显示出错代码所属的文件名。
6.3.7.6 ConstantValue 属性
通知虚拟机自动为静态变量赋值,只有被 static 关键字修饰的变量才能用这种属性。
6.3.7.7 InnerClasses 属性
记录内部类与宿主类之前的关联。属性的结构:
attribute_name_index- 对应常量池中的InnerClassesattribute_lengthnumber_of_classesinner_classes
每个内部类属性值的结构如下:
inner_class_info_index- 指向常量池中的
CONSTANT_Class_info类型常量的索引 - 代表内部类
- 指向常量池中的
outer_class_info_index- 同上
- 代表宿主类
inner_name_index- 指向常量池中
CONSTANT_Utf8_info类型常量的索引 - 如果是匿名内部类,值为
0
- 指向常量池中
inner_class_access_flags- 内部类的访问标志
6.3.7.8 Deprecated 及 Synthetic 属性
6.3.7.9 StackMapTable 属性
6.3.7.10 Signature 属性
6.3.7.11 BootstrapMethods 属性
6.3.7.12 MethodParameters 属性
记录方法的各个形参名称和信息。之前的 LocalVariableTable 属性是 Code 属性的子属性,这意味着没有方法体就没有局部属性表。而对于接口或抽象方法来说,是可以不存在方法体的,该属性可以将方法中的参数信息保存下来。
6.4 字节码指令简介
JVM 的指令由一个字节长度的操作码,以及跟随其后的 0 或多个操作数构成。大多数指令都不包含操作数,只有一个操作码。JVM 采用 面向操作数栈 而不是面向寄存器的架构,指令参数都放在操作数栈中。由于限制了操作码为 1 字节,因此操作码总数不能超过 256 条。
6.4.1 字节码与数据类型
大多数指令都包含操作对应的数据类型信息,比如 iload fload dload。但是由于操作码的长度有限,不可能为每条指令都提供不同数据类型的版本。因此 JVM 针对特定的操作只提供了有限数据类型的指令,这种特性称为 Not Orthogonal。编译器会在编译器或运行期作如下转换:
byte和short类型的数据带符号扩展为intboolean和char类型的数据零扩展为int- 然后统一使用
int版本的指令来操作
6.4.2 加载和存储指令
用于将数据在栈帧中的 局部变量表 和 操作数栈 之间来回传输。
6.4.3 运算指令
对两个操作数栈上的值进行某种特定运算,并将结果重新存入到操作栈顶。
6.4.4 类型转换指令
用于用户代码中的显式类型转换。JVM 直接支持小范围类型向大范围类型的安全转换。转换指令用于处理窄化类型转换:
- 可能产生不同正负号
- 可能导致精度丢失
6.4.5 对象创建与访问指令
类实例和数组都是对象,但是创建与操作使用了不同的字节码指令。
6.4.6 操作数栈管理指令
JVM 提供用于直接操作操作数栈的指令。
6.4.7 控制转移指令
对于 boolean、byte、char、short 类型的条件分支操作,都使用 int 类型的分支指令完成。对于 long、float、double 类型的条件分支比较,则先执行相应类型的分支比较指令,指令返回一个 int 型到操作数栈中,随后再执行 int 型的条件分支比较完成分支跳转。
JVM 提供的 int 型的条件分支指令是最为丰富、强大的。
6.4.8 方法调用和返回指令
- invokevirtual 指令 - 调用对象的实例方法
- invokeinterface 指令 - 调用接口方法
- invokespecial 指令 - 调用需要特殊处理的实例方法 (初始化方法、私有方法、父类方法)
- invokestatic 指令 - 调用类静态方法
- invokedynamic 指令
方法调用指令与数据类型无关,方法返回指令根据返回值的类型区分。
6.4.9 异常处理指令
用于在 Java 程序中用 throw 显式抛出异常。其它异常是自动抛出的。在 JVM 中,处理异常不是使用字节码指令实现的,而是采用异常表来完成。
6.4.10 同步指令
JVM 支持方法级的同步和方法内部一段指令序列的同步。方法级同步是隐式的,无需通过字节码指令控制,通过 管程 (Monitor) 实现:
- 函数调用时,调用指令检查函数的
ACC_SYNCHRONIZED访问标志 - 如果设置了该标志,执行线程就需要先成功持有管程,然后才能执行函数
- 函数完成后释放管程 (不管是正常完成还是非正常完成)
- 其它任何线程都无法再获取到同一个管程
同步一段指令集序列由 synchronized 语句块表示。JVM 提供 monitorenter 和 monitorexit 两条指令支持该语义,两条指令必须相互对应。
6.5 公有设计,私有实现
Class 文件格式以及字节码指令集都是 Java 虚拟机规范 中规定好的。满足该规定的约束下,对 JVM 的具体实现做出修改和优化是完全可行的,Java 虚拟机规范 中明确鼓励实现者这样去做。只要优化以后的 JVM 依旧可以正确读取 Class 文件,并且包含在其中的语义能得到完整保持,JVM 在后台如何处理 Class 文件完全是实现者自己的事情。