虚拟机是如何加载类的
一、概述
首先先来看几个问题
- jvm是如何加载这些Class文件的?
- jvm加载一个Class文件需要哪些步骤?
- Class文件中的信息进入到虚拟机后会发生什么变化?
接下来看看jvm加载class文件的概述:
jvm把描述类的数据从class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。这句话差不多已经回答上面三个问题的大部分了。
与那些在编译是需要进行连接工作的语言不同,在Java语言里面,类型的加载和连接过程都是在程序运行期间完成的,这样会在类加载是稍微增加一些性能开销,但是却能为Java应用程序提供高度的灵活性,Java中可以动态的扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。比如编写一个使用接口的应用程序,可以等到运行时在指定其实际的实现。这种组装应用程序的方式广泛应用于Java程序之中。
二、要点
类从被加载到jvm内存中开始,到卸载出内存为止,它的生命周期包括了一下步骤:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Useing)和卸载(Unloading)七个阶段。其中的验证、准备和解析三个部分统称为链接(Linking),这七个阶段的发生顺序如下图,注意是发生的顺序,不是执行完成的先后顺序。
1、加载
加载阶段是“类加载”过程的一个阶段,虚拟机需要做以下三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在Java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的入口。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式有虚拟机实现自定义,虚拟机规范未规定此区域的具体数据结构。然后再Java堆中实例化一个java.lang.Class类的对象,这个对象作为程序访问方法区中的这些类型数据的外部接口。加载阶段与连接阶段的部分内容是交替进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
2、验证
验证阶段虚拟机做了下面这些事情
1、文件格式验证
第一阶段是要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。会验证一下这些内容。
- 主、次版本号是否在当前虚拟机处理范围之内。
- 常量池的常量中是否有不被支持的常量类型。
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
- CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据。
- Class文件中各部分及文件本身是否有被删除的或附加的其他信息
2、元数据的验证
- 这个类是否有父类。
- 这个类的父类是否继承了不允许被继承的类(即被final修饰的类)。
- 若这个类不是抽象类,是否实现了其父类或接口之中要求的所有方法。
3、字节码的验证
这个阶段是验证最为复杂的一个阶段,主要工作是进行数据流和控制流分析,紧接第二阶段。
- 保证任意时刻操作数栈的数据类型与指令代码序列能配合工作。不会是在操作栈中放置了一个int类型的数据,使用时却按照long类型来加载。
- 保证跳转指令不会跳转到方法体之外的字节码上。
- 保证 方法体中的类型转换是有效。
4、符号引用验证
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在知道类中是否存在符合方法的字段描述符即简单名称所描述的方法和字段。
- 符合引用中的类、字段和方法的访问级别是否可以被当前的类访问。
3、准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,注意是初始值不是最终变量的值,都将在方法区中进行分配。如果该变量不是静态变量,将不会进行内存分配,而是会在类出乎实话的时候随着对象一起分配到Java堆中。另外这里的初始值通常情况下是零值。具体的初始化的值见下图,图片来源于《深入理解Jvm虚拟机》。
4、解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
-
符号引用:符号引用以一组符号来描述所引用的目标,符号可以使任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用于虚拟机实现内存布局无关,引用的目标不一定已经加载到内存中。
- 直接引用:直接引用可以使直接指向目标的指针、相对偏移量是一个能间接定位到目标的句柄。
5、初始化
类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全有虚拟机主导和空值。到了初始化阶段,才真正开始执行类中定义的Java程序代码。
前面讲到在准备阶段变量已经富余过一次初始值,而在初始化阶段,则是根据程序员通过程序制定主观计划去初始化变量和其他资源。
三、初始化阶段补充
一下四中情况会必须立即对类进行“初始化”。
- 遇到new、getstatic、putstatic或invokestatic者4调字节码指令时,如果类每一进行过初始化,则需要先触发器初始化。
- 使用反射调用一个对象的时候,该对象必须初始化
- 当初始化一个类的时候发现其父类没有初始化,则对其父类先初始化
- 当虚拟机启动的时候,用户需要知道一个要执行的主类(即包含main()方法的那个类),虚拟机会先初始化这个主类。
除了上面4中场景,都不会触发初始化,称为被动引用。
场景一
public class SupClass { public static int value = 100; static{ System.out.println("SupClass init..."); } } public class SubClass extends SupClass { static{ System.out.println("SubClass init..."); } }
客户端代码
public class InitTest { public static void main(String[] args) { System.out.println(SubClass.value); } }
输出如下
SupClass init...
100
可以看到通过子类引用父类的静态字段,不会导致子类初始化。
场景二
其他代码同场景一,客户端代码变成如下
public class InitTest { public static void main(String[] args) { SupClass[] sca = new SupClass[10]; } }
这段代码不会输出任何结果。因为通过数组定义来引用类,不会触发此类的初始化。
场景三
public class ConstClass { public static final String HELLO = "hello"; static{ System.out.println("ConstClass init..."); } }
客户端代码
public class InitTest { public static void main(String[] args) { System.out.println(ConstClass.HELLO); } }
输出如下
hello
可以看到常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
四、总结
本篇文章依据以下两点
- 在实际情况中,每个class文件都有可能代表着Java语言中的一个类或者接口,而对于类和接口需要分开描述
- 笔者所讲的“Class文件”并非指class必须是存在于具体磁盘中的某个文件,这里说的class文件指的是一种二进制字节流,无论以何种形式存在都可以。