第六章
6.2 无关性的基石
实现语言无关性的基础仍然是虚拟机和字节码存储格式。java虚拟机不和包括java在内的任何语言绑定,它只和“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了java虚拟机指令集和符号表以及若干其他辅助信息。-
6.3 Class类文件的结构
注意:任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。
class文件是一组8位字节为基础的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符。当遇到需要占用8位字节以上空间的数据项目时,则会按照高位在前的方式分割成若干个8为字节来存储。
Class文件格式采用一种类型C语言结构体的伪结构来存储数据,这种伪结构只有两种数据类型:无符号数和表。无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数、无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时称这一系列的某一类型的数据为某一类型的集合。
- 魔数与Class文件的版本
每个Class文件的头4个字节称为魔数,值为0xCAFEBABE。紧接着魔数的4个字节存储的是Class文件的版本号:5 6 字节是次版本号,7 8 字节是主版本号。 -
常量池
在版本号之后的是常量池入口,常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目。
由于常量池中常量的数目是不固定的,所以在常量池的入口需要防止一项u2类型的数据,代表常量池容量计数器。索引值范围为1~21。0代表不引用任何一个常量池项目的含义。
常量池中主要存放两大类常量:字面量和符号引用。字面量比较接近于java语言层面的常量概念,如文本字符串、final常量值等。而符号引用则属于编译原理方面的概念,包括了下面三类常量。- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
java代码在编译后,并不像c和c++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时运行解析、翻译到具体的内存地址之中。在JDK1.7中总共14种表,如图所示:
比如,我们发现第一个常量表为CONSTANT_Class_info,代表类或接口的符号引用,那么表中的name_index指向了CONSTANT_Utf8_info类型,而这个表就存储着我们这个类的全限定包名。常量池中的14种常量项的结构如图所示:
- 访问标志
常量池结束后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否声明为final等,如图所示:
access_flags一共有16个标志位可以使用,但当前只定义了8个,没用的标志位一律为0。 - 类索引、父类索引与接口索引集合
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件由这三项数据来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。父类索引只有一个,除了java.lang.Object外,所有的java类的父类索引都不为0。接口索引集合就用来描述这个类实现了哪些接口。这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。
类索引、父类索引和接口索引集合都按照顺序排列在访问标志之后,类索引和父类索引都用两个u2类型的索引值标识,它们各自指向一个类型为CONSTANT_Class_info的类描述符常量,通过CONSTANT_Class_info类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串。对于接口索引集合,入口的第一项——u2类型的数据为接口计数器,标识索引表的容量。如果该类没有实现任何接口,则该计数器值为0,后面接口的索引表不再占用任何字节。 - 字段表集合
字段表(field_info)用于描述接口或者类中声明的变量。字段(field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。字段表的格式如图:
字段修饰符放在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。它们都是对常量池的引用,分别代表着字段的简单名称以及字段和方法的描述符。
简单名称就是这个字段或者方法的名称。描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型以及无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示,如图:
对于一个数组,每一维度将使用一个前置的“[”字符来描述。描述符在描述方法时,按照先参数列表,后返回值的顺序描述。参数列表按照参数的严格顺序放在一组小括号“()”之内。如方法void inc()的描述符为“()V”,方法java.lang.String toString()的描述符为“()Ljava/lang/String”。
字段表都包含的固定数据项目到descriptor_index为止就结束了,不过在descriptor_index后面还有一个属性表用于存储一些额外的信息,字段都可以在属性表中描述零至多项的额外信息。
字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本java代码中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。 - 方法表集合
结构与字段表几乎完全一致,如图:
access_flags有改变,如图:
方法里的java代码,经过编译器编译成字节码指令后,存放在方法属性表集合中的一个名为“Code”的属性里面。
与字段表集合相对应的,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来字父亲的方法信息。但同样的,有可能出现由编译器自动添加的方法,最典型的就是类构造器“”方法和实例构造器“”方法。
在java语言中,要重载一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不包含在特征签名中,所以java中不允许有名称相同,参数相同而返回类型不同的方法存在。但是在class文件中,特征签名包括了返回值,所以上述两种方法是可以共存在class文件中的。 - 属性表集合
属性表用于描述某些场景转悠的信息,虚拟机规范中预定义的属性如图:
对于每一个属性,他的名称需要从常量池中引用一个CONSTANT_Utf_8_info类型的常量标识,而属性值的结构则是完全定义的。- Code属性
java程序方法体中的代码经过javac编译器处理后,最终变为字节码指令存储在Code属性内。Code属性出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性,如接口或者抽象类的方法就不存在Code属性,如图所示:
max_stack代表了操作数栈深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配帧栈中操作数栈的深度。
max_locals代表了局部变量表所需的存储空间。
code_length和code用来存储java源程序编译后生成的字节码指令。code是用来存储字节码指令的一系列字节流。
code_length虽然是一个u4类型的长度只,但是虚拟机规范中明确限制了一个方法不允许超过65535条字节码指令。
Code属性用于描述代码,所有的其他数据项目都用与描述元数据。 - Exceptions的作用是列举出方法中可能抛出的受查异常。也就是方法描述时在throws关键字后面列举出的异常。
- LineNumberTable属性用于描述java源码行号与字节码行号(字节码的偏移量)之间的对应关系。
- LocalVariableTable属性用于描述栈帧中局部变量表中的变量与java源码中定义的变量之间的关系,它也不是运行时必须的属性,但默认会生成到Class文件之中。
- SourceFile属性用于记录生成这个Class文件的源码文件名称。
- ConstantValue属性的作用是通知虚拟机自动为静态变量赋值。
- InnerClasses属性用于记录内部类与宿主类之间的关联。如果一个类定义了内部类,那编译器将会为它以及它所包含的内部类生成InnerClasses属性。
- Deprecated及Synthetic属性都属于标志类型的布尔属性,只存在有没有的区别,Deprecated用于标识某个类、字段或者方法、已经被程序坐着定为不再推荐使用。Synthetic属性代表此字段或者方法并不是由java源码直接生成的,而是编译器自行添加的。
- StackMapTable属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器使用。
- Signature属性用于记录泛型签名信息。
- BootstrapMethods属性用于保存invokedynamic指令引用的引导方法限定符。
- Code属性
- 魔数与Class文件的版本
-
字节码指令简介
java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(操作码)以及跟随其后的零至多个代表此操作所需参数(操作数)而构成。由于Java虚拟机采用面向操作数栈而不是寄存器结构,所以大多数的指令都不包含操作数,只有一个操作码。- 字节码与数据类型
在java虚拟机的指令集中,大多数的指令都包括了其操作所对应的数据类型信息。例如,iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。这两条指令的操作在虚拟机内部可能是由同一段代码来实现的,但在Glass文件中他们必须拥有各自独立的操作码。
大部分的指令都没有完整的支持所有的基本数据类型,因为在虚拟机内部会将有些未实现的需求转化成已有的指令来实现相同的功能,这样就简化了指令集。 - 加载和存储指令
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。 - 运算指令
用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入操作栈顶。 - 类型转换指令
可以将两种不同的数值类型进行相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理本节开篇所提到的字节码指令集中无法与数据类型一一对应的问题。 - 对象创建与访问指令
虽然类实例和数据都是对象,但java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素。指令如下:- 创建类实例的指令:new
- 创建数组的指令:newarray、anewarray、multianewarray。
- 访问类字段(static字段,或者称为类变量)和实例字段(非static字段)的指令:getfield putfield getstatic putfield;
- 把一个数组元素加载到操作数栈的指令:baload, caload, saload, iaload…..
- 把一个操作数栈的值存储到数组元素中:bastore , castore。。。
- 取数组长度的指令: arraylength
- 检查类实例类型的指令:instanceof, checkcast
- 操作数栈管理指令
如同操作普通的栈一样,
出栈,复制栈顶,交换栈顶数据等。。 - 控制转移指令
可以让虚拟机从指定的位置运行,而不是一行行的运行。 - 方法调用和返回指令
- invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这是java中最常见的方法分派方式。
- invokeinterface指令用于调用接口方法,它会在运行时期搜寻一个实现了该接口方法的对象,找出适合的方法来调用。
- invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法,父类方法和私有方法。
- invokestatic指令用于调用类方法(static静态方法)。
- invokedynamic指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。
方法调用指令与方法返回值类型无关,而方法返回指令是根据返回值的类型来区分的,包括ireturn等。。
- 异常处理指令
在java虚拟机中显式的抛出异常都是通过athrow指令来实现,而且java虚拟机还规定了许多情景下会自动抛出异常。如除数为0.
在java虚拟机中,处理异常(catch语句)不是由字节码指令来完成了(以前是jsr和ret指令),现在是用异常表来完成。 - 同步指令
java中的同步机制是通过管程(Monitor)来支持的。
方法级的同步是隐式的,即无需通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机从方法常量池中的方法表结构中的ACC_SYNCHORONIZED访问标志得知一个方法是否为同步方法。当方法调用时,指令先会检查ACC_SYNCHORONIZED是否被设置。如果设置了,执行线程要求先持有管程,才能执行方法, 当方法完成时(无论是正常结束还是非正常结束)都要释放管程。如果执行线程持有了管程,那么其他任何线程都无法再获得同一个管程。如果一个持有管程的方法在运行期间抛出了异常,并且在方法内部无法处理这个异常,那么方法将在将异常抛出的同时释放持有的管程。
java虚拟机中使用monitorenter和monitorexit来支持synchornized关键词的语义。
- 字节码与数据类型