深入理解Java虚拟机 - 类加载机制

2,056 阅读12分钟

代码编译的结果从本地机器码转换为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。

概述

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

类加载的时机

类加载的整个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载7个过程,其中验证、准备和解析统称为连接。
虚拟机没有对什么时候进行类的加载有强制约束,但是对于初始化阶段,虚拟机规范则是严格规定了有且只有5中情况必须立即对类进行初始化(加载、验证、准备和初始化自然得在初始化前完成):

  1. 遇到new、getstatic、putstatic和invokestatic这四条字节码指令时,如果类没有进行过初始化,则需要触发其初始化(初始化自然存在类的加载)。这四条指令最常见的场景:使用new关键字实例化对象、获取或设置一个类的静态字段(被final修饰的除外)的时候和使用一个类的静态方法时。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果没有对类进行过初始化,则触发初始化。
  3. 当初始化一个子类时,发现其父类没有初始化时,需先触发父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(含有main方法的类)时,虚拟机会先初始化这个类。
  5. 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

类加载的过程

加载

类加载阶段,虚拟机需要完成以下3件事件:

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

一个非数组类的加载,既可以使用虚拟机提供的引导类加载器来完成,也可以自定义类加载器完成(即重写一个类加载器的loadClass方法)。对于数组类而言,情况有所不一样,数组类本身不通过类加载器创建,而是由虚拟机自己创建。

验证

验证是连接阶段的第一步,目的是保证Class文件的字节流包含的信息符合当前虚拟机的要求,保证输入的字节流能正确被解析并存储于方法区。验证阶段主要包括以下4个阶段:文件格式验证、元数据验证、字节码验证、符号引用验证。

  • 文件格式验证
    第一阶段验证字节流是否符合Class文件的格式规范,并且能当前被虚拟机处理。这一阶段主要验证点:
  1. 是否以魔数开头
  2. 版本是否在本机虚拟机处理范围内
  3. 指向常量的各种索引值是否有指向不存在的常量
  4. ......
  • 元数据验证
    第二阶段主要是对类的元数据信息进行语义分析,保证不存在不符合Java语言规范的元数据。验证点有:
  1. 这个类是否有父类
  2. 这个类是否继承了不允许被继承的类(被final修饰的类)
  3. 如果这个类不是抽象类,是否实现了其父类或继承的接口要求实现的方法
  4. ......
  • 字节码验证
    第三个阶段是验证过程中最复杂的阶段,主要目的是通过数据流和控制流分析程序语义是否合法。这个阶段对类的方法体进行校验,保证被校验方法运行时不会危害虚拟机:
  1. 保证跳转指令不会跳转到方法体以外的字节码指令
  2. 保证方法体中的类型转换时是正确的
  3. ......
  • 符号引用验证
    最后一个阶段发生在虚拟机将符号引用转换为直接引用的时候,这个转换动作发生在连接的第三个阶段 - 解析。符号引用可以看做是对常量池中各种符号引用进行校验,验证点有:
  1. 符号引用中通过字符串描述的全限定名是否找到对应的类或接口
  2. 符号引用中的类、字段、方法的访问性是否可被当前类访问
  3. ......

准备

准备阶段是正式为类变量(被static修饰的变量,不包括实例变量)分配内存并设置类变量初始值的阶段,这些内存在方法区进行分配。还有,这里所说的初始值通常情况下是指数据类型的零值

public static int value = 123

value变量在准备阶段后的初始值为0,而不是123,因为这个时候并未开始执行任何java方法,而把value赋值为123的putstatic指令时程序被编译后,存放于类构造器方法中的,所以把value赋值为123的操作是在初始化阶段才执行的。
上面提到,通常情况下是数据类型的零值,但是有一些特殊情况就不一样:如果类变量被final修饰,在准备阶段 ,类变量就会被初始化为指定的值

public static final int value = 123;

在准备阶段,value的值就会被赋值为123.

解析

