回到顶部 暗色模式

JVM(5):类加载

1. 类加载的时机

        一个类型从被加载到卸载的过程中,会经历加载 ( $Loading$ )、验证 ( $Verification$ )、准备 ( $Preparation$ )、解析 ( $Resolution$ )、初始化 ( $Initialization$ )、使用 ( $Using$ ) 和卸载 ( $Unloading$ ) 七个阶段,其中可以把验证、准备、解析统称为连接 ( $Linking$ )。这些阶段之间可以交叉进行。加载、验证、准备、初始化、卸载的顺序是确定的,解析阶段则不一定,可能开始在初始化之后。
        《Java虚拟机规范》中并没有规定什么时候需要进行加载,但是严格规定了有且只有六种情况需要进行初始化:

        这六种情况触发初始化场景的行为称为主动引用,除此之外的引用称为被动引用。例如通过子类访问父类的静态字段并不会触发子类的初始化,只会触发父类的初始化;声明一个引用数组并不会触发引用类型的初始化,而是初始化一个数组类型。
        与类相同,接口也具有初始化过程。虽然接口中不能使用静态代码块,但是编译器仍然会生成 <$clinit$>$(\ )$ 类构造器,用于初始化成员变量。在前面的六种主动引用触发场景中,只有在初始化父类时才会对接口进行初始化。但是一个接口在初始化时,并不要求其父接口全部初始化,只有在用到时才需要进行初始化。

2. 类加载的过程

2.1 加载

        类加载过程,虚拟机需要完成以下三件事:

        由于没有限制获取字节流的位置,因此可以从许多地方以许多方式获取,例如从Jar包、War包获取,从JSP文件获取、从数据库中读取等。相较于其他阶段,非数组类型的类加载的可控性很强,可以通过自定义的类加载器完成。数组类的加载十分特殊,它并不依赖类加载器,而是由JVM在内存中直接构造。但是数组类仍然与类加载器有着密切关系,因为其元素类型仍然需要依赖类加载完成加载。一个数组类的创建规则如下:

        加载阶段与连接阶段是交叉进行的,但是一些夹在加载阶段中进行的动作仍然属于连接阶段的一部分。加载阶段与连接阶段保持着一个严格的开始顺序,即前者必须先于后者开始。

2.2 验证

        验证是连接阶段的第一步,确保Class文件中的信息符合要求,保证执行后不会危害虚拟机安全,一些不安全的行为如数组越界访问、不允许的类型转换等会被验证。验证阶段在类加载过程中占了很大一部分比重,但它是值得的,因为保证了JVM的安全。
        虽然验证阶段十分重要,但并非必要执行的,因为一个程序在被发布时往往会进行大量测试和验证,因此可以通过 $-Xverify:none$ 参数关闭大部分类验证措施,从而缩短虚拟机类加载时间。

2.2.1 文件格式验证

        文件格式验证要验证字节流是否符合Class文件格式的规范,并且能否被当前版本的虚拟机处理。可能包括以下验证点:

2.2.2 元数据验证

        元数据验证是对字节码描述的信息进行语义分析,保证其描述的信息符合要求。可能包括以下验证点:

2.2.3 字节码验证

        字节码验证是最复杂的一个阶段,主要通过数据流分析和控制流分析确定语义是否合法、符合逻辑。保证类方法在运行时不会做出危害虚拟机安全的行为也是在这一阶段进行的。可能的验证点包括:

        即使一个类通过了字节码验证,也不能保证其是绝对安全的。由于数据流分析和控制流分析的高度复杂性,JDK 6之后方法体Code属性表内添加了一个新属性:$StackMapTable$ ,用于描述所有基本块开始时本地变量表和操作栈应有的状态。这样JVM在验证时只需要检查 $StackMapTable$ 中的记录是否合法即可。

2.2.4 符号引用验证

        符号引用验证发生在虚拟机将符号引用转换为直接引用时(这个转换发生在解析阶段)。可能包括以下验证点:

        符号引用验证主要是为了保证解析的正常执行。如果无法通过符号引用验证,JVM会抛出 $java.lang.IncompatibleClassChangError$ 的子类的异常,如 $IllegalAccessError$ 、$NoSuchFieldError$ 、$NoSuchMethodError$ 等。

