《深入理解 Java 虚拟机》读书笔记:类文件结构

时间:2020-12-26 21:50:40

正文

一、无关性的基石

1、两种无关性

  • 平台无关性: Java 程序的运行不受计算机平台的限制,“一次编写,到处运行”。
  • 语言无关性: Java 虚拟机只与 Class 文件关联,并不关心 Class 文件的来源是何种语言。

2、无关性的实现基础

  • 各种不同平台的虚拟机
  • 所有平台都统一使用的字节码存储格式

二、Class 类文件的结构

Class 类文件是一组以 8 字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件中,中间没有添加任何分隔符。当遇到需要占用 8 字节以上空间的数据项目时,则按照高位在前(最高位字节在地址最低位)的方式分割成若干个 8 位字节进行存储。

Class 文件格式采用一种类似 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。

  • 无符号数: 基本数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数。可用来描述数字、索引引用、数量值或按照 UTF-8 编码构成字符串值。
  • 表: 由多个无符号数或其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构数据,整个 Class 文件本质上就是一张表。

无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,会使用一个前置的容量计数器加若干个连续数据项的形式,这若干个连续数据项称为集合

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

1、魔数

每个 Class 文件的头 4 个字节称为魔数,用于确定该文件是否为一个能被虚拟机接受的 Class 文件。其值为:0xCAFEBABE(咖啡宝贝?)。

2、Class 文件的版本

紧接着魔数的 4 个字节存储的是 Class 文件的版本号:第 5、6 个字节是次版本号,第 7、8 个字节是主版本号。

3、常量池

紧接着主次版本号之后的是常量池入口,常量池可以理解为 Class 文件中的资源仓库。

由于常量池中常量的数量是不固定的,所以在常量池入口放置了一个 u2 类型的常量池容量计数器。该计数器的索引值是从 1 而不是从 0 开始,当表示“不引用任何一个常量池项目”时,则可将计数器置为 0。

常量池主要存放两大类常量:字面量和符号引用。每一项常量都是一个表,这些表开始的第一位是一个 u1 类型的标志位,代表当前常量所属的常量类型。常量池目前有 14 种常量类型,它们各自均有自己的结构。

常量池的项目类型:

类型 标志 描述
CONSTANCT_Utf8_info 1 UTF-8 编码的字符串
CONSTANCT_Integer_info 3 整型字面量
CONSTANCT_Float_info 4 浮点型字面量
CONSTANCT_Long_info 5 长整型字面量
CONSTANCT_Double_info 6 双精度浮点型字面量
CONSTANCT_Class_info 7 类或接口的符号引用
CONSTANCT_String_info 8 字符串类型字面量
CONSTANCT_Fieldref_info 9 字段的符号引用
CONSTANCT_Methodref_info 10 类中方法的符号引用
CONSTANCT_InterfaceMethodref_info 11 接口中方法的符号引用
CONSTANCT_NameAndType_info 12 字段或方法的部分符号引用
CONSTANCT_MethodHandle_info 15 表示方法句柄
CONSTANCT_MethodType_info 16 标识方法类型
CONSTANCT_InvokeDynamic_info 18 表示一个动态方法调用点

常量类型结构:

(1)CONSTANT_Class_info 类型常量

类型 名称 数量 描述
u1 tag 1 标志位,值为 0x07
u2 name_index 1 索引值,指向常量池中一个 CONSTANT_Utf8_info 类型常量,表示这个类(或接口)的全限定名

(2)CONSTANT_Utf8_info 类型常量

类型 名称 数量 描述
u1 tag 1 标志位,值为 0x01
u2 length 1 UTF-8 编码的字符串占用的字节数
u1 bytes length 长度为 length 的 UTF-8 编码的字符串

(3)...

4、访问标志

常量池之后,紧接着的两个字节代表访问标志,用于识别一些类或接口层次的访问信息,包括:这个 Class 是类还是接口、是否定义为 public 类型、是否定义为 abstract 类型、是否被声明为 final(只有类可设置)等。

访问标志:

