JVM学习笔记五:Class类文件结构

一、前言

在Java技术发展之初,Java虚拟机就被设定为不仅限于java语言运行,也可以支持其他语言(如:Kotlin、Clojure、Groovy、JRuby、JPython、Scala等)在其上运行。JVM发展至今,尤其是在2018年,基于HotSpot扩展而来的GraalVM公开之后,当年的虚拟机设计者们已经基本兑现了这个承诺。

 

 

实现语言无关性的基础在于虚拟机和字节码存储格式。Java虚拟机不与包括Java语言在内的任何程序语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集、符号表以及若干其他辅助信息。任何语言想要运行在JVM,只需遵循JVM的语法和结构化规则。语言的实现者可以将Java虚拟机作为他们语言的运行基础,以Class文件作为他们产品的交付媒介。

Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用8个字节以上空间的数据项时,则会按照高位在前[2]的方式分割成若干个8个字节进行存储。 根据《Java虚拟机规范》的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”和“表”。

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

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

 

 

二、魔数与Class文件的版本

每个Class文件的头4个字节被称为魔数(Magic Number),它的唯一作用是进行身份识别,确定这个文件是否为一个能被虚拟机接受的Class文件。该值可以由文件格式制定者自行定义,java的class文件魔数值为固定的0xCAFEBABE

魔数后4个字节存储的是class文件的版本号:第5,6字节是次版本号(0000),minor version为0,第7,8字节是主版本号(0034),major version = 52 ,对应版本JDK8。Java的版本号是从45开始的。高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,因为《Java虚拟机规范》在Class文件校验部分明确要求了即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class 文件。次版本号则是从JDK12开始启用,早前版本固定为零。因为12之后有一些复杂的新特性需要以“公测”的形式放出,所以设计者重新启用了副版本号,将它用于标识“技术预览版”功能特性的支持。

 

 

三、常量池

紧接着版本号之后的是常量池入口,常量池可以比喻为Class文件里的资源仓库。,它是Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一,另外,它还是在Class文件中第一个出现的表类型数据项目。

常量池容量计数值用于记录常量池中常量的数量,位于常量池入口。这个容量计数是从1而不是0开始的,常量池容量为十六进制数0x0016,即十进制的22,这就代表常量池中有21项常量,索引值范围为1~21。预留出的第0项常量是为了表示特定情况下,不引用任何一个常量池项目的数据可以将索引值设置为0。Class文件结构中只有常量池的容量计数从1开始。

常量池中主要存放两大类常量:

  • 字面量
  • 符号引用

字面量类似java语言层面的常量概念,和文本字符串、被声明为final的常量值等。

符号引用则属于编译原理的概念,主要包括以下几类常量:

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

常量池中每一项常量都是一个表,截至JDK13,常量表中分别有17种不同类型的常量。这17类表都有一个共同的特点,表结构起始的第一位是个u1类型的标志位,代表着当前常量属于哪种常量类型。以下为17种常量项的结构定义总结:

 

 

四、访问标志

在常量池之后的两个字节表示访问标志,这个标志用于识别一些类或是接口层次的访问信息。具体标志位及其含义如下:

 

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

在访问标志之后是类索引、父类索引和接口索引集合,前二者都是u2类型的数据,各自指向一个类型为CONSTANT_Class_info的类描述符常量,通过 CONSTANT_Class_info类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串。接口索引集合是一组u2类型的数据集合,Class文件汇总有这三项数据来确定该类型的继承关系。

类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。因为java不允许多重继承,所以除了java.lang.Object外,所有Java类的父类索引都不为0。

接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements/extends后的接口顺序从左到右排序在接口索引集合中。对于接口索引集合,入口的第一项u2类型的数据为接口计数器,表示索引表的容量,如果该类没有实现任何接口,则该计数器值为0,后面接口的索引表不再占用任何字节。

 

六、字段表集合

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

字段包括以下修饰符:

  • public、private、protected修饰符(有字段的作用域)
  • static修饰符(实例变量/类变量)
  • final修饰符(可变性)
  • volatile修饰符(并发可见性,是否强制从)
  • transient修饰符(可否被序列化)
  • 基本类型、对象、数组(子弹数据类型)
  • 字段名称

上述各个修饰符都是布尔值,适合使用标志位表示,而字段叫什么名,字段被定义为什么数据类型,这些是无法固定的,只能引用常量池中的常量来表示。字段表的最终格式如下图所示:

