概述

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,整个过程就是虚拟机的类加载机制。

与其他语言的不同在于,Java语言中,类型的加载、连接和初始化过程都是在程序运行期间完成的,这样的策略会令类加载时稍微开销大一点,但却带来了高度的灵活性。Java中许多语言特性都用到了动态加载和动态连接。

类加载的时机

Java中类的整个生命周期共有6个阶段:加载、验证、准备、解析、初始化、使用、卸载。

class-life

有且仅有遇到以下5种情况,jvm才会开始一个class的初始化:

  1. 遇到newgetstaticputstaticinvokestatic这四条字节码指令时,如果类没有进行过初始化,则需要对其首先进行初始化。这4条指令最常见的的场景分别为:使用new关键字实例化对象的时候、读取或者设置一个类的静态字段时、以及调用一个类静态方法的时候。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则触发其初始化过程。
  3. 当初始化一个类的时候,发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,jvm将首先初始化包含Main()方法的类
  5. 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandler实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic方法的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

只有这5种方式会触发类地初始化,它们被称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,这些被称为被动引用。

  • 仅使用一个类的引用不会触发类的初始化
  • 通过子类引用父类的静态字段,不会触发类的初始化
  • 使用一个类的数组定义不会触发类的初始化
  • 常量在编译阶段会存入调用类的常量池中,本质上并没有使用到定义常量的类,因此不会触发类的初始化

加载

在加载阶段,虚拟机所作的事为:

  1. 通过一个类的全限名获取定义该类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时结构数据
  3. 在内存中生成一个代表此类的java.lang.Class对象,作为方法区这个类的各种数据访问接口

一般来说,加载阶段可控的是如何获取类的二进制字节流,包括但不限于以下情况:

  • 从class文件中读取
  • 从ZIP包中读取
  • 从网络中获取
  • 运行时动态计算生成
  • 从其他文件生成,譬如jsp
  • 从数据库读取

JVM中数组是一种比较特殊的类,它本身不通过类加载器创建,它是由JVM直接创建的。数组类与其元素类型密切相关,数组类的创建过程遵循以下原则:

  • 如果数组的组件类型是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组类将在加载该组件类型的类加载器的类名称空间上被标识。
  • 如果数组的组件类型不是引用类型(比如int[]),JVM将把数组类与引导类加载器关联(bootstrap classloader)
  • 数组类的可见性与组件类型一致,如果组件类型不是引用类型,数组类的可见性默认为public

验证

验证是连接阶段第一步,目的是为了确保Class文件字节流包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身安全。

从整体上来看,验证阶段大致会完成下面4个阶段的检验动作:

  1. 文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本虚拟机处理。
    • 是否以MagicNumber 0xCAFEBABE开头
    • 主、次版本号是否在当前虚拟机处理范围之内
    • 常量池中的常量是否有不被支持的常量类型(检查常量tag标志)
    • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
    • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息
  2. 元数据验证:对字节码描述信息进行语义分析,以保证其描述的信息符合Java语言规范要求。
    • 这个类是否有父类(除java.lang.Object之外,所有类均有父类)
    • 这个类的父类是否继承了不被允许继承的类(被final修饰的类)
    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
    • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类final字段,出现不符合规则的方法重载等)
  3. 字节码验证:对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。
    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,不会出现保存了一个int数据,却按照long类型进行加载。
    • 保证跳转指令不会跳转到方法体之外的字节码指令上。
    • 保证方法体中的类型转换是有效的。
  4. 符号引用验证:对类自身以外(常量池中的各种符号引用)信息进行匹配性校验:
    • 符号引用中通过字符串描述的全限定名是否能找到对应的类
    • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
    • 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可以被当前类访问

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中被分配。需要注意以下两点:

  1. 进行内存分配的仅包括类变量(被static修饰的变量),不包括实例变量
  2. 初始值“通常情况”下为数据类型的零值。
    假设一个类变量定义为
    public static int value = 123;
    

    变量value在准备阶段后的初始值为0而不是123。将value赋值为123的指令存放于类构造器<clinit>()方法中,该动作将在初始化阶段进行

特殊情况是指该类字段的字段属性表中存在ConstantValue属性,那么在准备阶段变量value将被初始化为ConstantValue属性所指定的值,假设上面类变量定义为:

public static final int value = 123;

编译时javac将会为value生成ConstantValue属性,准备阶段就会为value赋值为123。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。类似于其他语言中的链接过程。

  • 符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用于虚拟机内存布局无关,引用的目标也不一定已经加载到内存内。各种虚拟机实现的内存布局不一定相同,但是他们所能接受的符号引用必然一样,因为使用相同规范的Class文件。
  • 直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或一个能够间接定位到目标的句柄。直接引用与虚拟机实现的内存布局息息相关,同一个符号引用在不同的虚拟机上翻译出来的直接引用一般不同。如果有了直接引用,那么引用的目标必然存在于内存中。

类或接口的解析

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那么虚拟机需要完成以下3个步骤:

  1. 如果C不是一个数组类型,那JVM将会把代表N的全限名传递给D的类加载器去加载这个C。该过程可能会触发其他相关类的加载动作,例如加载这个类的父类或者实现的接口。
  2. 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似”[Ljava/lang/Integer”的形式,那将会按照第一点的规则加载数组元素类型。
  3. 如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认D是否具备对C的访问权限。如果不具备访问权限,将抛出java.lang.IllegalAccessError异常。

字段解析

首先将解析字段所属的类或接口的符号引用。如果解析成功,将这个字段所属的类或接口用C表示,后续步骤如下:

  1. 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  2. 如果在C中实现了接口,将会按照继承关系从下往上递归搜索每个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  3. 如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  4. 否则,查找失败。

实际使用时,编译器的规则将更严。

类方法解析

依然采用C表示这个类。

  1. 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中的索引C是个接口,直接抛出异常。
  2. 如果通过第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  3. 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  4. 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时查找结束,抛出异常
  5. 否则,宣告查找失败,抛出异常

接口方法解析

以类C为例

  1. 与类方法解析不同,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
  2. 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  3. 否则,在接口C的父接口中递归查找,直到java.lang.Object类为止。

初始化

前面所有的类加载过程,除了加载阶段用户可以通过自定义加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码。

初始化阶段就是执行类构造器<cint>()方法的过程

  • <cint>()方法是由编译器自动收集类中所有类变量的赋值动作静态语句块中的语句合并产生的。
  • 编译器收集的顺序与语句在源文件中出现的顺序一致。
  • 静态语言块只能访问到定义在静态语句块之前的变量,定义在静态语句块之后的变量只能赋值,但不能访问。如下
    public class Test {
      static {
          i = 0;                      //给变量赋值可以正常编译通过
          System.out.print(i);        //这句编译器将提示"非法向前引用"
      }
      static int i = 1;
    }
    
  • <cint>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,他不需要显式地调用父类构造器,虚拟机会保证在子类的<cint>()方法执行之前,父类的<cint>()方法已经执行完毕。
  • 由于父类的<cint>()方法先执行,也就是说父类中定义的静态语句块要优先于子类的变量赋值操作。
  • 如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <cint>()方法。
  • 对于接口而言,执行接口的<cint>()方法并不会保证父类的<cint>()方法已经执行完毕。只有使用父类中定义的变量时,父接口才会初始化。接口的实现类初始化时也不会执行接口的<cint>()方法。
  • 在多线程环境执行类的<cint>()方法时,虚拟机会保证只有一个线程区去执行这个类的<cint>()方法,所以可能会造成多个线程阻塞的情况。