第六章 类文件结构

40 阅读14分钟

前言

Java 语言在诞生之初就以 “一次编写,到处运行” 作为宣传口号。一方面,Java 使用虚拟机来运行 Java 程序,向上屏蔽了硬件与操作系统细节,另一方面,使用 Java 编写的程序将被编译成格式严格统一的字节码存放在 “.class” 文件中,这使得运行在不同平台上的各种不同的 Java 虚拟机也都能运行相同的 Java 程序。

事实上,Java 虚拟机 + 字节码的组合不仅仅让 Java 语言运行在不同的平台上,图灵完备的字节码结构还使得任何其他功能性语言都可以被表示为能被 Java 虚拟机所接受的 Class 文件。目前 Java 虚拟机已经支持 Kotlin、JRuby、Scale 等语言。

Class 类文件结构

Class 文件是一组以 8 个字节为基础单位的二进制流,其中各个数据项目按照顺序紧凑的排列在文件中,并且中间没有空隙存在。当遇到某一项需要使用 8 个字节以上的空间时,会按照高位在前(大端存储)的方式分割成若干个 8 个字节进行存储。

Class 文件使用一种类似于 C 语言结构体的伪结构来存储数据,其中包含两种数据类型——无符号数与表

  • 无符号数属于基本数据类型,以 u1、u2、u4、u8 分别代表 1 个字节、2 个字节、4 个字节与 8 个字节的无符号数。无符号数通常可以用来描述数字、引用索引、数量值或按照 UTF-8 编码构成的字符串值。
  • 表是由多个无符号数或其他表作为数据项组成的复合数据结构,命名通常以 "_info" 结尾。

整个 Class 文件也可以看作是一张表,由以下的数据项按照顺序严格排列。当某一数据类型有多个数据但数量不定时,常常使用一个前置的无符号数作为容量计数器

类型名称数量
u4magic1
u2minor_version1
u2major_version1
u2constant_pool_count1
cp_infoconstant_poolconstant_pool_count -1
u2access_flags1
u2this_class1
u2super_class1
u2interfaces_count1
u2interfacesinterfaces_count
u2fields_count1
field_infofieldsfields_count
u2methods_count1
method_infomethodsmethods_count
u2attributes_count1
attribute_infoattributesattributes_count

接下来将根据上表的顺序,对各个类型信息进行具体介绍。

魔数与 Class 文件的版本

所有 Class 文件的前四个字节都被称为魔数(cafebaby),它的唯一作用是标识该文件是一个可以被 JVM 运行的 Class 文件。

魔数后面紧接着的两个字节(第5、6个字节)是次版本号,再后面两个字节(第7、8个字节)是主版本号。 JDK1.1 使用的主版本号为 45,之后的每个版本依次向上加 1。JDK 版本向下兼容,但不向上兼容。在 Class 文件校验过程中即使文件格式未发生任何变化,虚拟机也会拒绝执行超过其版本的 Class 文件。

以下面这段测试代码 A 为例,我们来分析一下它对应的字节码中的魔数与版本号:

public class ClassTest {
    private int m;

    public int inc(){
        return m + 1;
    }
}

javac ClassTest.java 命令编译成字节码文件后,用 javap -v ClassTest.class 命令反编译得到内容。其中关于版本号的部分如下:

image.png

常量池

在主版本号之后存放着常量池的入口。常量池可以比喻为Class文件的资源仓库,它是 Class 文件中与其他项目关联最多的数据,通常也是占用 Class 文件空间最大的数据项之一。

常量池中常量的数量通常是不固定,因此在常量池的入口处需放置一个 u2 类型的无符号数(第9、10个字节)来记录常量的数量(constant_pool_count)。常量池的索引从 1 开始,即 constant_pool_count = 19 表示常量池中有 18 个常量。

常量池中主要存放两大类常量:字面量和符号引用。字面量即文本字符串、final常量等等,而符号引用则主要包括以下几类:

  • 被模块导出或开放的包
  • 类和接口全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符
  • 方法句柄和方法类型
  • 动态调用点和动态常量

常量池中每一个常量都是一个表,例如 Constant_Uft8_info 型常量有以下的结构:

类型名称数量
u1tag1
u2length1
u1byteslength

其中 tag 标识了常量类型(如整型字面量、UTF8 编码的字符串、类或接口的符号引用等等),length 标识字符串长度,bytes 则是字符串的具体编码。

最后我们将通过代码 A得到的常量池提供于此,后面的很多内容都会涉及到这个常量池:

image.png

访问标志

在常量池之后紧接着的两个字节是访问标志(access_flag),该标志用于标识类或接口的访问信息,包括:该 Class 表示类还是接口、是否为 public、是否为 Abstract 类型、是否被声明为 final 等。具体的访问标志如下:

