(深入理解 Java虚拟机)一篇文章带你深入了解类文件结构

一、字节码是无关性的基石

如果全世界所有计算机的指令集就只有x86一种,操作系统就只有Windows一种,那也许就不会有Java语言的出现。Java在刚刚诞生之时曾经提出过一个非常著名的宣传口号一次编写,到处运行(Write Once,Run Anywhere,这句话充分表达了当时软件开发人员对冲破平台界限的渴求。“与平台无关”的理想最终只有实现在操作系统以上的应用层:Oracle公司以及其他虚拟机发行商发布过许多可以运行在各种不同硬件平台和操作系统上的Java虚拟机,这些虚拟机都可以载入和执行同一种平台无关的字节码,从而实现了程序的“一次编写,到处运行”。

各种不同平台的Java虚拟机,以及所有平台都统一支持的程序存储格式 - 字节码(Byte Code)是构成平台无关性的基石,但本节标题中笔者刻意省略了“平台”二字,语言无关性正在越来越被开发者所重视。直到今天,或许还有相当一部分程序员认为Java虚拟机执行Java程序是一件理所当然和天经地义的事情。但在Java技术发展之初,设计者们就曾经考虑过并实现了让其他语言运行在Java虚拟机之上的可能性,他们在发布规范文档的时候,也刻意把Java的规范拆分成了《Java语言规范》(The Java LanguageSpecification)及《Java虚拟机规范》(The Java Virtual MachineSpecification)两部分。并且早在1997年发表的第一版《Java虚拟机规范》中就曾经承诺过:“在未来,我们会对Java虚拟机进行适当的扩展,以便更好地支持其他语言运行于Java虚拟机之上”。Java虚拟机发展到今天,尤其是在2018年,基于HotSpot扩展而来的GraalVM公开之后,当年的虚拟机设计者们已经基本兑现了这个承诺。

实现语言无关性的基础仍然是虚拟机和字节码存储格式。Java虚拟机不与包括Java语言在内的任何程序语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集、符号表以及若干其他辅助信息
基于安全方面的考虑,《Java虚拟机规范》中要求在Class文件必须应用许多强制性的语法和结构化约束,但图灵完备的字节码格式,保证了任意一门功能性语言都可以表示为一个能被Java虚拟机所接受的有效的Class文件。作为一个通用的、与机器无关的执行平台,任何其他语言的实现者都可以将Java虚拟机作为他们语言的运行基础,以Class文件作为他们产品的交付媒介。例如,使用Java编译器可以把Java代码编译为存储字节码的Class文件,使用JRuby等其他语言的编译器一样可以把它们的源程序代码编译成Class文件。虚拟机丝毫不关心Class的来源是什么语言,它与程序语言之间的关系如图6-1所示。

在这里插入图片描述
Java语言中的各种语法、关键字、常量变量和运算符号的语义最终都会由多条字节码指令组合来表达,这决定了字节码指令所能提供的语言描述能力必须比Java语言本身更加强大才行。因此,有一些Java语言本身无法有效支持的语言特性并不代表在字节码中也无法有效表达出来,这为其他程序语言实现一些有别于Java的语言特性提供了发挥空间

二、Class 类文件的结构

Java技术能够一直保持着非常良好的向后兼容性,Class文件结构的稳定功不可没,任何一门程序语言能够获得商业上的成功,都不可能去做升级版本后,旧版本编译的产品就不再能够运行这种事情。本章所讲述的关于Class文件结构的内容,绝大部分都是在第一版的《Java虚拟机规范》(1997年发布,对应于JDK 1.2时代的Java虚拟机)中就已经定义好的,内容虽然古老,但时至今日,Java发展经历了十余个大版本、无数小更新,那时定义的Class文件格式的各项细节几乎没有出现任何改变。尽管不同版本的《Java虚拟机规范》对Class文件格式进行了几次更新,但基本上只是在原有结构基础上新增内容、扩充功能,并未对已定义的内容做出修改。

任何一个Class文件都对应着唯一的一个类或接口的定义信息,但是反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中)。本章中只是通俗地将任意一个有效的类或接口所应当满足的格式称为“Class文件格式”,实际上它完全不需要以磁盘文件的形式存在。

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

  • 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
  • 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上也可以视作是一张表,这张表由所示的数据项按严格顺序排列构成。
    在这里插入图片描述
    无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时候称这一系列连续的某一类型的数据为某一类型的“集合”。Class的结构不像XML等描述语言,由于它没有任何分隔符号,所以在表6-1中的数据项,无论是顺序还是数量,甚至于数据存储的字节序(Byte Ordering,Class文件中字节序为Big-Endian)这样的细节,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,全部都不允许改变

1. 魔数与 Class 文件的版本

每个Class文件的头4个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。不仅是Class文件,很多文件格式标准中都有使用魔数来进行身份识别的习惯,譬如图片格式,如GIF或者JPEG等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全考虑,因为文件扩展名可以随意改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过而且不会引起混淆。Class文件的魔数取得很有“浪漫气息”,值为0xCAFEBABE(咖啡宝贝?)。这个魔数值在Java还被称作“Oak”语言的时候(大约是1991年前后)就已经确定下来了。它还有一段很有趣的历史,据Java开发小组最初的关键成员Patrick Naughton所说:“我们一直在寻找一些好玩的、容易记忆的东西,选择0xCAFEBABE是因为它象征着著名咖啡品牌Peet’s Coffee深受欢迎的Baristas咖啡。”这个魔数似乎也预示着日后“Java”这个商标名称的出现。

紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1(JDK1.0~1.1使用了45.0~45.3的版本号),高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,因为《Java虚拟机规范》在Class文件校验部分明确要求了即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。

在这里插入图片描述