标志名称 标志值 含义
ACC_PUBLIC 0x0001 是否为 public 类型
ACC_FINAL 0x0010 是否被声明为 final,只有类可设置
ACC_SUPER 0x0020 是否允许使用 invokespecial 字节码指令的新语意,invokespecial 指令的语意在 JDK1.0.2 发生过改变,为了区别使用哪种语意,JDK1.0.2 之后编译出来的类的这个标志都必须为真
ACC_INTERFACE 0x0200 标识这个一个接口
ACC_ABSTRACT 0x0400 是否为 abstract 类型
ACC_SYNTHETIC 0x1000 标识这个类并非由用户代码产生
ACC_ANNOTATION 0x2000 标识这是一个注解
ACC_ENUM 0x4000 标识这是一个枚举

5、类索引、父类索引与接口索引集合

类索引和父类索引都是 u2 类型的数据,而接口索引集合是一组 u2 类型的数据的集合,Class 文件由这三项数据确定这个类的继承关系。

  • 类索引:指向一个类型为 CONSTANT_Class_info 的类描述符常量,表示该类的全限定名。
  • 父类索引:指向一个类型为 CONSTANT_Class_info 的类描述符常量,表示父类的全限定名。
  • 接口索引集合:用于描述该类实现了哪些接口,接口索引集合的入口放置了一个 u2 类型的接口计数器,表示索引表的容量。

6、字段表集合

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

字段表结构:

类型 名称 数量
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):指向常量池中一个 CONSTANT_Utf8_info 类型常量,代表字段的简单名称。
  • 描述符索引(descriptor_index):指向常量池中一个 CONSTANT_Utf8_info 类型常量,代表字段和方法的描述符。描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。
  • 属性表集合(attributes):用于存储一些额外的信息。

7、方法表集合

方法表的结构与字段表一样,依次包括了访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项,这些数据项目的含义也非常类似,仅在访问标志和属性表集合的可选项中有所区别。

方法表结构:

类型 名称 数量
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_SYNCHRONIZED 0x0020 方法是否 synchronized
ACC_BRIDGE 0x0040 方法是否是由编译器产生的桥接方法
ACC_VARARGS 0x0080 方法是否接受不定参数
ACC_NATIVE 0x0100 方法是否 native
ACC_ABSTRACT 0x0400 方法是否 abstract
ACC_STRICTFP 0x0800 方法是否 stricftp
ACC_SYNTHETIC 0x1000 方法是否由编译器自动产生的

方法里的 Java 代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性里面。

8、属性表集合

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

属性表不要求各个属性表具有严格的顺序,并且只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性。

属性表结构:

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u1 info attribute_length

(1)Code 属性

Java 程序方法体中的代码经过 Javac 编译器处理后,最终变成字节码指令存储在 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 attritutes_count 1
attribute_info attritutes attritutes_count
  • attribute_name_index:代表该属性的属性名称,是一项指向 CONSTANT_Uft8_info 型常量的索引,常量值固定为“Code”。
  • attribute_length:代表属性值的长度。
  • max_stack:代表操作数栈深度的最大值。
  • max_locals:代表局部变量表所需的存储空间。
  • code_length:代表字节码长度。
  • code:用于存储字节码指令的一系列字节流。

(2)Exceptions 属性

用于列举出方法中可能抛出的受查异常,也就是方法描述时在 throws 关键字后列举的异常。

Exceptions 属性表的结构:

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 number_of_exceptions 1
u2 exception_index_table number_of_exceptions

(3)...

三、字节码指令简介

Java 虚拟机的指令由一个操作码和零至多个操作数构成。由于 Java 虚拟机采用面向操作数栈而不是寄存器的架构,所有大多数指令都不包括操作数,只有一个操作码。但是大多数指令都包含了其操作所对应的数据类型信息。

如果不考虑异常处理,Java 虚拟机的解释器可以使用下面的伪代码当作最基本的执行模型来理解:

do {
自动计算 PC 寄存器的值加 1;
根据 PC 寄存器的指示位置,从字节码流中取出操作码;
if ( 字节码存在操作数 ) 从字节码流中取出操作数;
执行操作码所定义的操作;
}

对于大多数与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表示专门为哪种数据类型服务:i 代表对 int 类型的数据操作,l 代表 long,s 代表 short,b 代表 byte,c 代表 char,f 代表 float,d 代表 double,a 代表 reference。

1、加载和存储指令

加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,这类指令包括:

  • 将一个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。
  • 将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>。
  • 将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。
  • 扩充局部变量表的访问索引的指令:wide。