字段修饰符放在access_flags项目中,它与类中的access_flags项目是非常类似的,都是一个u2的数据类型,下图为可设置标志位及其含义:

很明显,由于语法规则的约束,ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED三个标志最多只能选择其一,ACC_FINAL、ACC_VOLATILE不能同时选择。接口之中的字段必须有ACC_PUBLIC、ACC_STATIC、ACC_FINAL标志,这些都是由Java本身的语言规则所导致的。

跟随access_flags标志的是两项索引值:name_index和descriptor_index。它们都是对常量池项的引用,分别代表着字段的简单名称以及字段和方法的描述符。现在需要解释一下“简单名称”“描述符”以及前面出现过多次的“全限定名”这三种特殊字符串的概念。

  • 全限定名:一个类的全限定名是将类全名的.全部替换为/,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“;”号表示全限定名结束。例:
src/main/java/platform/action/SampleClass
  • 简单名称:是指没有类型和参数修饰的方法或者字段名称

描述符的作用是用来描述字段 的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类 型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的void类型都用一个大 写字符来表示,而对象类型则用字符L加对象的全限定名来表示,详见下图:

对于数组类型,每一维度将使用一个前置的“[”字符来描述,如一个定义为“java.lang.String[][]”类型的二维数组将被记录成“[[Ljava/lang/String;”,一个整型数组“int[]”将被记录成“[I”。

用描述符来描述方法时,按照先参数列表、后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。如方法void inc()的描述符为“()V”,方法java.lang.String toString()的描述符为“()Ljava/lang/String;”,方法int indexOf(char[]source,int sourceOffset,int sourceCount,char[]target, int targetOffset,int targetCount,int fromIndex)的描述符为“([CII[CIII)I”。

字段表集合中不会列出从父类或者父接口中继承而来的字段,但有可能出现原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,编译器就会自动添加指向外部类实例的字段。另外,在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于Class文件格式来讲,只要两个字段的描述符不是完全相同,那字段重名就是合法的。

 

七、方法表集合

Class文件存储 格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,方法表的结构如同字段表一样,依次包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项,如下图所示。这些数据项目的含义也与字段表中的非常类似,仅在访问标 志和属性表集合的可选项中有所区别。

因为volatile关键字和transient关键字不能修饰方法,所以方法表的访问标志中没有了ACC_VOLATILE标志和ACC_TRANSIENT标志。与之相对,synchronized、native、strictfp和abstract关键字可以修饰方法,方法表的访问标志中也相应地增加了ACC_SYNCHRONIZED、 ACC_NATIVE、ACC_STRICTFP和ACC_ABSTRACT标志。对于方法表,所有标志位及其取值如下:

方法里的Java代码,经过Javac编译器编译成字节码指令之后,存放在方法属性表集合中一个名为“Code”的属性里面,属性表作为Class文件格式中最具扩展性的一种数据项目。

与字段表集合相对应地,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样地,有可能会出现由编译器自动添加的方法,最常见的便是类构造器“<clinit>()”方法和实例构造器“<init>()”方法。

在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求 必须拥有一个与原方法不同的特征签名。特征签名是指一个方法中各个参数在常量池中的字段符号引用的集合,也正是因为返回值不会包含在特征签名之中,所以Java语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。但是在Class文件格式之中,特征签名的范围明显要更大一些, 只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法有相同的名称和特征签 名,但返回值不同,那么也是可以合法共存于同一个Class文件中的。

 

八、属性表集合

与Class文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍微宽松一 些,不再要求各个属性表具有严格顺序,并且《Java虚拟机规范》允许只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不认识的属性。为了能正确解析Class文件,《Java虚拟机规范》最初只预定义了9项所有Java虚拟机实现都应当能识别的属性,而在最新的《Java虚拟机规范》的Java SE 12版本中,预定义属性已经增加到29项。

对于每一个属性,它的名称都要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,而属性值的结构则是完全自定义的,只需要通过一个u4的长度属性去说明属性值所占用的位数即可。

 

九、参考资料

《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》

 

注:

1.class文件格式的数据项,无论是顺序,数量还是数据存储的字节序等细节都是被严格限定的,都不允许被修改。

2.这种顺序称为“Big-Endian”,具体顺序是指按高位字节在地址最低位,最低字节在地址最高位来存储数据,它是SPARC、PowerPC等处理器的默认多字节存储顺序,而x86等处理器则是使用了相反的“Little-Endian”顺序来存储数据。

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值