深入理解JVM-类文件结构

551 阅读17分钟

6.1. Class类文件结构

任何一个Class文件都对应一个类或接口的定义,但是类或接口并不一定定义在文件中,因为动态代理的存在,类或接口可以被动态地生成。

Class是一组以8个字节为基础单位的二进制流,各个数据项目紧密排列,如果某个项目需要大于8字节,则会按照高位在前的方式进行分割。

Class文件采用类似结构体的方式进行组织和排列数据。不过它只有两种数据类型:无符号数和表。其中,表可以由无符号数和表组成。

  • 无符号数有四种:u1,u2,u3,u8,分别对应1个字节,2个字节,4个字节,8个字节的无符号数。无符号数可以用来描述很多东西,甚至UTF-8编码的字符串。

  • 表由多个无符号数或者无符号数加表构成,为了命名统一,表的名称以"_info"结尾

整个Class文件其实可以看成一张表。里面包含的数据项如下所示:

类型名称数量
u4magic(魔数)1
u2minor_version1
u2major_version1
u2constant_pool_count(常量池数量)1
constant_pool_info(常量池表)constant_pool(常量池)constant_pool_count - 1
u2access_flags(访问标识)1
u2this_class(此类全限定名)1
u2super_class(父类全限定名)1
u2interfaces_count(实现的接口数量)1
u2interfaces(接口全限定名)interfaces_count
u2fields_count(私有域(字段)数量)1
field_info(字段表)fields(私有域)fields_count
u2methods_count(方法数量)1
method_info(方法表)methods(方法)methods_count
u2attributes_count(属性数量)1
attribute_info(属性表)attributes(属性)attributes_count

无论是无符号数还是表,如果有多个且数量不定时,可以使用一个数字+连续若干的同类型的数据来描述,类似数组,称为“集合”。

6.1.1. 魔数和文件版本

魔数用来确定文件类型,JVM通过Class文件魔数判断这个文件是不是Class文件,魔数占4个字节,也就是起始4字节。

魔数后面就是版本号,小版本和大版本各占2字节,Class文件只能被更高版本的JVM执行,这也是版本号的作用。

6.1.2. 常量池

常量池在Class文件中异常重要,可以把它理解成仓库,它负责记录各种字符串,各种类型信息等。后面每种数据的名称,说明,解释,签名的字符串格式,都是常量池记录的。

由于常量池数量不固定,所以会在常量池开头处放置一个u2类型的数据,代表里面的常量数量。但是这个常量从1开始(仅这个是的),所以实际数量是常量池数量 - 1。0被保留是为了表示不引用,如果某个表的常量池索引 = 0,代表这个表不引用常量池的任何一项。

常量池主要存放两个类型的数据字面量和符号引用,前者类似文本字符串,或者被声明为final的常量值。后者属于编译原理方面的,是需要JVM在类加载时载入符号引用,然后在类创建或运行时做链接的,这样才能把符号引用解析到真实的内存地址。

常量池中的每一项都是一个表。来看看所有的表。

类型标志描述
CONSTANT_Utf8_info1UTF-8编码的字符串
CONSTANT_Integer_info3整型字面量
CONSTANT_Float_info4浮点型字面量
CONSTANT_Long_info5长整型字面量
CONSTANT_Double_info6双精度浮点型字面量
CONSTANT_Class_info7类或接口的符号引用
CONSTANT_String_info8字符串类型字面量
CONSTANT_Fieldref_info9字段的符号引用
CONSTANT_Methodref_info10类中方法的符号引用
CONSTANT_InterfaceMethodref_info11接口中方法的符号引用
CONSTANT_NameAndType_info12字段或方法的部分符号引用
CONSTANT_MethodHandle_info15方法句柄
CONSTANT_MethodType_info16方法类型
CONSTANT_Dynamic_info17动态计算常量
CONSTANT_InvokeDynamic_info18动态方法调用点
CONSTANT_Module_info19模块
CONSTANT_Package_info20模块中开放或者导出的包

这17种常量类型之间没有什么联系和共性,所以常量表比较繁琐。

常量池中的字符串,字面量使用的是CONSTANT_Utf8_info来保存的,所以对字符串的索引就是对保存了这个字符串的CONSTANT_Utf8_info的索引。

6.1.2.1. UTF-8

CONSTANT_Utf8_info类型的组成:

类型名称数量
u1tag1
u2length1
u1byteslength

tag就是标志的意思,length指出这个表存放的UTF-8编码的字符串的长度,单位是字节。注意到length类型为u2,所以常量池支持的字符串最大就是64KB,如果方法或字段名的文本内容大于这个,就会无法编译。

