本章内容报考虚拟机如何加载Class文件?Class文件中的信息进入到虚拟机后会发生什么变化?
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期如下图所示共有七个阶段,其中加载、验证、准备、初始化,他们的顺序是固定的,即类型加载要按这个顺序开始,大梅沙中间可能会有相互交叉的混合进行,一个阶段中调用另一个阶段。
而从整体上看,可以用这样的图来描述整个过程:
《Java虚拟机规范》没有规定什么时候开始类加载的第一个阶段“加载”。但是严格规定了以下6种情况下,必须对类进行初始化(加载、验证、准备自然在此前开始了):
(1)遇到new
、getstatic
、putstatic
、invokestatic
这四条字节码指令时。
(2)使用java.lang.reflect
包的方法第一次对类进行反射调用时会触发类的初始
(3)初始化类时,如果发现父类还没有初始化,则需要先触发父类的初始化
(4)虚拟机启动时,用户需要指定一个主函数类(main()
方法所在的类),虚拟机会先启动这个类
(5)使用JDK7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHanlde
实例最后的解析结果为REF_getstatic
、REF_putstatic
、REF_invokeStatic
、REF_newInvokeSpecial
四种类型的方法句柄时,都需要先初始化该句柄对应的类
(6)接口中定义了JDK 8新加入的默认方法(default
修饰符),实现类在初始化之前需要先初始化其接口
以上六种情况也叫做对一个类型的主动引用,这种情况下必然触发初始化,其它情况为被动引用,不会触发。
下面介绍类加载的每个阶段
在加载阶段,虚拟机需要完成以下三件事情:
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3) 在Java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。
验证阶段是非常重要的,并且验证阶段的工作量在虚拟机的类加载子系统中占了很大一部分,但虚拟机规范对这个阶段的限制和指导显得非常笼统,仅仅说了一句如果验证到输入的字节流不符合Class文件的存储格式,就抛出一个java.lang.VerifyError异常或其子类异常,具体应当检查哪些方面,如何检查,何时检查,都没有强制要求或明确说明,所以不同的虚拟机对类验证的实现可能会有所不同,但大致上都会完成下面四个阶段的检验过程:
文件格式验证:验证Class文件规范以及虚拟机版本。
元数据验证:对类的元数据信息做语义校验,确保语言规范。
字节码验证:最为复杂的阶段,对方法体类的语义校验,确保程序语义合法,符合逻辑。
符号引用验证:在解析阶段才会发生,用于校验符号引用是否可达。
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
这里所说的初始值“通常情况”下是数据类型的零值。
public static int value = 123;变量value在准备阶段过后的初始值为0而不是123;
在“通常情况”下初始值是零值,那相对的会有一些“特殊情况”:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值
public static final int value = 123;变量value在准备阶段过后的初始值是123;
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行,这里重点介绍前四个。
7.3.4.1.类或接口的解析
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要包括以下3个步骤:
1)如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于无数据验证、字节码验证的需要,又将可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。
2)如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似“[Ljava.lang.Integer”的形式,那将会按照第1点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表此数组维度和元素的数组对象。
3)如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认C是否具备对D的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。
7.3.4.2.字段解析
要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那将这个字段所属的类或接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索:
1)如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
2)否则,如果在C中实现了接口,将会按照继承关系从上往下递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
3)否则,如果C不是java.lang.Object的话,将会按照继承关系从上往下递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
4)否则,查找失败,抛出java.lang.NoSuchFieldError异常。
如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常。
在实际应用中,虚拟机的编译器实现可能会比上述规范要求得更加严格一些,如果有一个同名字段同时出现在C的接口和父类中,或者同时在自己或父类的多个接口中出现,那编译器将可能拒绝编译。
7.3.4.3.类方法解析
类方法解析的第一个步骤与字段解析一样,也是需要先解析出类方法表的class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的类方法搜索:
1)类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
2)如果通过了第(1)步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
3)否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
4)否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出java.lang.AbstractMethodError异常。
5)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。
最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证;如果发现不具备对此方法的访问权限,将抛出java.lang.IllegalAccessError异常。
7.3.4.4.接口方法解析
接口方法也是需要先解析出接口方法表的class_index[6]项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:
1)与类方法解析相反,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
2)否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
3)否则,在接口C的父接口中递归查找,直到java.lang.Object类(查找范围会包括Object类)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
4)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。
由于接口中的所有方法都默认是public的,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出java.lang.IllegalAccessError异常。
5.1 在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器< clinit>()方法的过程;
< clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的;
5.2 < clinit>()方法与类的构造函数(或者说实例构造器< clinit>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的< clinit>()方法执行之前,父类的< clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的< clinit>()方法的类肯定是java.lang.Object;
5.3 由于父类的< clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作;
5.4 < clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成< clinit>()方法。
5.5 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成< clinit>()方法。但接口与类不同的是,执行接口的< clinit>()方法不需要先执行父接口的< clinit>()方法。只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的< clinit>()方法。
5.6 虚拟机会保证一个类的< clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的< clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行()方法完毕。如果在一个类的< clinit>()方法中有耗时很长的操作,那就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
对于任意一个类,必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每个类加载器都有一个独立的类名称空间。也即,比较两个类是否相等,只有在这两个类由同一个类加载器加载的前提下才有意义。这里的相等报考代表类的Class对象的equals()方法,isAssignableFrom()方法、isInstance()方法。
从虚拟机角度看,只有两种类型的类加载器:
启动类加载器(Bootstrap ClassLoader),C++实现,虚拟机的一部分。
其它类加载器,Java实现,独立于虚拟机之外,全都继承自java.lang.ClassLoader。
JDK1.2以来,Java中保持着三层类加载器、双亲委派的类加载结构,下面就JDK8版本介绍这两个。
三层类加载器:
启动类加载器(Bootstrap Class Loader):负责加载
扩展类加载器(Extension Class Loader):该类在sun.misc.Launcher$ExtClassLoader中以Java代码实现,负责加载
应用程序类加载器(Application Class Loader):应用程序类加载器是ClassLoader类中的getSystemCLassLoader()方法的返回值,有些场合中称它为“系统类加载器”。如果应用程序中没有自定义类加载器,则默认是这个。
双亲委派机制:
该机制要求除了顶层的启动类加载器,其它的类加载器都应该有自己的父类加载器。这里的父子关系不是继承来实现的,而实通过组合(composition)来实现的。
双亲委派模型的工作过程:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
好处:
其实实现很简单:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
ClassLoader的loadClass方法中:先检查请求加载的类型是否已经被加载过,没有则调用父类加载器的loadCLass()方法,如果父类加载器为空,则使用启动类加载器,如果以上都加载失败,则调用自己的findClass()方法来尝试加载。
所以一般实现ClassLoader的时候,主要是重写findClass()方法,这是因为JDK历史原因导致的,后续遵循这个方式就行。
除了子类委托父类这种自下而上的委托加载,还有一种打破了双亲委派模型的设计,即线程上下文类加载器(Thread Context ClassLoader),它能通过Thread类的setCOntextClassLoader()方法设置类加载器,如果创建线程时还没设置,则从父线程继承一个,如果都没有,则默认应用程序类加载器。
7.5的模块化系统
属于JDK9之后的新特性,先不看了。
笔记主要内容来自《深入理解Java虚拟机 第3版》,里面的一些插图来自尚硅谷的宋红康老师的ppt。