图6-2显示的是使用十六进制编辑器WinHex打开这个Class文件的结果,可以清楚地看见开头4个字节的十六进制表示是0xCAFEBABE,代表次版本号的第5个和第6个字节值为0x0000,而主版本号的值为0x0032,也即是十进制的50,该版本号说明这个是可以被JDK 6或以上版本虚拟机执行的Class文件。

2. 常量池

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

由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。与Java中语言习惯不同,这个容量计数是从1而不是0开始的,如图6-3所示,常量池容量(偏移地址:0x00000008)为十六进制数0x0016,即十进制的22,这就代表常量池中有21项常量,索引值范围为1~21。在Class文件格式规范制定之时,设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于,如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,可以把索引值设置为0来表示。Class文件结构中只有常量池的容量计数是从1开始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都与一般习惯相同,是从0开始。

在这里插入图片描述
常量池中主要存放两大类常量:字面量(Literal)和符号引用(SymbolicReferences)。字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,主要包括下面几类常量:

被模块导出或者开放的包(Package)
类和接口的全限定名(Fully Qualified Name)
字段的名称和描述符(Descriptor)
方法的名称和描述符
方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)

Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接,也就是说,在Class文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中

常量池中每一项常量都是一个表,最初常量表中共有11种结构各不相同的表结构数据,后来为了更好地支持动态语言调用,额外增加了4种动态语言相关的常量,为了支持Java模块化系统(Jigsaw),又加入了CONSTANT_Module_info和CONSTANT_Package_info两个常量,所以截至JDK 13,常量表中分别有17种不同类型的常量。

这17类表都有一个共同的特点,(也就是第9位对应16进制中的A:0x07)表结构起始的第一位是个u1类型的标志位(tag,取值见表6-3中标志列),代表着当前常量属于哪种常量类型。17种常量类型所代表的具体含义如表6-3所示。

在这里插入图片描述

之所以说常量池是最烦琐的数据,是因为这17种常量类型各自有着完全独立的数据结构,两两之间并没有什么共性和联系,因此只能逐项进行讲解。

在这里插入图片描述
请看看图6-3中常量池的第一项常量,它的标志位(偏移地址:0x0000000A)是0x07,查表6-3的标志列可知这个常量属于CONSTANT_Class_info类型,此类型的常量代表一个类或者接口的符号引用。CONSTANT_Class_info的结构比较简单,如表6-4所示。

在这里插入图片描述
tag是标志位,它用于区分常量类型;name_index是常量池的索引值,它指向常量池中一个CONSTANT_Utf8_info类型常量,此常量代表了这个类(或者接口)的全限定名,本例中的name_index值(偏移地址:0x0000000B)为0x0002,也就是指向了常量池中的第二项常量。继续从图6-3中查找第二项常量,它的标志位(地址:0x0000000D)是0x01,查表6-3可知确实是一个CONSTANT_Utf8_info类型的常量。CONSTANT_Utf8_info类型的结构如表6-5所示。

在这里插入图片描述
length值说明了这个UTF-8编码的字符串长度是多少字节,它后面紧跟着的长度为length字节的连续数据是一个使用UTF-8缩略编码表示的字符串。UTF-8缩略编码与普通UTF-8编码的区别是:从’\u0001’到’\u007f’之间的字符(相当于1~127的ASCII码)的缩略编码使用一个字节表示,从’\u0080’到’\u07ff’之间的所有字符的缩略编码用两个字节表示,从’\u0800’开始到’\uffff’之间的所有字符的缩略编码就按照普通UTF-8编码规则使用三个字节表示。

顺便提一下,由于Class文件中方法、字段等都需要引用CONSTANT_Utf8_info型常量来描述名称,所以CONSTANT_Utf8_info型常量的最大长度也就是Java中方法、字段名的最大长度。而这里的最大长度就是length的最大值,既u2类型能表达的最大值65535。所以Java程序中如果定义了超过64KB英文字符的变量或方法名,即使规则和全部字符都是合法的,也会无法编译。本例中这个字符串的length值(偏移地址:0x0000000E)为0x001D,也就是长29个字节,往后29个字节正好都在1~127的ASCII码范围以内。

到此为止,我们仅仅分析了TestClass.class常量池中21个常量中的两个,还未提到的其余19个常量都可以通过类似的方法逐一计算出来,为了避免计算过程占用过多的版面篇幅,后续的19个常量的计算过程就不手工去做了,而借助计算机软件来帮忙完成。在JDK的bin目录中,Oracle公司已经为我们准备好一个专门用于分析Class文件字节码的工具:javap。代码清单6-2中列出了使用javap工具的-verbose参数输出的TestClass.class文件字节码内容:
在这里插入图片描述
从代码清单6-2中可以看到,计算机已经帮我们把整个常量池的21项常量都计算了出来,并且第1、2项常量的计算结果与我们手工计算的结果完全一致。仔细看一下会发现,其中有些常量似乎从来没有在代码中出现过,如“I”“V”“”“LineNumberTable”“LocalVariableTable”等,这些看起来在源代码中不存在的常量是哪里来的?

这部分常量的确不来源于Java源代码,它们都是编译器自动生成的,会被后面即将讲到的字段表(field_info)、方法表(method_info)、属性表(attribute_info)所引用,它们将会被用来描述一些不方便使用“固定字节”进行表达的内容,譬如描述方法的返回值是什么,有几个参数,每个参数的类型是什么。因为Java中的“类”是无穷无尽的,无法通过简单的无符号数来描述一个方法用到了什么类,因此在描述方法的这些信息时,需要引用常量表中的符号引用进行表达。这部分内容将在后面进一步详细阐述。

3. 访问标志

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

5. 字段表集合

6. 方法表集合

7. 属性表集合

三、字节码指令简介

四、公有设计和私有实现

五、Class 文件结构的发展

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值