2.3 准备

        准备阶段为类中定义的静态变量分配内存并设置初始值。概念上,变量使用的内存都应该分配在方法区中,JDK 7之前,方法区在永久代中,JDK 8之后,方法区则处于Java堆之中。这里进行赋值的是类变量,而非实例变量,所以通常情况下初始值是零值,因为准备阶段并未执行初始化方法。初始化静态变量是 <$clinit$>$(\ )$ 方法中的 $putstatic$ 指令。如果要使初始值为非零值,那么需要将字段声明为 $final$ 。

2.4 解析

        解析是JVM将常量池内符号引用替换为直接引用的过程。符号引用 ( $Symboic\ \ References$ ) 可以是任何形式的字面量,其使用一组符号描述其引用目标。符号引用的引用目标并不要求已经加载到虚拟机内存中,不同的虚拟机能接受的符号引用都是一致的。与符号引用相对应的是直接引用 ( $Direct\ \ References$ ),可以是一个直接指向目标的指针、相对偏移量,或者是一个能间接定位到目标的句柄。直接引用与虚拟机的内存布局直接挂钩,同一个符号引用在不同虚拟机上可能会翻译出不同的直接引用。和符号引用不同,直接引用引用的目标必须是已经加载在虚拟机内存中的。
        《Java虚拟机规范》中规定了在进行操作符号引用的操作之前必须先对符号引用进行解析,但并未规定其具体时机,因此解析可以发生在许多时刻。对同一个符号引用进行多次解析是很常见的,虚拟机可以对第一次解析的结果进行缓存,从而避免重复解析。
        在触发解析的指令中,$invokedynamic$ 是特殊的,因为该指令用于支持动态语言,而动态语言需要在程序运行时解析,因此由该指令触发的解析并不能被其他 $invokedynamic$ 指令使用。与动态解析对应,其余指令触发的解析可以称为静态解析,即在程序运行之前进行解析。

