虚拟机执行子系统
Posted 小路不懂2
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了虚拟机执行子系统相关的知识,希望对你有一定的参考价值。
类文件格式
大量建立在虚拟机之上的程序语言将编写的程序编译成二进制本地机器码(Native Code)已不再是唯一的选择,越来越多的程序语言选择了与操作系统和机器指令集无关的,平台中立的格式作为程序编译后的存储格式。
各种不同平台的虚拟机与所有平台都统一使用的程序存储格式--字节码(ByteCode)是构成平台无关性的基石。
实现语言无关性的基础仍然是虚拟机和字节码存储格式。Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集合符号表以及若干其他辅助信息。利用各种不同语言的编译器吧程序代码编译成Class文件,虚拟机并不关心Class的来源是何种语言。
Java语言中的各种变量,关键字和运算符的语义最终都是由多条字节码命令组合而成的,因此字节码命令所能提供的语义描述能力肯定会比Java语言本身更加强大。因此,有一些Java语言本身无法有效支持的语言特性不代表字节码本身无法有效支持,这也为其他语言实现一些有别于Java的语言特性提供了基础。
class类文件的结构
任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件中(类或接口也可以通过类加载器直接生成)
Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储。
根据Java虚拟机规范中的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。
- 无符号数属于基本的数据类型,以u1,u2,u4,u8来分别代表1字节,2字节,4字节,8字节的无符号数。可以用来描述数字,索引引用,数量值或安装UTF-8编码构成字符串值。
- 表是由多个无符号数或其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的符合结构的数据,整个Class文件本质上就是一张表。
无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时称这一些列连续的某一类型的数据为某一类型的集合。
类型 | 名称 | 数量 | 备注 |
u4 | magic | 1 | |
u2 | minor_version | 1 | |
u2 | major_version | 1 | |
u2 | constant_pool_count | 1 | |
cp_info | constant_pool | constant_pool_count - 1 | |
u2 | access_flags | 1 | |
u2 | this_calss | 1 | |
u2 | super_class | 1 | |
u2 | interfaces_count | 1 | |
u2 | interfaces | interfaces_count | |
u2 | fields_count | 1 | |
field_info | fields | fields_count | |
u2 | methods_count | 1 | |
method_info | methods | methods_count | |
u2 | attributes_count | 1 | |
attribute_info | attributes | attributes_count |
魔数与Class文件的版本
每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。Class文件的魔数值为:0xCAFEBABE。
注:很多文件存储标准中都使用魔数来进行身份识别,而不是扩展名来进行识别主要是基于安全方面的考量,因为文件扩展名可以随意地改动。
魔数之后的4个字节存储的是Class文件的版本号:5,6个字节是次版本号,7,8个字节是主版本号。高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。
为了方便分析,提供如下示例代码:
package com.huawei.jvm; public class Test03 { private int m; public int inc() { return m + 1; } }
使用十六进制编辑器WinHex打开这个Class文件的结果,如上图:开头4个字节的十六进制标示是0xCAFEBABE,次版本号0x0000,主版本号0x0034(十进制54,对应jdk1.8)。
使用javap -verbose Test可以查看相关信息:
常量池
版本号之后的是常量池入口,是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一。
由于常量池中常量的数量不是固定的,所以常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值,这个容量计数是从1而不是0开始的。
上图,常量池容量为0x0013,(十进制19)代表常量池中有19-1=18项常量,索引值范围是1~18。
常量池中国主要存放两个类常量:字面量和符号引用。
字面量比较接近于Java语言层面的常量概念,如文本字符串,声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
Java代码在进行javac编译的时候,并不想C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中国不会保存各个方法,字段的最终内存布局信息,而是这些字段,方法的符号引用不经过运行期转换的话无法获得真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析,翻译到具体的内存地址之中。
常量池中每一项常量都是一个表。目前有14种表,这些表有一个共同的特点,就是表开始的第一位是一个u1类型的标志位(tag)代表当前这个常量属于哪种常量类型。
类型 | 标志 | 描述 | 结构 |
CONSTANT_Utf8_info | 1 | UTF-8编码的字符串 |
tag(u1):标志,下同 length(u2):UTF-8编码的字符串占用的字节数 bytes(u1):长度为length的UTF-8编码的字符串 |
CONSTANT_Integer_info | 3 | 整型字面量 |
tag(u1) bytes(u4):按照高位在前存储的int值 |
CONSTANT_Float_info | 4 | 浮点型字面量 |
tag(u1) bytes(u4):按照高位在前存储的float值 |
CONSTANT_Long_info | 5 | 长整型字面量 |
tag(u1) bytes(u8):按照高位在前存储的long值 |
CONSTANT_Double_info | 6 | 双精度浮点类字面量 |
tag(u1) bytes(u8):按照高位在前存储的double值 |
CONSTANT_Class_info | 7 | 类或接口的符号引用 |
tag(u1) index(u2):指向全限定名常量项的索引 |
CONSTANT_String_info | 8 | 字符串类型字面量 |
tag(u1) index(u2):指向字符串字面量的索引 |
CONSTANT_Fieldref_info | 9 | 字段的符号引用 |
tag(u1) index(u2):指向声明字段的类或接口描述符CONSTANT_Class_info的索引项 index(u2):指向字段描述符CONSTANT_NameAndType的索引项 |
CONSTANT_Methodref_info | 10 | 类中方法的符号引用 |
tag(u1) index(u2):指向声明方法的类描述符CONSTANT_Class_info索引项 index(u2):指向名称及类型描述符CONSTANT_NameAndType的索引项 |
CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
tag(u1) index(u2):指向声明方法的接口描述符CONSTANT_Class_info的索引项 index(u2):指向名称及类型描述符CONSTANT_NameAndType的索引项 |
CONSTANT_NameAndType_info | 12 | 字段或方法的部分符号引用 |
tag(u1) index(u2):指向该字段或方法名称常量项的索引 index(u2):指向该字段或方法描述符常量项的索引 |
CONSTANT_MethodHandle_info | 15 | 标示方法句柄 |
tag(u1) reference_kind(u1):值必须在1~9之间,它决定了方法句柄的类型,方法句柄类型的值标示方法句柄的字节码行为 reference_index(u2):值必须是对常量池的有效索引 |
CONSTANT_MethodType_info | 16 | 标示方法类型 |
tag(u1) descriptor_index(u2):值必须是对常量池的有效索引,常量池在该索引处的项必须是CONSTANT_Utf8_info结构,标示方法的描述符 |
CONSTANT_InvokeDynamic_info | 18 | 标示一个冬天方法调用点 |
tag(u1) bootstrap_method_attr_index(u2):值必须是对当前Class文件中引导方法表的bootstrap_methods[]数组的是有效索引 name_and_type_index(u2):值必须是对当前常量池的有效索引,常量池在该索引处的项必须是CONSTANT_NameAndType_info接口,标示方法名和方法描述符 |
下面分析具体分析2个常量:
常量池容量计数器之后,是第一个常量,tag(0x0000000A)值为0x000A,十进制为10,标示CONSTANT_Methodref_info,从上表查得其结构为tag(u1),index(u2),index(u2)。从截图可以看出一个index值为0x0004(十进制4),另一个为0x000F(十进制15),与javap生成的信息是一致的。
第二个常量:
tag值为9的是CONSTANT_Fieldref_info,两个index的值分别是3和16与下图一致:
其他的常量可以通过类似的方式分析。
访问标志
常量池之后,紧接着的两个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等。具体的标志位及标志的含义如下表:
标志名称 | 标志值 | 含义 | 备注 |
ACC_PUBLIC | 0x0001 | 是否为public类型 | |
ACC_FINAL | 0x0010 | 是否被声明为final,只有类可设置 | |
ACC_SUPER | 0x0020 | 是否允许使用invokespecial字节码指令的新语意,invokespecial指令的语意在jdk1.0.2发生过改变,为了区别这条指令使用哪种语意,jdk1.0.2之后编译出来的类的这个标志都必须是真 | |
ACC_INTERFACE | 0x0200 | 标示这是一个接口 | |
ACC_ABSTRACT | 0x0400 | 是否为abstract类型,对于接口或抽象类来说,此标志值为真,其他类值为假 | |
ACC_SYNTHETIC | 0x1000 | 标示这个类并非由用户代码产生的 | |
ACC_ANNOTATION | 0x2000 | 标示这是一个注解 | |
ACC_ENUM | 0x4000 | 标示这是一个枚举 |
Test03的访问标志位:0x0001 | 0x0020 = 0x0021
顺便javap -verbose String查看一下jdk自带的String类的访问标志:
类引用,父类索引与接口索引集合
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据集合,Class文件中由这三项数据确定这个类的继承关系。
注意:这些索引都是相对于常量池而言,也就是说索引值在常量池中。
类索引用于确定这个类的全限定名;
父类索引用于确定这个类的父类的全限定名,由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。
接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。
类索引,父类索引和接口索引集合都按顺序排列在访问标志之后,类索引和父类索引用两个u2类型的索引值标示,他们各自执行一个类型为CONSTANT_Class_info的类描述符常量,通过CONSTANT_Class_info类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串,如下图:
对于接口索引集合,入口的第一项--u2类型的数据为接口计数器(interfaces_count)标示索引表的容量。如果该类没有实现任何接口,则该计数器值为0,后面接口的索引表不再占用任何字节。
字段表集合
字段表(field_info)用于描述接口或类中声明的变量。字段包括类级变量和实例级变量,但不包括在方法内部声明的局部变量。Java中描述一个字段包含的信息有:字段的作用域(public,private,protected修饰符),是实例变量还是类变量(static修饰符),可变性(final),并发可见性(volatile),是否被序列化(transient),字段数据类型(基本类型,对象,数组),字段名称。
字段表(field_info)的格式如下表:
类型 | 名称 | 数量 | 描述 | 备注 | ||||||||||||||||||||||||||||||||||||||||||
u2 | access_flags | 1 |
与类中access_flags类似:
|
|||||||||||||||||||||||||||||||||||||||||||
u2 | name_index | 1 | 代表字段的简单名称 | |||||||||||||||||||||||||||||||||||||||||||
u2 | descriptor_index | 1 |
字段和方法的描述符,作用是用来描述字段的数据乐行,方法的参数列表(包括数量,类型以及顺序)和返回值。 根据描述符规则:基本类型以及代码无返回值的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"
|
|||||||||||||||||||||||||||||||||||||||||||
u2 | attributes_count | 1 | ||||||||||||||||||||||||||||||||||||||||||||
attribute_info | attributes | attributes_count | 用于存储一些额外的信息 | |||||||||||||||||||||||||||||||||||||||||||
字段表集合中不会列出从超类或父接口中继承而来的字段,但是可能列出原本Java代码之中不存在的字段。Java中字段无法重载,不允许重名,但是对于字节码来说,如果两个字段的描述符不一致,那字段重名就是合法的。
方法表集合
Class文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。
类型 | 名称 | 数量 | 描述 | 备注 |
u2 | access_flags | 1 |
无ACC_VOLATILE和ACC_TRANSIENT 增加ACC_SYNCHRONIZED,ACC_NATIVE,ACC_STRICTFP,ACC_ABSTRACT 注:方法里面的代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为Code的属性里面。 |
|
u2 | name_index | 1 | ||
u2 | descriptor_index | 1 | ||
u2 | attributes_count | 1 | ||
attribute_info | attributes | attributes_count | 存储Code,即方法的实现体 |
如果父类方法在子类中没有被重写,方法表集合中就不会出现来自父类的方法信息,但是有可能出现由编译器自动添加的方法。
说明:Java语言中,重载除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名。但是Class文件格式中,只要描述符不是完全一致的两个方法就可以共存,也就是说,如果两个方法有相同的名称和参数,但返回值不同,那么也是可以合法共存于同一个Class文件中的。
属性表集合(单独一章介绍,请点击跳转)
字节码指令
属性表集合
属性表(attribute_info)在Class文件,字段表,方法表都可以携带自己的属性表集合,用于描述某些场景专有的信息。属性表集合不再要求各个属性表具有严格顺序,并且只要不与已有属性名重复。
Java虚拟机运行时会忽略掉它不认识的属性,虚拟机规范预定义了如下属性:
属性名称 | 使用位置 | 含义 | 备注 |
Code | 方法表 | Java代码编译成的字节码指令 | |
ConstantValue | 字段表 | final关键字定义的常量值 | |
Deprecated | 类,方法表,字段表 | 被声明为deprecated的方法和字段 | |
Exceptions | 方法表 | 方法抛出的异常 | |
EnclosingMethod | 类文件 | 仅当一个类为局部类或匿名类时才能拥有这个属性,这个属性用于标示这个类所在的外围方法 | |
InnerClasses | 类文件 | 内部类列表 | |
LineNumberTable | Code属性 | Java源码的行号与字节码指令的对应关系 | |
LocalVariableTable | Code属性 | 方法的局部变量描述 | |
StackMapTable | Code属性 | jdk1.6新增的属性,供新的类型检查验证器(Type Chcker)检查和处理目标方法的局部变量和操作数栈所需要的类型是否匹配 | |
Signature | 类,方法表,字段表 | jdk1.5新增 | |
SourceFile | 类文件 | 记录源文件名称 | |
SourceDebugExtension | 类文件 | jdk1.6新增 | |
Synthetic | 类,方法表,字段表 | 标示方法或字段为编译器自动生成的 | |
LocalVariableTypeTable | 类 | jdk1.5新增,它使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型 | |
RuntimeVisibleAnnotations | 类,方法表,字段表 | jdk1.5新增 | |
RuntimeInvisibleAnnotations | 类,方法表,字段表 | jdk1.5新增 | |
RuntimeVisibleParameterAnnocations | 方法表 | jdk1.5新增 | |
RuntimeInvisibleParameterAnnotations | 方法表 | jdk1.5新增 | |
AnnotationDefault | 方法表 | jdk1.5新增 | |
BootstrapMethods | 类文件 | jdk1.7新增,保存invokedynamic指令引用的引导方法限定符 |
对于每个属性,它的名称需要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,而属性值得结构则是完全自定义的,只需要同一个u4的长度属性去说明属性值所占用的位数即可。
Code属性
Java程序方法体重的代码经过javac编译器处理后,最终变为字节码指令存储在Code属性内。Code属性只能出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性,接口或抽象类中的方法就不存在Code属性。
Code属性表的结构如下:
类型 | 名称 | 数量 | 说明 | 备注 | ||||||||||||||||||||||||
u2 | attribute_name_index | 1 |
指向CONSTANT_Utf8_info型常量的索引,固定为Code 代表该属性的名称 |
|||||||||||||||||||||||||
u4 | attribute_length | 1 | 属性值的长度 | |||||||||||||||||||||||||
u2 | max_stack | 1 | 操作数栈深度的最大值 | |||||||||||||||||||||||||
u2 | max_locals | 1 |
局部变量表所需的存储空间,单位(Slot), 对于byte,char,float,int,short,boolean和returnAddress等长度不超过32位的数据类型,每个局部变量占用1个Slot,而double和long这两种64位的数据类型则需要两个Slot。 方法参数(包括实例方法中的隐藏参数this),显式异常处理器的参数,方法体重定义的局部变量都需要使用局部变量表来存放。 |
|||||||||||||||||||||||||
u4 | code_length | 1 | 字节码长度 | |||||||||||||||||||||||||
u1 | code | code_length | 存储字节码指令的一系列字节流 | |||||||||||||||||||||||||
u2 | exception_table_length | 1 | ||||||||||||||||||||||||||
exception_info | exception_table | exception_table_length |
方法的显式异常处理表集合,格式如下:
异常表实际上是Java代码的一部分,编译器使用异常表而不是简单的跳转命令来实现Java异常和finally处理机制。 如下代码,生成对应的异常表: public class Test03 { private int m; public int inc() { int x; try { } catch (Exception e) { x = 2; return x; } finally { x = 3; } } }
|
|||||||||||||||||||||||||
u2 | attributes_count | 1 | ||||||||||||||||||||||||||
attribute_info | attributes | attributes_count |
Java方法的执行过程中的数据交换,方法调用等操作都是基于栈(操作栈)的。
对如下代码的class文件,执行javap -verbose Test03
public class Test03 { private int m; public int inc() { return m + 1; } }
对于inc()方法的输出是:
inc方法没有参数也没有定义局部变量,为什么Args_size=1,Locals=1?
在任何实例方法里面都可以通过this关键字访问到此方法所属对象,此处javac编译器编译的时候把对this关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数而已。因此在实例方法的局部变量表中至少存在一个指向当前对象实例的局部变量,局部变量表中也会预留出一个Slot位来存放对象实例的引用。
Exception属性
Exception属性是在方法表中与Code属性平级的属性,与上面的异常表是不同的。
Exception属性的作用是列举出方法中可能抛出的受查异常(Checked Exceptions),也就是方法描述时在throws关键字后面列举的异常。
结构如下:
类型 | 名称 | 数量 | 说明 |
u2 | attribute_name_index | 1 | |
u4 | attribute_length | 1 | |
u2 | number_of_exceptions | 1 | 有多少种受查异常 |
u2 | exception_index_table | number_of_exceptions | 每一种受查异常使用一个exception_index_table标示 |
LineNumberTable属性
描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。
类型 | 名称 | 数量 | 说明 |
u2 | attribute_name_index | 1 | |
u4 | attribute_length | 1 | |
u2 | line_number_table_length | 1 | |
line_number_info | line_number_table | line_number_table_length |
line_number_info是一个数量为line_number_table_length,类型为line_number_info的集合, line_number_info表包括了start_pc和line_number两个u2类型的数据项。前者是字节码行号,后者是Java源码行号 |
LocalVariableTable属性
描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系。它不是运行时必需的属性,但默认会生成到Class文件之中,可以在javac中分别使用-g:none或-g:vars选项来取消或要求生成这项信息。
如果没有生成这项属性,最大的影响就是当其他人引用这个方法时,所有的参数名称都将会丢失,IDE将会使用诸如arg0,arg1之类的占位符代替原来的参数名,这对程序运行没有影响,但是会对代码编写带来较大不便,而且在调试期间无法根据参数名从上下文中获得参数值。
属性的格式如下:
类型 | 名称 | 数量 | 说明 | |||||||||||||||||||||||
u2 | attribute_name_index | 1 | ||||||||||||||||||||||||
u4 | attribute_length | 1 | ||||||||||||||||||||||||
u2 | local_variable_table_length | 1 | ||||||||||||||||||||||||
local_variable_info | local_variable_table | local_variable_table_length |
local_variable_info代表了一个栈帧与源码中的局部变量的关联,结构如下:
|
|||||||||||||||||||||||
jdk1.5引入泛型之后,增加了LocalvariableTypeTable属性,与LocalVariableTable非常相似,仅仅把记录字段描述符的descriptor_index替换成了字段的特征签名(Signature),对于非泛型类型来说,描述符和特征签名能描述的信息基本一致,但是泛型引入之后,由于描述符中泛型的参数化类型被擦除掉,描述符就不能准确地描述泛型类型了。
SourceFile属性
记录生成这个Class文件的源码文件名称。格式如下:
类型 | 名称 | 数量 | 说明 |
u2 | attribute_name_index | 1 | |
u4 | attribute_length | 1 | |
u2 | sourcefile_index | 1 | 指向常量池中CONSTANT_Utf8_info型常量的索引,值时源码文件的文件名。 |
ConstantValue属性
作用是通知虚拟机自动为静态变量赋值。只有被static关键字修饰的变量(类变量)才可以使用这项属性。
InnerClasses属性
记录内部类与宿主类之间的关系。如果一个类中定义了内部类,那编译器将会为它所包含的内部类生成InnerClasses属性。
Deprecated和Synthetic属性
这两个属性都属于标志类型的布尔属性。
Deprecated属性用于标示某个类,字段或方法,已经被程序作者定为不在推荐使用。
Synthetic属性代表此字段或方法并不是有Java源码直接产生的,而是由编译器自行添加的。
StackMapTable属性
jdk1.6新增的,位于Code属性的属性表中。这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器使用。
Signature属性
如果累,接口,初始化方法或成员的泛型签名如果包含了类型变量或参数化类型,则Signature属性会为它记录泛型签名信息。之所以要专门使用这样一个属性去记录泛型类型,是因为Java语言的泛型采用的是擦除法实现的伪泛型。在字节码(Code属性)中,泛型信息编译之后都通通被擦除掉。
BoostrapMethods属性
保存invokedynamic指令引用的引导方法限定符。
虚拟机类加载机制
概述
在Class文件中描述的各种信息,最终都需要加载到虚拟机中之后才能运行和使用。而虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
与那些在编译时需要进行连接工作的语言不同,在Java语言里面,类型的加载,连接和初始化过程都是在程序运行期完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现。
类加载的时机
类从被加载虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段。其中验证,准备,解析三个部分统称为连接。
什么情况下需要开始类加载过程的第一个阶段:加载?Java虚拟机规范中并没有进行强制约束,但是初始化节点,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载,验证,准备自然需要在此之前开始)
- 遇到new,getstatic,putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。
- 使用java.lang.reflect包的方法对类进行反射调用的时候。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main方法的那个类),虚拟机会先初始化这个主类。
- 当使用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先触发其初始化。
这5种场景中的行为称为一个类进行主动引用,除此之外,所有引用类的方式都不会触发初始化,称为被动引用。接口初始化与类有一些区别:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父类接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。
类加载的过程
Java虚拟机加载类的全过程:加载,验证,准备,解析和初始化这5个阶段。
加载
“加载”是“类加载”过程的一个阶段,两个是不一样的,在加载阶段,虚拟机完成3件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流;
- 将这个字节流所代表的静态存储接口转化为方法区的运行时数据结构;
- 在内存中生成一个代表这个列的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中。然后在内存中实例化一个java.lang.Class类的对象(在堆还是方法区中未规定),这个对象将作为程序访问方法区中这些类型数据的外部接口。
验证
验证是连接节点的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求(否则抛出一个java.lang.VerifyError异常或其子类异常),并且不会危害虚拟机自身的安全。
验证主要包括如下4个部分:
- 文件格式验证:只有通过了整个阶段的验证,字节流才会进入内存的方法区中进行存储,后面的验证阶段全部是基于方法区的存储接口进行的,不会再直接操作字节流;
- 元数据验证:对字节码描述的信息进行语义分析,以保障其描述的信息符合Java语义规范的要求;
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的。这个阶段将对类的方法体进行验证分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件;
- 符号引用验证:最后一个阶段的校验是在虚拟机将符号引用转为直接引用的时候,这个转化动作将在连接的第三阶段--解析阶段中发生。符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么僵会抛出一个java.lang.IncompatibleClassChangeError异常的子类,如java.lang.IllegalAccessError,java.langNoSuchFieldError,java.lang.NoSuchMethodError等。
准备
正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
这个时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随对象一起分配在Java堆中。这里所说的初始值“通常情况下”是数据类型的零值。比如定义:
public static int value = 123;
那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java等方法,而把value赋值为123的putstatic指令时程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。
而final修饰的静态变量(常量)在编译时会为常量生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue设置常量值,而非零值。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。主要针对类后或接口,字段,类方法,接口方法,方法类型,方法句柄和调用点限定符7类符号引用进行。
符号引用:在Class文件中它以CONSTANT_Class_info,CONSTANT_Fieldref_info,CONSTANT_Methodref_info等类型的常量出现。
初始化
类初始化阶段是类加载过程的最后一步,签名的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码。
在准备阶段,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。
(1)<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义的静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
(2)<clinit>()方法与类的构造函数(实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。
(3)由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
(4)<clinit>()方法对于类或接口来说不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法
(5)接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与列不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
(6)虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁,同步。
类加载器
“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去执行,以便让应用程序自己决定如何去获取所需要的类,实现这个动作的代码模块称为“类加载器”。
类与类加载器
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。
每一个类加载器,都拥有一个独立的类名称空间。
比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。
package com.huawei.jvm; import java.io.IOException; import java.io.InputStream; public class Test05 { public static void main(String[] args) throws ClassNotFoundException { ClassLoader loader = new ClassLoader() { @Override public Class<?> loadClass(String name) throws ClassNotFoundException { try { String fileName = name.substring(name.lastIndexOf(\'.\') + 1) + ".class"; InputStream is = getClass().getResourceAsStream(fileName); if (is == null) { return super.loadClass(name); } byte[] b = new byte[is.available()]; is.read(b); return defineClass(name, b, 0, b.length); } catch (IOException e) { throw new ClassNotFoundException(name); } } }; Object obj = loader.loadClass("com.huawei.jvm.Test05"); System.out.println(obj); System.out.println(obj instanceof com.huawei.jvm.Test05); } }
运行截图:
双亲委派模型
从Java虚拟机的角度来讲,只存在两种不同的类加载器:(1)启动类加载器【Bootstrap ClassLoader】,这个类加载器使用C++语言实现,是虚拟机自身的一部分;(2)所有其他的类加载器,这些类加载器都是Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader
从Java开发人员的角度来看,了加载器还可以划分得更细致一些,绝大部分Java程序都会使用到一下3中系统提供的类加载器:
(1)启动类加载器Bootstrap ClassLoader,这个类负责将存放在JAVA_HOME/lib目录中的,或者被-Xbootclasspath参数指定的路径中的,并且是虚拟机识别的(按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。
(2)扩展类加载器Extension ClassLoader,这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载JAVA_HOME/lib/ext目录中的,或被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
(3)应用程序类加载器Application ClassLoader,这个类加载器有sun.misc.Launcher$AppClassLoader实现,由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般称为系统类加载器,它负责加载用户类路径(Classpath)上所指定的类库开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
应用程序都是由这3中类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。
类加载器之间的这种层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。除了启动类加载器外,其余的类加载器都应当有自己的父类加载器(非继承的关系)。
原理如下:当一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的类加载器都是如此,因此所有的加载请求都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围内没有找到所需的类)时,子加载器才会尝试自己去加载。
虚拟机字节码执行引擎
执行引擎是Java虚拟机最核心的组成部分之一。虚拟机是一个相对于物理机的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器,硬件,指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集和执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
运行时栈帧结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表,操作数栈,动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多深内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法锁需要分配的局部变量表的最大容量。容量以变量槽Slot为单位。局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题。
在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字this来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的。
操作数栈
操作数栈也称为操作栈,它是一个后入先出栈(LIFO)。操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以试任意的Java数据类型,在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点。
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转为直接引用,这种转化称为静态解析。另一部分将在每一次运行期间转为直接引用,这部分称为动态连接。
方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法:
(1)执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值的类型将根据遇到何种方式返回指令来决定,这种退出方法的方式称为正常完成出口。
(2)在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口,一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中。
方法调用
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。Class文件的编译谷草中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
解析
所有方法调用中的目标方法在Class文件里面都有一个常量池中的符号引用,在类加载的解析阶段,会将 其中的一部分符号引用转化为直接引用,这种解析成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。调用目标的程序代码写好,编译器进行编译时就必须确定下来,这类方法的调用称为解析。
Java语言中符合“编译器可知,运行期不可变”这个要求的方法主要包括:静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问。
Java虚拟机中提供了5条方法调用字节码指令:
invokestatic:调用静态方法
invokespecial:调用实例构造器<init>方法,私有方法和父类方法
invokevirtual:调用所有的虚方法
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象
invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法,私有方法,实例构造器,父类方法4类,在类加载的时候就会把符号引用解析为该方法的直接引用,这些方法可以成为非虚方法,与之相反,其他方法称为虚方法(除去final方法)。
实例如下:
|
|
Java中的非虚方法有invokestatic,invokespecial和被final修饰的方法。
解析调用一定是个静态的过程,在编译期间完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。
分派
分派过程会揭示多态性特征的一些最基本的体现,如:重载和重写在Java虚拟机之中是如何实现的。
静态分派
实例
package com.huawei.jvm; public class Test06 { static abstract class Human {} static class Man extends Human {} static class Woman extends Human {} public void sayHello(Human guy) { System.out.println("hello, guy!"); } public void sayHello(Man guy) { System.out.println("hello, gentleman!"); } public void sayHello(Woman guy) { System.out.println("hello, lady!"); } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); Test06 t = new Test06(); t.sayHello(man); t.sayHello(woman); } }
运行截图:
上面代码中的“Human”称为变量的静态类型(Static Type)或者叫做外观类型(Apparent Type);
“Man”和“Woman”称为变量的实际类型(Actual Type)。
静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态乐行不会被改变,并且最终的静态类型是在编译器可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
虚拟机(或者说编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的,并且静态类型是编译期可知的,因此在编译阶段,javac编译器会根据参数的静态类型决定使用哪个重载版本。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是唯一的,往往只能确定一个更加合适的版本。
动态分配
动态分配与多态性的另外一个重要体现--重写(Override)有着密切的关联。
package com.huawei.jvm; public class Test07 { static abstract class Human { protected abstract void sayHello(); } static class Man extends Human { @Override protected void sayHello() { System.out.println("man say hello..."); } } static class Woman extends Human { @Override protected void sayHello() { System.out.println("woman say hello..."); } } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); man.sayHello(); woman.sayHello(); man = new Woman(); man.sayHello(); } }
输出截图:
通过javap查看Test07:
17和21行是方法调用指令,这两条指令单从字节码角度来看完全一样,但是这两句指令最终执行的目标方法并不相同。原因需要从invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:
(1)找到操作数栈的第一个元素所指向的对象的实际类型,计做C。
(2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找谷草结束;如果不通过,则返回java.lang.IllegalAccessError异常
(3)否则,按照继承关系从下往上对C的各个父类进行第2步的搜索和验证过程。
(4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常
由于invokevirtual指令执行的第一步就是在运行期确定接受者的实际类型,所有两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言方法重写的本质。这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
单分派和多分派
虚拟机动态分派的实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需
以上是关于虚拟机执行子系统的主要内容,如果未能解决你的问题,请参考以下文章