解析阶段就是虚拟机将常量池内的符号引用替换为直接引用的过程。虚拟机规范并未对什么时候进行解析阶段有规定,只要求了**在执行anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokeestatic、involevirtual、ldc、ldc_w、multianewarray、new、putstatic和putfield这16个用于操作符号引用的字节码指令之前,先对他们所使用的符号引用进行解析 **。所以虚拟机可以根据需要来判断是在类被加载器加载时就对符号引用进行解析或是在符号引用在被使用前才去解析。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行解析。

  • 类或接口的解析
    假设当前代码所处的类为D,如果想把一个从未解析过的符号引用N解析到一个类或接口C的直接引用,虚拟机完成整个解析阶段的过程分为以下3步:
  1. 如果C是不是一个数组类型,虚拟机将会把符号引用N的全项定类名传递给D的类加载器去加载这个类C。在加载的过程中由于需要验证,可能又会触发其他类的加载,一当加载过程出现错误,解析过程直接失败。
  2. 如果C是一个数组类型,数组元素也是对象类型的话,N的描述符将会是类似[Ljava/lang/Integer的形式。那将会按照第一点的规则加载数组元素类型,接着由虚拟机生成一个代表此数组维度和元素的数组对象
  3. 如果前面的步骤都没有出现错误,在解析完成前还需要进行符号引用的验证,确认D是否具备对C的访问权限,如果D没有对C的访问权限,抛出java.lang.IllegalAccessEroor异常。
  • 字段解析
    要解析一个未被解析过的字段的符号引用。首先会对字段表内的class_index项索引的CONSTANT_Class_info符号引用解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口的符号引用出现异常,都会导致字段解析的失败。如果这个类或接口解析成功,将对这个字段所属的类或接口用C表示,然后对C进行后续的字段搜索:
  1. 如果C本身就包含了简单名称和字段描述符都与目标字段相同的字段,则返回这个字段的 直接引用,查找结束
  2. 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索每个接口和它的父接口,然后按照步骤1去查找
  3. 否则,如果C不是object类的话,按照继承关系从下往上递归搜索其父类,然后按照步骤1去查找
  4. 否则,查找失败,抛出java.lang.NoSuchFieldError异常。
  • 类方法解析
    类方法的解析的第一个步骤与字段解析一样,也需要先解析出类方法表的claaa_index索引的方法所属类或接口的符号引用,如果解析成功,用C表示这个类,接下来虚拟机按照以下步骤进行类方法的搜索:
  1. 在类C中查找是否有简单名称和描述符都与目标匹配的方法,如果有返回这个方法的直接引用,查找结束
  2. 否则在类C的父类中递归查找
  3. 否则在类C的接口或父接口中查找
  4. 否则查找失败,抛出java.lang.NoSuchMethodError异常。
  • 接口方法解析
    接口方法解析与类方法解析类方法解析类似,这里不再冗余。

初始化

初始化阶段是类加载过程的最后一步。在前面的类加载过程中,除了在加载阶段可以自动定义加载器参与类的加载过程外,其余的动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java代码
在准备阶段,变量已经被赋值为系统要求的零值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源。或者说初始化阶段是执行类构造器方法的过程。

  1. 方法是由编译器自动收集类中的所有类变量的复制操作和静态语句块(static{})中的所有语句合并而生的。静态语句块只能访问到定义在静态语句前的变量,定义在它之后的变量,只能在静态语句块中赋值而不能访问。
  2. 方法与类的构造函数不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的方法执行前执行父类的方法。
  3. 方法并不是必须的,如果一个类没有静态语句块,也没有变量的赋值操作,编译器可以不为这个类生成方法。
  4. 虚拟机会保证一个类的方法在多线程环境下被正确地加锁、同步,如果多线程同时去初始化一个类,只会有一个线程执行方法。

类与类加载器

虚拟机设计团队把类加载阶段的通过一个类的全限定名获取此类的二进制字节流这个动作放到Java虚拟机外部实现,以便让开发人员自己决定如何获取所需要的类,实现这个动作的代码模块称为“类加载器”。
对于任意一个类,都需要加载它的类加载器和这个类本身一同确定其所在虚拟机的唯一性。通俗地说,比较两个类是否相等,只有在相同的类加载器的前提下才有意义,否则,即使这两个类来自于同一个Class文件,被同一个虚拟机加载,只要类加载器不一样,这两个类就不可能相等。

双亲委派模型

从虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器,是虚拟机的一部分;另一种是其他的类加载器,独立于虚拟机之外,而且全都继承于抽象类java.lang.ClassLoader.
从开发人员的角度来看,绝大部分java程序都会使用到以下3种系统提供的类加载器:

  • 启动类加载器
    这个类负责将放在<JAVA_HOME>\lib目录下的并且被虚拟机识别的(按照文件名识别,名字不符合的类库即使放在lib目录下也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被java程序直接引用。

  • 拓展类加载器
    它负责加载<JAVA_HOME>\lib\ext目录下的所有类库,开发者可以直接使用拓展类加载器

  • 引用程序类加载器
    它负责加载用户类路径(ClassPath)下所指定的类库,开发者可以直接使用。如果程序中没有自定义自己的类加载器,一般情况下这个就是程序默认的类加载器。

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器、但是这里的类加载器之间的父子关系不是以继承关系实现的,而是使用组合关系来复用父类加载器。

  • 双亲委派模型的工作流程:
    如果一个类加载器收到了一个类加载请求,它不会自己去加载这个类,而是将请求委派给它的父类加载器去加载,每一个层次的类加载器都是这样,因此所有的类加载请求最终都会落到顶层的启动类加载器,只有当父类加载器五法加载这个请求时(它的搜索范围中没有找到所需的类),子加载器才会尝试自己去加载。使用双亲委派的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。