6.深入理解java虚拟机--第三部分虚拟机执行子系统----类文件结构

6.1概述

记得在第一节计算机程序课上我的老师就讲过:“计算机只认识0和1,所以我们写的程序需要经编译器翻译成由0和1构成的二进制格式才能由计算机执行”。10多年时间过去了,今天的计算机仍然只能识别0和1,但由于最近10年内虚拟机以及大量建立在虚拟机之上的程序语言如雨后春笋般出现并蓬勃发展,将我们编写的程序编译成二进制本地机器码(Native Code)已不再是唯一的选择,越来越多的程序语言选择了与操作系统和机器指令集无关的、平台中立的格式作为程序编译后的存储格式

6.2 无关性的基石

各种不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码(ByteCode)是构成平台无关性的基石

实现语言无关性的基础仍然是虚拟机和字节码存储格式

Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。

6.3 Class类文件的结构

注意:任何一个class文件都一一对应着一个类或接口的信息,但反过来说,类和接口不一定都得定义在文件里(譬如类或接口可以直接由类加载器直接生成)

Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前[插图]的方式分割成若干个8位字节进行存储

根据Java虚拟机规范的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表,后面的解析都要以这两种数据类型为基础,所以这里要先介绍这两个概念。

无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。

扫描二维码关注公众号,回复: 9218513 查看本文章

表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表,它由表6-1所示的数据项构成。

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

本节结束之前,笔者需要再重复讲一下,Class的结构不像XML等描述语言,由于它没有任何分隔符号,所以在表6-1中的数据项,无论是顺序还是数量,甚至于数据存储的字节序(ByteOrdering,Class文件中字节序为Big-Endian)这样的细节,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。接下来我们将一起看看这个表中各个数据项的具体含义。

6.3.1 魔数与Class文件的版本

每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动

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

6.3.2 常量池

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

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括了下面三类常量:

  • 类和接口的全限定名(Fully Qualified Name)
  • 字段的名称和描述符(Descriptor)
  • 方法的名称和描述符\

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

6.3.3 访问标志

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

类索引、父类索引和接口索引集合都按顺序排列在访问标志之后

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。

6.3.5 字段表集合

字段表(field_info)用于描述接口或者类中声明的变量。

6.3.6 方法表集合

方法里的Java代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性里面

6.3.7 属性表集合

1. Code属性

Java程序方法体中的代码经过Javac编译器处理后,最终变为字节码指令存储在Code属性内。Code属性出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性,譬如接口或者抽象类中的方法就不存在Code属性

2. Exceptions属性

这里的Exceptions属性是在方法表中与Code属性平级的一项属性,读者不要与前面刚刚讲解完的异常表产生混淆。Exceptions属性的作用是列举出方法中可能抛出的受查异常(CheckedExcepitons),也就是方法描述时在throws关键字后面列举的异常。

3. LineNumberTable属性

LineNumberTable属性用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。它并不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中分别使用-g:none或-g:lines选项来取消或要求生成这项信息。如果选择不生成LineNumberTable属性,对程序运行产生的最主要的影响就是当抛出异常时,堆栈中将不会显示出错的行号,并且在调试程序的时候,也无法按照源码行来设置断点

4. LocalVariableTable属性

LocalVariableTable属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系,它也不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中分别使用-g:none或-g:vars选项来取消或要求生成这项信息。如果没有生成这项属性,最大的影响就是当其他人引用这个方法时,所有的参数名称都将会丢失,IDE将会使用诸如arg0、arg1之类的占位符代替原有的参数名,这对程序运行没有影响,但是会对代码编写带来较大不便,而且在调试期间无法根据参数名称从上下文中获得参数值。

5. SourceFile属性

SourceFile属性用于记录生成这个Class文件的源码文件名称。这个属性也是可选的,可以分别使用Javac的-g:none或-g:source选项来关闭或要求生成这项信息。在Java中,对于大多数的类来说,类名和文件名是一致的,但是有一些特殊情况(如内部类)例外。如果不生成这项属性,当抛出异常时,堆栈中将不会显示出错代码所属的文件名。

6. ConstantValue属性

ConstantValue属性的作用是通知虚拟机自动为静态变量赋值。只有被static关键字修饰的变量(类变量)才可以使用这项属性。类似“int x=123”和“static int x=123”这样的变量定义在Java程序中是非常常见的事情,但虚拟机对这两种变量赋值的方式和时刻都有所不同。对于非static类型的变量(也就是实例变量)的赋值是在实例构造器<init>方法中进行的;而对于类变量,则有两种方式可以选择:在类构造器<clinit>方法中或者使用ConstantValue属性。目前Sun Javac编译器的选择是:如果同时使用final和static来修饰一个变量(按照习惯,这里称“常量”更贴切),并且这个变量的数据类型是基本类型或者java.lang.String的话,就生成ConstantValue属性来进行初始化,如果这个变量没有被final修饰,或者并非基本类型及字符串,则将会选择在<clinit>方法中进行初始化。

7. InnerClasses属性

InnerClasses属性用于记录内部类与宿主类之间的关联。如果一个类中定义了内部类,那编译器将会为它以及它所包含的内部类生成InnerClasses属性

8. Deprecated及Synthetic属性

Deprecated和Synthetic两个属性都属于标志类型的布尔属性,只存在有和没有的区别,没有属性值的概念

Deprecated属性用于表示某个类、字段或者方法,已经被程序作者定为不再推荐使用,它可以通过在代码中使用@deprecated注释进行设置。

9. StackMapTable属性

StackMapTable属性在JDK 1.6发布后增加到了Class文件规范中,它是一个复杂的变长属性,位于Code属性的属性表中。这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器(Type Checker)使用(见7.3.2节),目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器。

10. Signature属性