常量池使用的是缩略编码表示的字符串。

6.1.2.2. Integer

类型名称数量
u1tag1
u4bytes按照高位在前存储int值

6.1.2.3. Float

类型名称数量
u1tag1
u4bytes按照高位在前存储float值

6.1.2.4. Long

类型名称数量
u1tag1
u8bytes按照高位在前存储long值

6.1.2.5. Double

类型名称数量
u1tag1
u8bytes按照高位在前存储double值

6.1.2.6. Class

类型名称数量
u1tag1
u2index全限定名在常量池中的索引

6.1.2.7. String

类型名称数量
u1tag1
u2index字符串字面量在常量池中的索引

6.1.2.8. Fieldref_info

类型名称数量
u1tag1
u2index声明了这个字段的类或接口的CONSTANT_Class_info在常量池中的索引
u2index字段描述符CONSTANT_NameAndType_info在常量池中的索引

6.1.2.9. Methodref_info

类型名称数量
u1tag1
u2index声明了这个方法的类的CONSTANT_Class_info在常量池中的索引
u2index名称及字段描述符CONSTANT_NameAndType_info在常量池中的索引

6.1.2.10. InterfaceMethodref_info

类型名称数量
u1tag1
u2index声明了这个方法的接口的CONSTANT_Class_info在常量池中的索引
u2index名称及字段描述符CONSTANT_NameAndType_info在常量池中的索引

6.1.2.11. NameAndType_info

类型名称数量
u1tag1
u2index该字段或方法的名称的字面量在常量池中的索引
u2index该字段或方法的描述符的字面量在常量池中的索引

6.1.2.12. MethodHandle_info

类型名称数量
u1tag1
u1reference_kind决定了方法句柄的类型
u2reference_index对常量池的索引

6.1.2.13. MethodType_info

类型名称数量
u1tag1
u2descriptor_index必须是常量池中CONSTANT_Utf8_info类型的数据的索引

6.1.2.14. Dynamic_info

类型名称数量
u1tag1
u2descriptor_index

6.1.2.15. InvokeDynamic_info

类型名称数量
u1tag1
u2index

6.1.2.16. Module_info

类型名称数量
u1tag1
u2index

6.1.2.17. Package_info

类型名称数量
u1tag1
u2index

6.1.3. 访问标志

在常量池结束后,紧跟着的是2个字节的访问标识,指出这个Class是类还是接口,是public还是private,以及是否是抽象类,是否是final。

来看看所有的访问标识:

标志名称标志值说明
ACC_PUBLIC0x0001是否是public
ACC_FINAL0x0010是否是final,仅限类
ACC_SUPER0x0020是否允许使用invokeespecial新语义,1.0.2之后默认为true
ACC_INTERFACE0x0200是否是接口
ACC_ABSTRACT0x0400是否是抽象类,除了接口和抽象类,都是false
ACC_SYNTHETIC0x1000是否是用户代码生成,true表示机器生成
ACC_ANNOTATION0x2000是否是注解
ACC_ENUM0x4000是否是枚举
ACC_MODULE0x8000是否是模块

Class文件的访问标志是由上述表的标志值组合计算得到的。

6.1.4. 类 父类 与接口的索引集合

类索引:this_class,父类索引:super_class,接口索引:interfaces;都是u2类型的数据,其中,interfaces是一组u2类型的数据,前面有个u2类型的count指出interfaces的数量。

Class文件依靠这三项获取该类型的继承关系。类索引确定类的全限定名,父类索引确定父类全限定名,由于Java不允许多继承,所以除了java.lang.Object之外,所有Java类的父类索引均不为0。接口索引则根据接口实现顺序指出这个类实现(或接口扩展)了哪些接口。如果某个类没有实现接口,则接口count = 0。

6.1.5. 字段表

Java中的字段包括类级字段以及实例级字段。字段可以包含很多东西,比如字段名,字段可见性,字段是否final或者static等。所以只能通过字面值常量索引来记录。

一个字段表如下所示:

类型名称数量
u2access_flags1
u2name_index1
u2descriptor_index1
u2attributes_count1
attribute_infoattributesattributes_count

access_flags是一个值,这个值可以由它的访问标志计算得出,包括访问标志之间的组合得到的值,同Class文件的访问标志计算。和上面提到的类的访问标识很像。name_index是简单名称的索引,descriptor_index是对描述符的索引。

简单名称很好理解,就是字段名和方法名,但是描述符就略显复杂。描述符用于描述字段的数据类型,方法的参数列表和返回。

来看看描述符定义的规则:

标识字符含义
Bbyte
Cchar
Ddouble
Ffloat
Iint
Jlong
Sshort
Zboolean
Vvoid
L对象