标志名称标志值含义
ACC_PUBLIC0x0001标识是否为 public
ACC_FINAL0x0010是否被声明为 final,仅类可用
ACC_SUPER0x0020是否允许使用 invokespecial 字节码指令的新语义,JDK1.0.2 后该标志均为真
ACC_INTERFACE0x0200标识这是一个接口
ACC_ABSTRACT0x0400是否是一个 Abstract 类型,抽象类与接口该标志均为真
ACC_SYNTHETIC0x1000标识这个类并非由用户代码产生
ACC_ANNOTATION0x2000标识这是注解
ACC_EMUM0x4000标识这是枚举
ACC_MODULE0x8000标识这是模块

代码 A 中的类被声明为 public、super,其他标志位为 0,因此他的访问标志位为 0x0001 | 0x0020 = 0x0021,在反编译文件中也可以看到:

image.png

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

类索引(this_class)与父类索引(super_class)都是 u2 类型的无符号数;紧随其后的是一组 u2 类型的数据的集合,表示接口索引。在 Class 文件中由这三项来确定该类型的继承关系。类索引用于确定该类的全限定名父类索引用来确定其父类的全限定名,由于 Java 不允许多继承,因此父类索引只有一项并且,由于 java.lang.Object 是所有类的根类,因此除了 Object 之外所有的类的父类索引均不为 0接口索引用于确定该类实现的接口,由于一个类可以实现多个接口,接口索引由一个接口索引集合来表示

对于接口索引集合,其在入口处的第一项为 u2 类型的接口计数器(interfaces_count),用来记录实现的接口数量(与常量池类似)。若该类没有实现任何接口,那么 interfaces_count 值为 0x0000,后面也就不存在接口的索引表。

代码 A 的反编译文件中关于这几项如下:

image.png

结合常量池可以知道,this_class 对应的 #8 是一个 Class 类型符号引用,它表示的字面量是 #10,即 org / example / ClassTest,其他几项同理。

字段表集合

字段表用于描述接口或类中声明的变量。要注意的是 Java 中的字段包括类变量和实例变量,但不包括方法内部声明的局部变量。与接口索引集合类似,在字段表集合的入口处使用了一个 u2 类型的数据描述了字段表的个数。下表列出了一个字段表的结构(一个字段表即一个变量):

类型名称数量说明
u2access_flags1字段访问标志
u2name_index1对常量池的引用,并且该常量应当是一个 CONSTANT_Utf8_info 结构的常量,表示字段的简单名称
u2descriptor_index1对常量池的引用,并且该常量应当是一个 CONSTANT_Utf8_info 结构的常量,表示字段的描述符,用来确定数据类型
u2attributes_count1字段属性数量
attribute_infoattributesattributes_count字段属性

字段表中的 access_flags 和类中的 access_flags 很类似,它表示的标志位如下:

标志名称标志值含义
ACC_PUBLIC0x0001表明字段是否是 public
ACC_PRIVATE0x0002表明字段是否是 private
ACC_PROTECTED0x0004表明字段是否是 protected
ACC_STATIC0x0008表明字段是否是 static
ACC_FINAL0x0010表明字段是否是 final
ACC_VOLATILE0x0040表明字段是否是 volatile
ACC_TRANSIENT0x0080表明字段是否是 transient
ACC_SYSTHETIC0x1000表明字段是否是由编译器自动产生
ACC_EMUM0x4000表明字段是否是 emum