Signature属性在JDK 1.5发布后增加到了Class文件规范之中,它是一个可选的定长属性,可以出现于类、属性表和方法表结构的属性表中。在JDK 1.5中大幅增强了Java语言的语法,在此之后,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types),则Signature属性会为它记录泛型签名信息。之所以要专门使用这样一个属性去记录泛型类型,是因为Java语言的泛型采用的是擦除法实现的伪泛型,在字节码(Code属性)中,泛型信息编译(类型变量、参数化类型)之后都通通被擦除掉。使用擦除法的好处是实现简单(主要修改Javac编译器,虚拟机内部只做了很少的改动)、非常容易实现Backport,运行期也能够节省一些类型所占的内存空间。但坏处是运行期就无法像C#等有真泛型支持的语言那样,将泛型类型与用户定义的普通类型同等对待,例如运行期做反射时无法获得到泛型信息。Signature属性就是为了弥补这个缺陷而增设的,现在Java的反射API能够获取泛型类型,最终的数据来源也就是这个属性.

11. BootstrapMethods属性

BootstrapMethods属性在JDK 1.7发布后增加到了Class文件规范之中,它是一个复杂的变长属性,位于类文件的属性表中。这个属性用于保存invokedynamic指令引用的引导方法限定符

6.4 字节码指令简介

Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。由于Java虚拟机采用面向操作数栈而不是寄存器的架构(这两种架构的区别和影响将在第8章中探讨),所以大多数的指令都不包含操作数,只有一个操作码。

字节码指令集是一种具有鲜明特点、优劣势都很突出的指令集架构,由于限制了Java虚拟机操作码的长度为一个字节(即0~255),这意味着指令集的操作码总数不可能超过256条;又由于Class文件格式放弃了编译后代码的操作数长度对齐,这就意味着虚拟机处理那些超过一个字节数据的时候,不得不在运行时从字节中重建出具体数据的结构,如果要将一个16位长度的无符号整数使用两个无符号字节存储起来(将它们命名为byte1和byte2),那它们的值应该是这样的:

这种操作在某种程度上会导致解释执行字节码时损失一些性能。但这样做的优势也非常明显,放弃了操作数长度对齐[插图],就意味着可以省略很多填充和间隔符号;用一个字节来代表操作码,也是为了尽可能获得短小精干的编译代码。这种追求尽可能小数据量、高传输效率的设计是由Java语言设计之初面向网络、智能家电的技术背景所决定的,并一直沿用至今。

6.4.1 字节码与数据类型

在Java虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。例如,iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但在Class文件中它们必须拥有各自独立的操作码。对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:i代表对int类型的数据操作,l代表long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference。也有一些指令的助记符中没有明确地指明操作类型的字母,如arraylength指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。还有另外一些指令,如无条件跳转指令goto则是与数据类型无关的。

6.4.2 加载和存储指令

加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈(见第2章关于内存区域的介绍)之间来回传输,这类指令包括如下内容。

[插图]将一个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。

[插图]将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>。

[插图]将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。

[插图]扩充局部变量表的访问索引的指令:wide。

6.4.3 运算指令

6.4.4 类型转换指令

类型转换指令可以将两种不同的数值类型进行相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作

6.4.5 对象创建与访问指令

虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令(在第7章会讲到数组和普通类的类型创建过程是不同的)。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素,这些指令如下。

6.4.6 操作数栈管理指令

如同操作一个普通数据结构中的堆栈那样,Java虚拟机提供了一些用于直接操作操作数栈的指令,包括:[插图]将操作数栈的栈顶一个或两个元素出栈:pop、pop2。[插图]复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。[插图]将栈最顶端的两个数值互换:swap。

6.4.7 控制转移指令

控制转移指令可以让Java虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,从概念模型上理解,可以认为控制转移指令就是在有条件或无条件地修改PC寄存器的值

6.4.8 方法调用和返回指令

6.4.9 异常处理指令

在Java程序中显式抛出异常的操作(throw语句)都由athrow指令来实现,除了用throw语句显式抛出异常情况之外,Java虚拟机规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。例如,在前面介绍的整数运算中,当除数为零时,虚拟机会在idiv或ldiv指令中抛出ArithmeticException异常。而在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的(很久之前曾经使用jsr和ret指令来实现,现在已经不用了),而是采用异常表来完成的。

6.4.10 同步指令

6.5 公有设计和私有实现

理解公有设计与私有实现之间的分界线是非常有必要的,Java虚拟机实现必须能够读取Class文件并精确实现包含在其中的Java虚拟机代码的语义。拿着Java虚拟机规范一成不变地逐字实现其中要求的内容当然是一种可行的途径,但一个优秀的虚拟机实现,在满足虚拟机规范的约束下对具体实现做出修改和优化也是完全可行的,并且虚拟机规范中明确鼓励实现者这样做。只要优化后Class文件依然可以被正确读取,并且包含在其中的语义能得到完整的保持,那实现者就可以选择任何方式去实现这些语义,虚拟机后台如何处理Class文件完全是实现者自己的事情,只要它在外部接口上看起来与规范描述的一致即可

精确定义的虚拟机和目标文件格式不应当对虚拟机实现者的创造性产生太多的限制,Java虚拟机应被设计成可以允许有众多不同的实现,并且各种实现可以在保持兼容性的同时提供不同的、新的、有趣的解决方案。

6.6 Class文件结构的发展

Class文件格式所具备的平台中立(不依赖于特定硬件及操作系统)、紧凑、稳定和可扩展的特点,是Java技术体系实现平台无关、语言无关两项特性的重要支柱。

发布了24 篇原创文章 · 获赞 1 · 访问量 3425

猜你喜欢

转载自blog.csdn.net/ashylya/article/details/104079461