对于数组类型,要是用一个前置的'['来表示。

描述符描述方法时,按照先参数列表,后返回值的顺序描述,参数列表严格按照参数顺序排放在"()"小括号内。描述符描述字段时,只是字段的类型,不带字段名;描述方法时,只是参数列表和返回值,不带方法名

字段表集合不会列出从父类或父接口中继承来的字段,但是可能出现原本不属于Java文件的字段,可能是JVM添加的。

6.1.6. 方法表

Class文件中方法表几乎和字段表一样,除了部分值:

类型名称数量
u2access_flags1
u2name_index1
u2descriptor_index1
u2attributes_count1
attribute_infoattributesattributes_count

因为volatile和transient不能修饰方法,所以access_flags没有了这两个标志。相应的,多了synchronized,native,strictfp和abstract。

方法的访问标志,名称,描述符,都有了,代码在哪呢?

方法的代码,编译后成为的字节码指令存放在了方法表的Code属性里。

子类方法会出现父类中被重写的方法。对于方法重载,Class要求只要方法描述符不一样即可共存,这个要求比Java代码要求要宽泛一些。同时可能出现Java代码里没有的方法,比如类构造方法(<clinit>())和类初始化方法(<init>())。

6.1.7. 属性表

终于来到了前面一直提及的属性表,属性表包含太多的东西,以至于没发一一讲解,在此,仅描述那些重要的,常见的属性。

属性名称使用位置说明
Code方法表Java代码编译成的字节码
Exceptions方法表方法抛出的异常列表
ConstantValue字段表保存由final修饰的常量池
Deprecated类,方法表,字段表被声明为Deprecated的方法或字段
LineNumberTableCode属性Java源代码的行号与字节码指令的对应关系
LocalVariableTableCode属性方法的局部变量描述
StackMapTableCode属性供类型检查验证器校验目标方法的局部变量和操作数栈所需要的类型是否匹配
EnclosingMethod类文件仅用在局部类或匿名类,用来标识这个类所在的外围方法
InnerClass类文件内部类列表
Signature类,方法表,字段表记录范型信息,避免因类型擦出而导致签名混乱
SourceFile类文件记录源文件名称
Synthetic类,方法表,字段表标识字段或方法由编译器自动生成
LocalVariableTypeTable使用特征签名替代描述符,为了能够描述范型参数化类型而设置
SourceDebugExtension类文件提供额外的调试信息
TODO...TODO...TODO...

对于任何一个属性,它的名称都是对常量池的索引,然后一个属性长度n,接下来是n个字节的属性信息。所以对于一个属性,其组成是很随意的,并没有什么很多的限制。

来看属性的构成:

类型名称数量
u2attribute_name_index1
u4attribute_length1
u1infoattribute_length

6.1.7.1. Code

Code属性是用来记录方法的代码的,但是并非所有方法都有这个属性,比如接口或抽象类中的方法。

因为所有属性的通性,所以第一行省略name_index和length。

N/AN/AN/A
u2max_stack1
u2max_locals1
u4code_length1
u1code(字节码)code_length
u2exception_table_length1
exception_infoexception_tableexception_table_length
u2attributes_count1
attribute_infoattributesattributes_count

如果使用属性通用结构来表示的话,那么Code表的attribute_length就是整个表长-6字节(名称索引和length一共占6字节)。

来看看各个属性的意思:

  • max_stack代表了操作数栈(作用类似于C中的寄存器,Java的执行单元通过操作数栈通信)深度的最大值。
  • max_locals代表了局部变量表所需的存储空间。在这里,max_locals的单位是槽。方法参数(也包括隐式参数this),显式异常处理程序的参数(就是catch (XXXException e) 中的e),方法体中的局部变量都需要局部变量表来存放。操作数栈和局部变量表直接决定一个方法所耗费的内存。为了节约内存,JVM会复用变量槽,具体做法是:在方法执行的一个局部变量超出了方法领域时,会复用这个变量的槽给其他局部变量使用(因为此时这个变量的值存在于另一个方法或字段中了,于是没有必要继续为它保留空间),Javac会根据每个变量的作用域来为它们分配变量槽,并以同时生存的变量的最大值作为变量槽最大值
  • code_length和code用来存储编译后的字节码指令,字节码之所以称为字节码,因为每个指令大小都是1字节。每次读到一个字节码,JVM就知道它是什么指令,如何解析,它的操作数是跟在后面的(下划线形式)还是去局部变量表或操作数栈获取。JVM限制了指令最大值为2^16-1,而不是u4,所以如果指令数大于这个,JVM会拒绝执行。对于this的处理,是把对this的访问变成对一个普通变量的访问,然后在JVM调用实例方法时,自动传入此参数。
  • 异常处理表用来记录在字节码第m-n行之间的e类型的异常,应该跳转到第h行处理。如果e = 0,则在m-n之间的不论什么异常都应该跳转到h行处理。JVM明确规定对于异常的处理应该使用异常表而不是跳转指令来实现。

