虚拟机类加载机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以直接使用的Java类型,这个过程被称为虚拟机的类加载机制。
注意:此处Class文件并非特指具体在磁盘上中的文件,而应当是一串二进制字节流,无论其以何种形式存在,包括但不限于磁盘文件、网络、数据库、内存、或动态产生,这为Java程序提供了极大的灵活性和扩展性。

Java类型的生命周期

loadclass.png
其中,加载、验证、准备、初始化和卸载五个阶段的开始顺序是确定的,而解析阶段则不一定:某些时候它可以在初始化阶段之后再开始,这是为了支持Java的运行时绑定特性。
这里说的顺序确定是阶段开始顺序是确定的,这些阶段通常都是互相交叉混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。

加载

在加载阶段,虚拟机完成以下三件事情:

  1. 通过一个类的全限定名获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区的各种数据的访问入口。
    这里并没有说明二进制字节流从哪里获取,这是JSP技术(其他文件生成)、动态代理(运行时生成)、运行JAR(从ZIP文件读取)的基础。
    数组类型是由虚拟机在内存中动态构造出来的,但是数组的元素类型(去掉所有维度后)是由类加载器完成加载的。

验证

验证阶段是为了保证加载的二进制字节流是符合虚拟机要求的,安全的。包含以下三个动作:

文件格式验证

是否符合Class文件的规范、能否被当前版本的虚拟机处理等,如是否以0xCAFEBABE开头,主次版本号验证等

元数据验证

对类的元数据信息进行语义校验,校验数据类型。比如默认父类Object、是否继承了final修饰的类等。

字节码验证

对字节码方法体进行数据流分析和控制流分析,确定语义是合法的、符合逻辑的,确保被校验类的方法在运行时不会做出危害虚拟机的行为,如存储的int类型却按long类型加载、两个毫不相关的类型强制转换等。
注意:就算方法体通过了字节码验证,也不能证明它就是没问题的,因为不可能用程序来校验程序是否有BUG

符号引用验证

该验证发生在虚拟机将符号引用转化为直接引用的时候,这个转化在连接的第三阶段——解析阶段发生。
符号引用验证可以看作对类自身以外的各类信息进行匹配校验,通俗来说就是,该类是否缺少或者禁止访问它依赖的某些外部类、方法、字段等资源。符号引用中通过字符串描述的全限定名能否找到对应的类、符号引用中类的、字段、方法的可访问性等。

准备

该阶段为类中定义的静态变量分配内存并设置初始零值,注意,是初始零值,例如:

public static int value = 666;

在准备阶段后值为0而不是666,因为这时尚未执行任何Java方法,为value赋值是在类构造器<clinit>()方法putstatic字节码指令之后,把value赋值为666的指令是在类的初始化阶段才被执行。
注意:上文说的是静态变量,若是静态常量(final修饰)则会赋值为666

解析

解析阶段是常量池的符号引用替换为直接引用的过程。

  • 符号引用:可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。使用javap命令生成的字节码中可以看到常量池中很多的符号引用。
  • 直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是能一个间接定位到目标的句柄。直接引用和虚拟机的内存布局和实现有关。如果有了直接引用,那引用的目标必定在虚拟机的内存中存在。