以上列举的指令助记符中,有一部分是以尖括号结尾的指令。这几组指令是带有一个操作数的通用指令(如 iload)的特殊形式,它们省略了显式的操作数,而是将操作数隐含在指令中。例如:iload_0 代表操作数为 0 的 iload 指令。

2、运算指令

运算或算术指令用于对两个操作数以上的值进行某种特定运算,并把结果重新存入到操作数栈顶。大体上算术指令可分为两种:对整型数据进行运算的指令和对浮点型数据进行运算的指令。所有的算术指令如下:

  • 加法指令:iadd、ladd、fadd、dadd。
  • 减法指令:isub、lsub、fsub、dsub。
  • 乘法指令:imul、lmul、fmul、dmul。
  • 除法指令:idiv、ldiv、fdiv、ddiv。
  • 求余指令:irem、lrem、frem、drem。
  • 取反指令:ineg、lneg、fneg、dneg。
  • 位移指令:ishl、ishr、iushr、lshl、lshr、lushr。
  • 按位或指令:ior、lor。
  • 按位与指令:iand、land。
  • 按位异或指令:ixor、lxor。
  • 局部变量自增指令:iinc。
  • 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp。

3、类型转换指令

类型转换指令可以将两种不同的数值类型进行相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。

Java 虚拟机直接支持(即转换时无需显示的转换指令)以下数值类型的宽化类型转换(小范围类型向大范围类型的安全转换):

  • int 到 long、float、double。
  • long 到 float、double。
  • float 到 double。

相对的,处理窄化类型转换时,必须显示地使用转换指令来完成,这些指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l、d2f。窄化类型转换可能会导致转换结果产生不同的正负号、不同的数量级的情况,转换过程很可能会导致数值的精度丢失。

4、对象创建与访问指令

虽然类实例和数组都是对象,但 Java 虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。相关指令如下:

  • 创建类实例的指令:new。
  • 创建数组的指令:newarray、anewarray、multianewarray。
  • 访问类字段和实例字段的指令:getstatic、putstatic、getfield、putfield。
  • 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload。
  • 将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore。
  • 取数组长度的指令:arraylength。
  • 检查类实例类型的指令:instanceof、checkcast。

5、操作数栈管理指令

Java 虚拟机提供了一些用于直接操作操作数栈的指令,包括:

  • 将操作数栈的栈顶一个或两个元素出栈:pop、pop2。
  • 复制栈顶一个或两个数值并将复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。
  • 将栈最顶端的两个数值互换:swap。

6、控制转移指令

控制转移指令可以让 Java 虚拟机有条件或无条件地从指定位置的指令继续执行程序,而不是从控制转移指令的下一条指令继续执行程序。从概念模型上理解,可认为控制转移指令就是在有条件或无条件地修改 PC 寄存器的值。控制转移指令如下:

  • 条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq、if_acmpne。
  • 复合条件分支:tableswitch、lookupswitch。
  • 无条件分支:goto、goto_w、jsr、jsr_w、ret。

7、方法调用和返回指令

方法调用指令与数据类型无关,包括:

  • invokevirtual 指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这是 Java 中最常见的方法分派方式。
  • invokeinterface 指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出合适的方法进行调用。
  • invokespecial 指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
  • invokestatic 指令:用于调用类方法。
  • invokedynamic 指令:用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。前 4 条调用指令的分派逻辑固化在 Java 虚拟机内部,而 invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的。

方法返回指令是根据返回值的类型区分的,包括:ireturn(用于返回值是 boolean、byte、char、short、int 的方法)、lreturn、freturn、dreturn、areturn、return(用于 void 方法、实例初始化方法、类和接口的类初始化方法)。

8、异常处理指令

Java 虚拟机中显式抛出异常的操作(throw 语句)都由 athrow 指令实现。而处理异常(catch 语句)则不是由字节码指令来实现的(很久之前曾经使用 jsr 和 ret 指令实现),而是采用异常表来完成。

9、同步指令

Java 虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。

方法级的同步是隐式的,即无须通过字节码指令来控制。虚拟机可以从方法访问标志 ACC_SYNCHRONIZED 得知一个方法是否声明为同步方法。如果方法访问标志 ACC_SYNCHRONIZED 被设置为 true,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成时释放管程。

同步一段指令集序列通常是由 synchronized 语句来表示的,Java 虚拟机的指令集中由 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字语义。