深入理解Java虚拟机:JVM高级特性与最佳实践-总结-9
- 虚拟机类加载机制
- 类加载的过程
- 准备
- 解析
- 字段解析
- 方法解析
- 接口方法解析
虚拟机类加载机制
类加载的过程
准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK 7
及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK 8
及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。
准备阶段进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。 这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:
public static int value = 123;
变量value
在准备阶段过后的初始值为0而不是123,因为这时尚未开始执行任何Java方法,而把value
赋值为123的putstatic
指令是程序被编译后,存放于类构造器<clinit>()
方法之中,所以把value
赋值为123的动作要到类的初始化阶段才会被执行。 下表列出了Java中所有基本数据类型的零值:
在“通常情况”下初始值是零值,某些“特殊情况”:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值,假设上面类变量value的定义修改为:
public static final int value = 123;
编译时Javac将会为value
生成ConstantValue
属性,在准备阶段虚拟机就会根据ConstantValue
的设置将value
赋值为123。
解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,在Class文件中符号引用以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info
等类型的常量出现。
- 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规 范》的Class文件格式中。
- 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
对方法或者字段的访问,也会在解析阶段中对它们的可访问性(public、protected、private、<package>
)进行检查。
对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic
指令以外,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。无论是否真正执行了多次解析动作,Java虚拟机都需要保证的是在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直能够成功;同样地,如果第一次解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常,哪怕这个请求的符号在后来已成功加载进Java虚拟机内存之中。
不过对于invokedynamic
指令,上面的规则就不成立了。当碰到某个前面已经由invokedynamic
指令触发过解析的符号引用时,并不意味着这个解析结果对于其他invokedynamic
指令也同样生效。因为invokedynamic
指令的目的本来就是用于动态语言支持,它对应的引用称为“动态调用点限定符(Dynamically-Computed Call Site Specifier)”,这里“动态”的含义是指必须等到程序实际运行到这条指令时,解析动作才能进行。相对地,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有开始执行代码时就提前进行解析。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CON-STANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info和CONSTANT_InvokeDynamic_info 8种常量类型
1.类或接口的解析
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要包括以下3个步骤:
- 如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。
- 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似
“[Ljava/lang/Integer”
的形式,那将会按照第一点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”
,接着由虚拟机生成一个代表该数组维度和元素的数组对象。 - 如果上面两步没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出
java.lang.IllegalAccessError
异常。
针对上面第3点访问权限验证,在JDK 9
引入了模块化以后,一个public
类型也不再意味着程序任何位置都有它的访问权限,我们还必须检查模块间的访问权限。
如果我们说一个D拥有C的访问权限,那就意味着以下3条规则中至少有其中一条成立:
- 被访问类C是
public
的,并且与访问类D处于同一个模块。 - 被访问类C是public的,不与访问类D处于同一个模块,但是被访问类C的模块允许被访问类D的 模块进行访问。
- 被访问类C不是public的,但是它与访问类D处于同一个包中。
在后续涉及可访问性时,都必须考虑模块间访问权限隔离的约束,即以上列举的3条规则。
字段解析
要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index
项中索引的CONSTANT_Class_info
符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完
成,那把这个字段所属的类或接口用C表示,《Java虚拟机规范》要求按照如下步骤对C进行后续字段
的搜索:
- 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引 用,查找结束。
- 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 否则,如果C不是
java.lang.Objec
t的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。 - 否则,查找失败,抛出
java.lang.NoSuchFieldError
异常。
如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError
异常。
以上解析规则能够确保Java虚拟机获得字段唯一的解析结果,但在实际情况中,Javac编译器往往
会采取比上述规范更加严格一些的约束,譬如有一个同名字段同时出现在某个类的接口和父类当中,
或者同时在自己或父类的多个接口中出现,按照解析规则仍是可以确定唯一的访问字段,但Javac编译
器就可能直接拒绝其编译为Class文件。下列代码清单演示了这种情况,如果注释了Sub类中的“public static int A=4;”,接口与父类同时存在字段A,那Oracle公司实现的Javac编译器将提示“The field Sub.A is ambiguous”,并且会拒绝编译这段代码。
public class FieldResolution {
interface Interface0 {
int A = 0;
}
interface Interface1 extends Interface0 {
int A = 1;
}
interface Interface2 {
int A = 2;
}
static class Parent implements Interface1 {
public static int A = 3;
}
static class Sub extends Parent implements Interface2 {
public static int A = 4;
}
public static void main(String[] args) {
System.out.println(Sub.A);
}
}
方法解析
方法解析的第一个步骤与字段解析一样,也是需要先解析出方法表的class_index
项中索引的方法所属的类或接口的符号引用,如果解析成功,那么我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的方法搜索:
- 由于Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的方法表中发现
class_index
中索引的C是个接口的话,那就直接抛出java.lang.IncompatibleClassChangeError
异常。 - 如果通过了第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛
java.lang.AbstractMethodError
异常。 - 否则,宣告方法查找失败,抛出
java.lang.NoSuchMethodError
。
最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此
方法的访问权限,将抛出java.lang.IllegalAccessError异常。
接口方法解析
接口方法也是需要先解析出接口方法表的class_index
项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:
- 与类的方法解析相反,如果在接口方法表中发现
class_index
中的索引C是个类而不是接口,那
么就直接抛出java.lang.IncompatibleClassChangeError
异常。 - 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在接口C的父接口中递归查找,直到
java.lang.Object
类(接口方法的查找范围也会包括
Object
类中的方法)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。 - 对于规则3,由于Java的接口允许多重继承,如果C的不同父接口中存有多个简单名称和描述符都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找,《Java虚拟机规范》中并没有进一步规则约束应该返回哪一个接口方法。但与之前字段查找类似地,不同发行商实现的Javac编译器有可能会按照更严格的约束拒绝编译这种代码来避免不确定性。
- 否则,宣告方法查找失败,抛出
java.lang.NoSuchMethodError
异常。
在JDK 9
之前,Java接口中的所有方法都默认是public
的,也没有模块化的访问约束,所以不存在访问权限的问题,接口方法的符号解析就不可能抛出java.lang.IllegalAccessError
异常。但在JDK 9
中增加了接口的静态私有方法,也有了模块化的访问约束,所以从JDK 9
起,接口方法的访问也完全有可能因访问权限控制而出现java.lang.IllegalAccessError
异常。