2.4.1 类/接口的解析

        假设当前代码所处类为 $D$ ,对符号引用 $N$ 进行第一次解析,将其解析为类/接口 $C$ 的直接引用,那么需要进行以下步骤:

  1. $C$ 非数组类型,JVM会将 $N$ 的全限定名传递给 $D$ 的类加载器,加载 $C$ ,在这个过程中可能还会加载其他相关类;
  2. $C$ 为数组类型,并且数组元素为非基本数据类型,那么 $N$ 的描述符会是类似于 $[Ljava/lang/Integer$ 的形式,类加载器会加载 $Integer$ 类型,再由JVM生成一个代表该数组维度和元素的数组对象;
  3. 如果上两步没有异常,那么JVM中已经生成 $C$ 了,但是还要进行符号引用验证,确认 $D$ 是否具备对 $C$ 的访问权限。

        JDK 9中引入的模块化使得 $public$ 类型也并不是可以被任何位置访问了,所以还要检查模块间的访问权限。

2.4.2 字段解析

        解析一个字段符号引用,首先会对字段表中 $class_-index$ 项中索引的 $CONSTANT_-Class_-info$ 符号引用进行解析,也就是字段所属的类或接口的符号引用。将该类或接口用 $C$ 表示,接下来会进行以下步骤:

  1. 如果 $C$ 本身包含了简单名称与字段描述符都与目标匹配的字段,返回字段的直接引用;
  2. 如果 $C$ 实现了接口,会按照继承关系由下往上递归搜索接口,直到找到相匹配的字段;
  3. 如果 $C$ 不是 $java.lang.Object$ ,按照继承关系由下往上递归搜索父类,直到找到相匹配的字段。
  4. 查找失败,抛出 $java.lang.NoSuchFieldError$ 异常。

        如果返回引用,则会对字段进行权限验证。实际情况中,往往会采取更加严格的约束,如同名字段出现在不同的接口或者父类中,Javac可能会拒绝编译。

2.4.3 方法解析

        解析一个方法符号引用,首先会对字段表中 $class_-index$ 项中索引的方法所属的类或接口的符号引用进行解析。将类或接口用 $C$ 表示,接下来会进行以下步骤:

  1. 如果 $C$ 为接口,抛出 $java.lang.IncompatibleClassChangeError$ 异常;
  2. 在 $C$ 中查找简单名称和描述符都与目标相匹配的方法,如果有则返回方法引用;
  3. 在 $C$ 的父类中递归查找相匹配的方法;
  4. 在 $C$ 的实现列表及其父接口中递归查找相匹配的方法,如果找到,说明 $C$ 为抽象类,抛出 $java.lang.AbstractMethodError$ 异常;
  5. 查找失败,抛出 $java.lang.NoSuchMethodError$ 异常。

        如果成功查找到了方法,会对方法进行权限验证。

2.4.4 接口方法解析

        解析一个接口方法符号引用,首先会对字段表中 $class_-index$ 项中索引的方法所属的类或接口的符号引用进行解析。将类或接口用 $C$ 表示,接下来会进行以下步骤:

  1. 如果 $C$ 为类,抛出 $java.lang.IncompatibleClassChangeError$ 异常;
  2. 在 $C$ 中查找简单名称和描述符都与目标相匹配的方法,如果有则返回;
  3. 在 $C$ 的父接口中递归查找,直到 $java.lang.Object$ 类。如果存在多个匹配方法,返回其中一个;
  4. 查找失败,抛出 $java.lang.NoSuchMethodError$ 异常。

        JDK 9之前接口方法都是 $public$ 的,不存在访问权限问题。JDK 9之后添加了接口的静态私有方法以及模块化访问约束,因此也需要对接口方法进行权限验证。

2.5 初始化

        初始化是类加载的最后一个步骤。在之前的阶段中,除了用户可以通过自定义类加载器的方式局部参与外,都由JVM主导。而直到初始化阶段,程序代码才真正开始执行,用户才开始拥有了主导权。初始化阶段可以简单的理解为执行类构造器 <$clinit$>$(\ )$ 的过程,这是个由Javac自动生成的方法。

2.6 卸载

        卸载该类即类的 $Class$ 对象被回收,需要满足 $3$ 个条件:

        也就是说,JVM自带的类加载器加载的类是不会被卸载的。

3. 类加载器

        类加载器 ( $Class\ \ Loader$ ) 负责通过类的全限定名获取描述该类的二进制字节流。

3.1 类与类加载器

        对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同决定其在JVM中的唯一性。每一个类加载器都有一个独立的类名称空间,对于两个类,如果它们来自不同的类加载器,那么它们就不相等。

3.2 双亲委派模型

        在JVM看来,只有两种类加载器:启动类加载器 ( $Bootstrap\ \ ClassLoader$ ) 和其他所有的类加载器。启动类加载器是使用C++实现的,属于虚拟机的一部分。其他所有类加载器由Java实现,且全部继承自 $java.lang.ClassLoader$ 。自JDK 1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构。在JDK 8之前,绝大多数程序都会使用到以下三个系统提供的类加载器:

        加载位于网络上的静态文件服务器提供的jar包和 $Class$ 文件,JDK内置了一个 $URLClassLoader$ ,用户只需要传递规范的网络路径给构造器,就可以加载远程类库了。$URLClassLoader$ 也支持加载本地路径,$ExtensionClassLoader$ 和 $ApplicationClassLoader$ 都是 $URLClassLoader$ 的子类,只是将路径替换为本地路径。
        类加载器的双亲委派模型 ( $Parents\ \ Delegation\ \ Model$ ) 要求除了顶层的启动类加载器外,其余的类加载器都要有自己的父类加载器。加载器之间的父子关系不是以继承方式实现的,而是以组合关系复用父加载器代码。双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,会将请求委派给父类加载器完成,最终会传送到最顶的启动类加载器。当父类加载器无法完成时会进行反馈,这时子加载器才会尝试自己完成加载。
        JDK 9之前的Java应用都是由这三种类加载器互相配合完成的。用户也可以添加自定义的类加载器,如增加磁盘位置之外的Class文件来源等。$ClassLoader$ 里面有三个重要的方法:$loadClass(\ )$ 、$findClass(\ )$ 和 $defineClass(\ )$ 。$loadClass(\ )$ 是加载目标类的入口,首先会查找当前 $ClassLoader$ 以及它的双亲里面是否已经加载了目标类,如果没有找到就让双亲尝试加载,如果双亲无法加载,就会调用 $findClass(\ )$ 让自定义类加载器加载目标类。$findClass(\ )$ 需要子类覆盖,获取目标类的字节码。$defineClass(\ )$ 方法则负责将字节码转换成 $Class$ 对象。
        $Thread$ 里面有一个 $contextClassLoader$ 字段,从父线程继承,除了显式使用之外不会用到。$contextClassLoader$ 可以做到跨线程共享类,如果线程之间的 $contextClassLoader$ 不同,那么就可以做到隔离线程之间的类。

JVM(5):类加载