exception_info:

类型名称数量
u2start_pc1
u2end_pc1
u2handler_pc1
u2catch_type1

6.1.7.2. Exceptions

Exceptions是方法表中与Code同级的属性,它用来记录方法中可能抛出的受查异常,所以和Code里面的异常表不一样。来看看Exceptions结构组成:

类型名称数量
N/AN/AN/A
u2number_of_exceptions1
u2exception_index_tablenumber_of_exceptions

其中,exception_index_table是对常量池中CONSTANT_Class_info的索引。代表了异常类型。

6.1.7.3. ConstantValue

ConstantValue的作用是通知虚拟机自动为静态变量赋值。对于实例变量的赋值,过程发生在实例构造器<init>()中。而对于类变量,有两个选择,一个是类构造器<clinit>(),另一个就是ConstantValue。如果同时使用final和static来修饰一个变量,且数据类型是基本数据类型或String时,会使用ConstantValue来进行初始化,否则就是类初始化。

6.2. 字节码指令

Java使用面向操作数栈而不是寄存器的架构,所以字节码指令一般不包含操作数,操作数包含在操作数栈中。

由于Class文件放弃了操作数长度对齐,就要求JVM在运行时要从字节中重构出具体的数据结构。但是优点就是可以缩减存储空间,完成更大的传输效率。

6.2.1. 字节码与数据类型

大多数指令都包括其操作数据的类型信息,比如iload和dload分别对应加载int和加载double。虽然在虚拟机内部可能是同一段代码实现的,但是在Class文件中,它们要有所不同。

另外,不可能每一个指令都有类型独立性,为了解决这个问题,可能会把某些类型的操作转换成另一种类型来进行操作,比如加载char,byte,short,boolean的数据,可以调用int的加载指令来实现。

6.2.2. 加载和存储指令

此类指令用于在局部变量表和操作数栈之间传输数据。

6.2.3.运算指令

算术指令用于对操作数栈上的两个值进行运算,并把结果入栈。

运算指令只有除法指令和求余指令中出现除数为0时才会导致异常。对于浮点数处理,要遵守IEEE754规范中的行为。同时,JVM在进行浮点数运算时,不会抛出任何异常。

6.2.4. 类型转换指令

类型转换指令用于实现数值类型之间的转换。对于宽化类型转换(小范围类型转换到大范围类型),则可以直接执行。对于窄化类型转换,则可能造成精度丢失,因为这是截断,保留的是大范围类型的低N位,这点同C语言。

6.2.5. 对象创建与访问指令

虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素,这些指令包括:

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

6.2.6. 操作数栈管理指令

JVM提供了一些用于操作数栈的指令,这样可以进行相应的操作。

  • 将操作数栈的栈顶一个或两个元素出栈:pop、pop2。
  • 复制栈顶一个或两个数值(栈的第一个或前两个值)并将复制值或双份的复制值(复制值可能是栈顶上的两个或一个)重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。
  • 将栈最顶端的两个数值互换:swap。

6.2.7. 控制转移指令

控制转移指令用于把程序跳转到指定位置的下一条指令执行。在JVM中有专门的指令集用来处理int和reference类型的条件分支比较操作,同时也有专门的指令用来检测null值。

一般来说,各种类型的比较操作最终都会转换成int型的比较操作,JVM对int型提供的分支跳转比较操作是最丰富强大的。

6.2.8. 方法调用和返回指令

方法调用指令,详见第8章。

方法调用指令与数据类型无关,但是方法返回指令却因返回值类型而异。包括ireturn(当返回值是boolean、by te、char、short和int类型时使用)、lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法、类和接口的类初始化方法使用。

6.2.9. 异常处理指令

在Java程序中,显式抛出异常的操作,都由athrow指令实现。在JVM中,处理异常使用异常处理表来实现。

6.2.10. 同步指令

JVM可以支持方法级以及方法内部某一段指令的同步,这对应了synchronized关键字(锁类另算,那玩意使用CAS+AQS实现的,前者是CPU支持,后者是一个抽象类)。JVM会在程序离开同步块或在异常抛出到同步块之外(就是同步块里面出现了异常)时自动释放锁。

monitorenter和monitorexit这两条指令负责实现synchronized语义。