类或接口的解析

  1. 如果该符号引用不是一个数组类型,那么虚拟机将会把该符号代表的全限定名称传递给类加载器去加载这个类。这个过程由于涉及验证过程所以可能会触发其他相关类的加载。
  2. 如果该符号引用是一个数组类型,并且该数组的元素类型是对象。我们知道符号引用是存在方法区的常量池中的,该符号引用的描述符会类似”[java/lang/Integer”的形式,将会按照上面的规则进行加载数组元素类型,如果描述符如前面假设的形式,需要加载的元素类型就是java.lang.Integer ,接着由虚拟机将会生成一个代表此数组对象的直接引用。
  3. 如果上面的步骤都没有出现异常,那么该符号引用已经在虚拟机中产生了一个直接引用,但是在解析完成之前需要对符号引用进行验证,主要是确认当前调用这个符号引用的类是否具有访问权限,如果没有访问权限将抛出java.lang.IllegalAccess异常。

字段解析

  1. 如果该字段符号引用就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,解析结束。
  2. 否则,如果在该符号的类实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果在接口中包含了简单名称和字段描述符都与目标相匹配的字段,那么久直接返回这个字段的直接引用,解析结束。
  3. 否则,如果该符号所在的类不是Object类的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都相匹配的字段,那么直接返回这个字段的直接引用,解析结束。
  4. 否则,解析失败,抛出java.lang.NoSuchFieldError异常

方法解析

  1. 类方法和接口方法的符号引用是分开的,所以如果在类方法表中发现class_index(类中方法的符号引用)的索引是一个接口,那么会抛出java.lang.IncompatibleClassChangeError的异常。
  2. 如果class_index的索引确实是一个类,那么在该类中查找是否有简单名称和描述符都与目标字段相匹配的方法,如果有的话就返回这个方法的直接引用,查找结束。
  3. 否则,在该类的父类中递归查找是否具有简单名称和描述符都与目标字段相匹配的字段,如果有,则直接返回这个字段的直接引用,查找结束。
  4. 否则,在这个类的接口以及它的父接口中递归查找,如果找到的话就说明这个方法是一个抽象类,查找结束,返回java.lang.AbstractMethodError异常。
  5. 否则,查找失败,抛出java.lang.NoSuchMethodError异常。

接口方法解析

  1. 如果在接口方法表中发现class_index的索引是一个类而不是一个接口,那么也会抛出java.lang.IncompatibleClassChangeError的异常。
  2. 否则,在该接口方法的所属的接口中查找是否具有简单名称和描述符都与目标字段相匹配的方法,如果有的话就直接返回这个方法的直接引用。
  3. 否则,在该接口以及其父接口中查找,直到Object类,如果找到则直接返回这个方法的直接引用。
  4. 否则,查找失败。

初始化

到这一步,虚拟机才真正开始执行类中编写的Java程序代码,将主导权交由用户程序。
初始化阶段是执行类构造器<clinit>()方法的过程。<clinit>()并不是Java代码中的方法,是Java编译器自动生成的产物。

  • <clinit>()方法是由编译器自动收集类中所有类变量的赋值动作(final修饰除外)和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句中只能访问到定义在静态语句块之前的变量,定义在其后的变量,在前面的静态语句块可以赋值,但是不能访问。
public class Main {

    static {
        /* 赋值正常 */
        i = 0;
        /* 非法前向引用 */
        System.out.println(i);
    }
    static int i = 1;

}
  • <clinit>()方法与类的构造函数(虚拟机视角中实例构造器()方法),不同,它不需要显示地调用父类构造器,Java虚拟机会保证在子类的()方法执行前,父类的<clinit>()方法已经执行完毕。显然,第一个被执行的<clinit>()方法肯定是Object类的方法。
  • <clinit>()方法对于类或接口来说不是必须的,如果一个类中没有静态语句块,也没有对静态变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
  • 接口中不能使用静态语句块,但有变量初始化的赋值操作,接口也有<clinit>()方法。但是,执行接口的<clinit>()方法不需要先执行父接口的方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。接口的实现类初始化时也不会执行接口的<clinit>()方法。
    为什么类初始化必须先执行父类的,而接口不需要?
    个人认为是因为类中可以有静态代码块,这些代码是可能并不是单纯的赋值操作,有的可能必须要执行,否则影响到子类的初始化行为,而接口中()方法只存在静态变量的赋值操作,可以等到用的时候再来初始化。
    若接口中有默认方法,则需要提前被初始化
  • <clinit>()方法必须保证线程安全。

类初始化的时机

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够产生这四条字节码指令的Java代码有:
    • 使用new关键字实例化对象。
    • 读取或设置一个类型的静态字段(常量除外)。
    • 调用一个类型的静态方法。
  2. 使用java.lang.reflect包的方法进行类型反射调用的时候,如果类型没有初始化过,则先触发其初始化。
  3. 当初始化类的时候,如果发现其父类还未进行初始化,则需先触发父类的初始化。
  4. 虚拟机启动的主类会被先初始化。
  5. 使用JDK7新加入的动态语言支持时,如果一个java.lang.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口中定义了JDK8新加入的默认方法,如果这个接口的实现类发生了初始化,那么这个接口需要在其之前被初始化。

以上参考《深入理解Java虚拟机》第三版

Q.E.D.


一切很好,不缺烦恼。