access_flags 后面跟着的是字段的简单名称描述符,它们都是对常量池的引用。简单名称很好理解,例如代码 A 中的 inc() 方法和 m 字段是简单名称分别为 inc 和 m。描述符会麻烦一些,对于基本类型,用大写字母表示,如 I 表示 int;对于对象类型,用 L 加对象的全限定名来表示,如 Ljava/lang/String 表示 String 类型。另外对于数组类型,每一个维度用一个前置 "[" 表示。综上,整型数组 int[] 将被表示为 [I,而字符串数组 String[][] 将被表示为 [[Ljava/lang/String

常量池中的 #9 表示了字段 m 的简单名称和描述符,即 m:I

image.png

至于最后的属性表 attribute_info 可以记录一些额外信息。本例的 m 没有额外信息需要记录,但如果将其改为 final static int m = 123;,就可能会存一项指向常量 123 的 ConstantValue 属性。

方法表集合

方法表的结构和字段表几乎一致,

类型名称数量说明
u2access_flags1字段访问标志
u2name_index1对常量池的引用,并且该常量应当是一个 CONSTANT_Utf8_info 结构的常量,表示方法的简单名称
u2descriptor_index1对常量池的引用,并且该常量应当是一个 CONSTANT_Utf8_info 结构的常量,表示方法的描述符,用来确定参数和返回值类型
u2attributes_count1方法属性数量
attribute_infoattributesattributes_count方法属性

和字段的 access_flag 相比,方法中删去了 volatile 和 transient,添加了 synchronized、native、abstract 等等,在此不再赘述。

ACC_SYNCHRONIZED 关系到 synchronized 关键字的原理,将在后文涉及。

方法的描述符和字段描述符很类似,只是需要将参数列表按照顺序放在一组小括号内,并在最后加上返回值类型。例如 String[] method(char[] c,int so, int sc, char[][] t) 对应的描述符为 ([CII[[C)[Ljava/lang/String

代码 A 共有两个方法,分别是编译器添加的实例构造器 <init> 和自定义方法 inc()

image.png

方法对应的代码存放在属性表集合中一个名为 “Code” 的属性里面。

属性表集合

属性表在前面出现了多次,方法、字段都可以携带自己的属性表。属性有很多,这里挑选几个常见的进行介绍。

Code

除了接口、抽象类中的方法不存在 Code 属性,其他方法表中的 Code 属性结构如下:

类型名称数量
u2attribute_name_index1
u4attribute_length1
u2max_stack1
u2max_locals1
u4code_length1
u1codecode_length
u2exception_table_length1
exception_infoexception_tableexception_table_length
u2attribute_count1
attribute_infoattributesattribute_count

max_stack 代表了操作数栈深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度. 虚拟机运行的时候需要根据这个值来分配栈帧(Stack Frame)中的操作栈深度

max_locals 代表了局部变量表所需的存储空间。在这里,max_locals 的单位是 slot 槽,具体见第二章 part1。

并不是在方法中用到了多少局部变量,就把这些局部变量所占用 slot 个数之和作为 max_locals 的值,原因是局部变量表中的 slot 槽可以重用。当代码执行超出一个局部变量的作用域时,这个局部变量所占用的 slot 可以被其它局部变量所使用。Javac 编译器会根据变量的作用域来分配 slot 给各个变量使用,并计算出 max_locals 的大小。

code_length 和 code 用来存储字节码指令的长度和字节流。

对于超过 65525 条字节码指令的代码,Javac 编译器会拒绝编译。

之后是方法的显式异常处理表集合,非必须。异常表格式包含四个字段,表示当字节码从第 start 行到第 end 行(不包含 end)之间出现了类型为 catch 或其子类的异常,则转到 handle 行继续处理。

类型名称数量
u2start1
u2end1
u2catch1
u2handle1

Exceptions

Exception 属性的作用是列举出方法的声明异常,也就是 throws 关键字后列举的异常,不要与 Code 中的异常表混淆。

LocalVariableTable

LocalVariableTable 属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间关系。属性结构如下:

类型名称数量
u2attribute_name_index1
u4attribute_length1
u2local_variable_table_length1
local_variable_infolocal_variable_tablelocal_variable_table_lengt

其中 local_variable_info 代表了不同时刻(代码行)定义的局部变量在局部变量表中的位置,它的结构如下:

类型名称数量
u2start_pc1
u2length1
u2name_index1
u2descriptor_index1
u2index1
  • start_pc 和 length 属性分别代表了这个局部变量的生命周期开始的字节码偏移量及其作用范围的覆盖长度。即:确定了这个局部变量的作用范围。

  • name_index 和 descriptor_index 都是指向常量池中 CONSTANT_Utf8_info 型常量的索引,分别代表了局部变量的名称以及这个局部变量的描述符。

  • index 是这个局部变量在栈帧局部变量表中 slot 的位置。如果这个局部变量是 64 位的,那么它占用的两个连续的 slot 的位置是 index 和 index+1。

可以用 -g:none 和 -g:vars 取消或要求生成这项信息。如果取消,那么常量池中不会有局部变量的名称和描述符,IDE 将用默认名(arg0、arg1)代替原有参数名;如果生成,常量池中就会多出来局部变量的名称和描述符

对于拥有 a、b、c 三个局部变量的代码生成的 LocalVariableTable 如下图所示:

image.png

补充:synchronized 同步指令

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

方法级的同步是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法表结构中的 ACC_SYNCHRONIZED 访问标志来声明同步方法。如果设置了,执行线程就要求先成功持有管程才能执行方法,执行完成后释放管程。

同步一段指令集序列通常是由 Java 语言中的 synchronized 语句来表示的,Java 虚拟机的指令集中有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义,正确实现 synchronized 关键字需要 Javac 编译器与 Java 虚拟机两者共同协作支持,譬如下述代码:

public class Test1{
    public static void main(String []args){
        synchronized(Test1.class){
            System.out.println("doSomething");
        }
    }
}

编译后,这段代码生成的字节码序列如下:

image.png

两个 monitorenter 指令分别对应方法正常结束和异常结束,保证管程的正常释放。