很遗憾,这将是很枯燥的一章,但是如果想较为深入的理解JVM,这一章又很有必要硬着头皮搞清楚。如果之前没有接触过类似的内容,那么有很大的可能第一次基本读不懂,如果出现这样的情况也没有关系,请继续保持学习,并且隔段时间再次重新阅读。像我这样不够灵光的脑袋,学习了3遍也就能够掌握基本原理。其实,只要掌握了对应的规则,类文件的内容又是很容易解读的,请保持你的耐心与好奇
Java号称跨平台,那么究竟是什么能够使Java跨平台?简单来说就是两点:第一是编译器能够将源代码编译成某种平台无关的格式;第二是能够将该种格式翻译成具体平台指令集的虚拟机
而这种平台无关的格式就是字节码。虚拟机不与包括Java语言在内的任何语言绑定,它只与字节码关联。因此也就诞生了后续众多基于JVM的新型语言
完整的类文件结构说明请参考“官网文档:The class File Format”
类文件结构
1.数据组织方式:紧凑的二进制
类文件是一组以字节为基础的二进制数据,各数据项严格按照定义排列,中间没有分隔符及填充。如果遇到8位以上的数据项时,则按照大端法(Big-Endian,关于大端法可参考“理解字节序 - 阮一峰的网络日志”)拆分成若干个字节存储
2.数据类型:无符号数和表
#无符号数,用来描述数字、索引或者UTF-8编码的字符串值。u1、u2、u4、u8分别代表1个字节、2个字节、4个字节、8个字节的无符号数
#表,由多个无符号数或其他表组合成复合数据结构。Class文件本质上就是一张表
3.多个同类数据项的描述:前置容量
由于类文件不采用分隔符的方式分隔数据,数据项的顺序是被严格限定的,因此当需要描述多个同类数据项的时候,采用前置容量计数器的方式
类文件结构定义详见下图:
类文件结构详解
通过上面的讲解,我们对类文件结构有了一个宏观的了解。接下来,我们通过一个简单的类文件实例,来深入细节具体看一下类文件结构
首先,我们定义一个足够简单的Java类,详见下图:
之后将该类编译后,通过十六进制方式查看TestClass.class文件。看着像乱码?然而并不是
另外我们还可以通过javap命令,查看该文件的反汇编信息
1.魔数(magic)
魔数用来描述文件类型,是一个u4类型的数据(占据类文件的头4个字节)
Java类文件的魔数值是0xCAFEBABE,看到这个是不是想起了Java的商标(咖啡)
使用魔数来表示文件类型,显然比使用文件扩展名更加安全。虚拟机在读取到0xCAFEBABE后则认为该文件是一个Class文件
2.版本号(version)
紧接着的4个字节代表的是类文件的版本号,其中前两个字节代表次版本号(Minor Version),后两个字节代表主版本号(Major Version)。通过版本号,虚拟机能够检查是否可兼容该类文件
查看十六进制类文件,看到次版本号是0x0000(十进制0),主版本号是0x0034(十进制52),我本地使用的编译器版本是1.8.0。具体编译器版本对应的十进制版本号请自行查阅,不在此赘述
3.常量池(constant_pool)
紧接着版本号的是常量池,常量池是类文件中第一个表类型的数据,其中数据项众多,并且数量不定。前面我们说过,对于描述多个同类数据项的时候,采用前置容量计数器的方式。因此在常量池之前,是一个u2类型的数据,代表常量池容量计数(constant_pool_count)
查看TestClass类文件,常量池容量计数值是0x0016(十进制22),代表该类有21项常量,索引范围是1-21(注意从1开始,而不是0)
常量池主要存放两大类数据:字面量和符号引用
#字面量比较接近于Java语言层面常量的概念,比如字符串、声明为final的常量值等
#符号引用主要包括:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。Java代码编译时,没有“静态连接”这一步骤,而是通过“动态连接”的方式。虚拟机在运行时,从常量池中获取对应的符号引用,再翻译到具体的内存地址
常量池中每一各数据项都对应一个表,一共有如下这些类型(14种),其中每一项开头都包含一个u1类型的tag(下图Value列),代表当前数据项代表的常量数据类型
下面我们继续使用TestClass作为例子,看看常量池中数据是怎样定义的:
首先我们看到的tag值是0x0a(十进制10),查阅上表,看到对应的是CONSTANT_Methodref_info,说明该常数项是方法的符号引用。我们看一下CONSTANT_Methodref_info的数据定义:
第一项是tag,上面说过了。第二项是class_index,代表拥有此方法的类的类信息在常量池中的索引。第三项是name_and_type_index,代表该方法的名称和描述符信息在常量池中的索引
查看我们的类文件,class_index值是0x0004,说明常量池中第4项存放该类的类信息。name_and_type_index值是0x0012(十进制18),说明常量池中第18项存放名称和描述符信息
另外,从上面提到的反汇编信息中,也可以更加明确地看出我们从十六进制类文件中分析出的内容
上面,我们通过查阅CONSTANT_Methodref_info的数据定义,并且对照十六进制类文件和反汇编信息,学习了怎样读懂类文件结构中的常量池信息。其实其他类型的常量和CONSTANT_Methodref_info一样,都是类似的结构。下图中选中的部分就是常量池相关的数据,有兴趣可以按照上述的方法对照官方文档逐一进行解析
4.访问标志(access_flags)
在常量池之后,紧接着的两个字节表示访问标志。这个标志用于识别一些类或者接口层次的访问信息。包括:该Class是否是public类型、是否被声明为final、是否是一个接口、是否是注解、是否是枚举等
完整定义如下:
其中ACC_SUPER代表是否允许invokespecial指令的新语义。invokespecial在JDK 1.0.2版本发生过改变,因此为了区分这条指令使用哪种语意,JDK 1.0.2之后该标志位都为0x0020。对于1.8及以上版本,无论该标志位是否被设置,JVM都会统一认为该标志位为真
我们实例中的TestClass,仅被定义为public,并且我当前使用的是1.8版本的JDK,因此ACC_PUBLIC及ACC_SUPER会被设置,其他标志位都为0。最终访问标志位的值会被设置为0x0001 | 0x0020 = 0x0021
5.类索引(this_class)、父类索引(super_class)、接口索引集合(interfaces)
类文件中通过这三项信息来确定这个类的继承关系。其中类索引和父类索引都是u2类型的数据,接口索引集合是一组u2类型的数据(接口索引前会有一个u2类型数据表示接口索引的数量constant_pool_count)。这三类数据都指向常量池中的某项数据
类索引用来确定该类的全限定名;父类索引确定其父类的全限定名。Java是单继承,所以父类索引只有一个;接口索引集合用来描述该类实现了哪些接口
继续看我们的TestClass,类索引值为0x0003(十进制3),说明类信息在常量池的第三项,结合反汇编代码,可以看到“class_structure/TestClass”
父类索引值为0x0004(十进制4),说明父类信息在常量池的第四项,结合反汇编代码,可以看到TestClass继承自“java/lang/Object”
接口索引的数量值为0x0000(十进制0),说明该类并没有实现任何接口
6.字段表集合(fields)
字段表集合用于描述类中的变量(包括静态变量、实例变量,但不包括局部变量)
每个字段通过一个field_info描述,field_info格式定义如下:
field_info中的access_flags作用及计算方式与类的access_flags类似,详细定义如下:
access_flags之后是name_index和descriptor_index,分别代表字段的简单名称索引及描述符索引,他们都是对常量池中常量的引用。之后是attributes方面的内容,后面再做介绍
下面来看一下TestClass,fields_count值为0x0001(十进制1),代表只有一个字段(private int m;);access_flags值为0x0002(十进制2),对照上面的access_flags定义表,发现只有ACC_PRIVATE为真,所以值为0x0002;name_index值为0x0005(十进制5),说明字段的简单名称引用常量池中第5项;descriptor_index值为0x0006(十进制6),说明字段的描述符引用常量池中第6项(反汇编代码常量第6项的“I”代表基本类型int);attributes_count值为0x0000(十进制0),说明没有额外属性
7.方法表集合(methods)
顾名思义,方法表集合用于描述类中的方法
如果理解了上一节的字段表集合,那么方法表集合就很好理解了,因为method_info在结构上与field_info极其类似。每个方法都通过一个method_info来描述
同样,第一项是access_flags,详细定义如下:
后续几项:name_index、descriptor_index、attributes含义都与字段表中类似,只不过在方法表中这些字段用于描述方法而已
也许你会有所疑问:方法里面的代码在哪里?方法里面的代码,存放在属性表集合中一个名为“Code”的属性里。关于属性表的内容,后面我们再做讲解
继续回到TestClass,methods_count值为0x0002(十进制2),代表有两个方法(其中一个是编译器自动添加的实例构造器<init>,另一个是我们自己定义的public int inc()方法);第一个方法的access_flags值为0x0001(十进制1),对照上面的access_flags定义表,发现只有ACC_PUBLIC为真,所以值为0x0001;name_index值为0x0007(十进制7),说明方法名称引用常量池中第5项;descriptor_index值为0x0008(十进制8),说明方法的描述符引用常量池中第8项(反汇编代码常量池第8项的“()V”代表void方法);attributes_count值为0x0001(十进制1),说明该方法的属性表集合有一项属性,索引为0x0009(十进制9),对应常量池中第9项常量为“Code”,说明此属性是方法的字节码描述
8.属性表集合(attributes)
前面在讲解类文件、字段表、方法表时曾多次出现属性表这个概念,它的主要作用是用于描述某些场景下的专有信息
截止到java 8,属性表集合中一共预定义了23种属性,下面我们拿一些属性作为例子进行讲解,完整的介绍请参看官方文档
对于每个属性,属性的名称(attribute_name_index)引用常量池中的常量,属性值(info)的结构完全自定义,只需要一个u4类型的长度属性(attribute_length)来说明属性值占用的字节数
#Code属性
前面在介绍方法表的时候曾提到过Code属性,其用于存储方法体中编译后的内容。但并非所有方法表都存在这个属性,比如接口和抽象类中的抽象方法。Code属性结构如下:
1)attribute_name_index和attribute_length上面已经讲过
2)max_stack代表操作数栈的最大深度,虚拟机需要根据这个值来分配栈帧中操作数栈的深度
3)max_locals代表了局部变量表所需的存储空间。max_locals的单位是slot,slot是虚拟机为局部变量分配内存的最小单位。对于32位的数据类型(byte、char、short、int、float、boolean、returnAddress),每个局部变量占用一个slot,而对于64位的数据类型(long、double)则需要占用两个slot。另外,max_locals的值并不是方法中定义了多少个局部变量,就把相应占用的slot数量简单相加。原因在于,当代码执行超出了某个变量的作用域之后,它所占用的slot就可以被其他的局部变量所占用,因此slot实际是可以复用的。编译器会根据作用域给本地变量分配slot,然后计算出max_locals的值
4)code_length和code用来存储编译器编译后的字节码指令(类似于汇编指令)。code_length代表字节码的长度,code则是一系列字节码流。每个字节码指令占用一个字节,当虚拟机读取到code中的一个字节,会根据字节码指令表找到对应的指令,并且可以知道这条指令后面是否会跟随参数,以及参数数量和具体含义。1个字节取值范围是0x00(十进制0)~0xFF(十进制255),也就是说一共可以表示256种指令
下面我们再次通过我们的TestClass来看一下code_length和code是如何定义的。首先code_length值为0x00000005(十进制5),说明后续五个字节是code
code中第一个字节值是0x2A,查表得知对应指令为aload_0,该指令含义是将第0个slot中的引用类型的本地变量推入操作数栈顶
code中第二个字节值是0xB7,查表得知对应指令为invokespecial,该指令含义是将操作数栈顶的引用数据所指向的对象作为方法接收者,调用该对象的实例构造方法、private方法或者他父类的方法
code中第三和第四个字节值是0x0001(十进制1),这个u2类型的数据是前面invokespecial指令的参数,它指向常量池中第一个常量,代表具体调用哪个方法。查看反汇编代码,可以看到对应的是“java/lang/Object."<init>":()V”,代表调用父类Object的实例构造方法
code中第五个字节值是0xB1,查表得知对应指令为return,含义是从当前方法返回void,这条指令执行后方法结束
我们再次查看TestClass的反汇编代码,看到其中两个方法(实例构造方法和inc方法)args_size的值都是1,但是这两个方法实际上都是无参的。另外无论是参数列表还是方法体内,都没有定义任何局部变量,但是locals也都是1。这是因为,在实例方法内,我们可以通过this关键字访问此方法所属的对象,而this正是通过编译器在方法调用时通过方法参数自动传入的。如果inc方法是static的,那么args_size就是0了
5)exception_table_length和exception_table用来描述异常处理信息。exception_table中一共包含4项信息,含义是:如果在start_pc到end_pc(不含)位置出现了类型为catch_type(包含其子类)的异常,则转向handler_pc处进行处理
#Exceptions属性
这里的Exceptions属性与上面讲到的Code属性里的exception_table不是一回事儿,这里的Exceptions属性与Code属性平级,代表该方法可能抛出的checked异常
Exceptions属性中的number_of_exceptions表示方法可能抛出的checked异常的数量。exception_index_table指向常量池中的常量,表示异常类型
#LineNumberTable属性
LineNumberTable属性用于描述源代码行号与字节码偏移量之间的对应关系。它虽然不是运行时必须的属性,但是如果没有相应的信息,那么程序抛出异常时,异常堆栈中将没有行号,另外也无法按照源码行来设置断点
#LocalVariableTable属性
LocalVariableTable属性用于描述栈帧中局部变量表中变量与源码中变量的关系。它也不是运行时必须的属性,但是如果没有相应信息,那么当其他人引用方法时,源码中定义的参数名称都将丢失,取而代之的是类似arg0、arg1这样的的占位符
#Signature属性
Signature属性出现于类、字段表、方法表结构的属性中,用于JDK1.5之后,记录范型信息。之所以加入一个属性记录范型信息,是因为Java中范型采用的是擦除法实现的伪范型。在字节码中,范型信息会被擦除,优点是实现简单(主要修改编译器,虚拟机很少改动),但缺点就是运行期间无法获得范型信息。Signature属性就是为了弥补这个缺陷增设的
行文至此,我想类文件的结构原理已经基本描述清楚了,其余没有讲到的结构也都是类似,如果有兴趣或者日后有需要用到,可以再做详细的了解
笔记3结束