JVM(5):类加载
1. 类加载的时机
一个类型从被加载到卸载的过程中,会经历加载 ( $Loading$ )、验证 ( $Verification$ )、准备 ( $Preparation$ )、解析 ( $Resolution$ )、初始化 ( $Initialization$ )、使用 ( $Using$ ) 和卸载 ( $Unloading$ ) 七个阶段,其中可以把验证、准备、解析统称为连接 ( $Linking$ )。这些阶段之间可以交叉进行。加载、验证、准备、初始化、卸载的顺序是确定的,解析阶段则不一定,可能开始在初始化之后。
《Java
虚拟机规范》中并没有规定什么时候需要进行加载,但是严格规定了有且只有六种情况需要进行初始化:
- 执行 $new$ 、$getstatic$ 、$putstatic$ 、$invokestatic$ 四条字节码指令时,如果类型没有初始化,需要先进行初始化阶段。这四条字节码指令分别对应:使用 $new$ 创建对象实例、读取和设置静态字段 ( $final$ 字段除外,因为它在编译器就已被置入常量池 )、调用静态方法;
- 对类型进行反射调用时,如果类型没有初始化,需要先进行初始化阶段;
- 初始化类时如果父类没有初始化,需要先初始化父类;
- 虚拟机启动时,主类需要先进行初始化;
JDK 7
之后,如果一个 $java.lang.invoke.MethodHandle$ 实例的解析结果为 $REF_-getStatic$ 、$REF_-putStatic$ 、$REF_-newInvokeSpecial$ 四种类型的方法句柄时,如果该方法句柄对应的类没有初始化,需要先进行初始化阶段;JDK 8
之后,接口中包含 $default$ 方法时,在其实现类发生初始化前,需要先进行接口的初始化阶段。
这六种情况触发初始化场景的行为称为主动引用,除此之外的引用称为被动引用。例如通过子类访问父类的静态字段并不会触发子类的初始化,只会触发父类的初始化;声明一个引用数组并不会触发引用类型的初始化,而是初始化一个数组类型。
与类相同,接口也具有初始化过程。虽然接口中不能使用静态代码块,但是编译器仍然会生成 <$clinit$>$(\ )$ 类构造器,用于初始化成员变量。在前面的六种主动引用触发场景中,只有在初始化父类时才会对接口进行初始化。但是一个接口在初始化时,并不要求其父接口全部初始化,只有在用到时才需要进行初始化。
2. 类加载的过程
2.1 加载
类加载过程,虚拟机需要完成以下三件事:
- 通过类的全限定名获取二进制字节流;
- 将字节流所代表的静态存储结构转为方法区的运行时数据结构;
- 在内存中生成一个代表这个类的 $java.lang.Class$ 对象,作为方法区中该类各种数据的访问入口。
由于没有限制获取字节流的位置,因此可以从许多地方以许多方式获取,例如从Jar
包、War
包获取,从JSP
文件获取、从数据库中读取等。相较于其他阶段,非数组类型的类加载的可控性很强,可以通过自定义的类加载器完成。数组类的加载十分特殊,它并不依赖类加载器,而是由JVM
在内存中直接构造。但是数组类仍然与类加载器有着密切关系,因为其元素类型仍然需要依赖类加载完成加载。一个数组类的创建规则如下:
- 如果数组的组件类型(数组去掉一个维度的类型)是引用类型,需要依赖类加载器递归加载,并将该数组标识在组件类加载器的类名称空间上;
- 如果数组的组件类型为基本类型,则数组会被标识为与引导类加载器关联;
- 数组类的可访问性与组件类型的可访问性一致,如果组件类型为基本类型,则默认为 $public$ 。
加载阶段与连接阶段是交叉进行的,但是一些夹在加载阶段中进行的动作仍然属于连接阶段的一部分。加载阶段与连接阶段保持着一个严格的开始顺序,即前者必须先于后者开始。
2.2 验证
验证是连接阶段的第一步,确保Class
文件中的信息符合要求,保证执行后不会危害虚拟机安全,一些不安全的行为如数组越界访问、不允许的类型转换等会被验证。验证阶段在类加载过程中占了很大一部分比重,但它是值得的,因为保证了JVM
的安全。
虽然验证阶段十分重要,但并非必要执行的,因为一个程序在被发布时往往会进行大量测试和验证,因此可以通过 $-Xverify:none$ 参数关闭大部分类验证措施,从而缩短虚拟机类加载时间。
2.2.1 文件格式验证
文件格式验证要验证字节流是否符合Class
文件格式的规范,并且能否被当前版本的虚拟机处理。可能包括以下验证点:
- 魔数;
- 主次版本号;
- 常量类型;
- 索引值所指向的常量是否存在或者类型是否合理。
2.2.2 元数据验证
元数据验证是对字节码描述的信息进行语义分析,保证其描述的信息符合要求。可能包括以下验证点:
- 类是否有父类 ( 除了 $java.lang.Object$ );
- 类是否继承了不允许被继承的类;
- 实现类是否实现了抽象类中的抽象方法;
- 子类是否出现不合规则的重载,是否覆盖了 $final$ 字段。
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$ 的直接引用,那么需要进行以下步骤:
- $C$ 非数组类型,
JVM
会将 $N$ 的全限定名传递给 $D$ 的类加载器,加载 $C$ ,在这个过程中可能还会加载其他相关类; - $C$ 为数组类型,并且数组元素为非基本数据类型,那么 $N$ 的描述符会是类似于 $[Ljava/lang/Integer$ 的形式,类加载器会加载 $Integer$ 类型,再由
JVM
生成一个代表该数组维度和元素的数组对象; - 如果上两步没有异常,那么
JVM
中已经生成 $C$ 了,但是还要进行符号引用验证,确认 $D$ 是否具备对 $C$ 的访问权限。
JDK 9
中引入的模块化使得 $public$ 类型也并不是可以被任何位置访问了,所以还要检查模块间的访问权限。
2.4.2 字段解析
解析一个字段符号引用,首先会对字段表中 $class_-index$ 项中索引的 $CONSTANT_-Class_-info$ 符号引用进行解析,也就是字段所属的类或接口的符号引用。将该类或接口用 $C$ 表示,接下来会进行以下步骤:
- 如果 $C$ 本身包含了简单名称与字段描述符都与目标匹配的字段,返回字段的直接引用;
- 如果 $C$ 实现了接口,会按照继承关系由下往上递归搜索接口,直到找到相匹配的字段;
- 如果 $C$ 不是 $java.lang.Object$ ,按照继承关系由下往上递归搜索父类,直到找到相匹配的字段。
- 查找失败,抛出 $java.lang.NoSuchFieldError$ 异常。
如果返回引用,则会对字段进行权限验证。实际情况中,往往会采取更加严格的约束,如同名字段出现在不同的接口或者父类中,Javac
可能会拒绝编译。
2.4.3 方法解析
解析一个方法符号引用,首先会对字段表中 $class_-index$ 项中索引的方法所属的类或接口的符号引用进行解析。将类或接口用 $C$ 表示,接下来会进行以下步骤:
- 如果 $C$ 为接口,抛出 $java.lang.IncompatibleClassChangeError$ 异常;
- 在 $C$ 中查找简单名称和描述符都与目标相匹配的方法,如果有则返回方法引用;
- 在 $C$ 的父类中递归查找相匹配的方法;
- 在 $C$ 的实现列表及其父接口中递归查找相匹配的方法,如果找到,说明 $C$ 为抽象类,抛出 $java.lang.AbstractMethodError$ 异常;
- 查找失败,抛出 $java.lang.NoSuchMethodError$ 异常。
如果成功查找到了方法,会对方法进行权限验证。
2.4.4 接口方法解析
解析一个接口方法符号引用,首先会对字段表中 $class_-index$ 项中索引的方法所属的类或接口的符号引用进行解析。将类或接口用 $C$ 表示,接下来会进行以下步骤:
- 如果 $C$ 为类,抛出 $java.lang.IncompatibleClassChangeError$ 异常;
- 在 $C$ 中查找简单名称和描述符都与目标相匹配的方法,如果有则返回;
- 在 $C$ 的父接口中递归查找,直到 $java.lang.Object$ 类。如果存在多个匹配方法,返回其中一个;
- 查找失败,抛出 $java.lang.NoSuchMethodError$ 异常。
JDK 9
之前接口方法都是 $public$ 的,不存在访问权限问题。JDK 9
之后添加了接口的静态私有方法以及模块化访问约束,因此也需要对接口方法进行权限验证。
2.5 初始化
初始化是类加载的最后一个步骤。在之前的阶段中,除了用户可以通过自定义类加载器的方式局部参与外,都由JVM
主导。而直到初始化阶段,程序代码才真正开始执行,用户才开始拥有了主导权。初始化阶段可以简单的理解为执行类构造器 <$clinit$>$(\ )$ 的过程,这是个由Javac
自动生成的方法。
- <$clinit$>$(\ )$ 方法是由编译器自动收集类中所有类变量的复制动作和静态代码块中的语句合并产生的,顺序由源文件决定。需要注意的是,静态代码块中的语句只能访问到块之前声明的变量,之后的声明的变量可以赋值,但不能访问;
- <$clinit$>$(\ )$ 方法不需要显示调用父类构造器,
JVM
保证父类会在子类之前调用,因此第一个执行的肯定是 $java.lang.Object$ 类; - <$clinit$>$(\ )$ 方法并非必须,如果类中没有静态代码块和变量赋值操作,那么编译器可以不生成;
- 接口中虽然不存在静态代码块,但是仍然有变量初始化赋值的操作,因此也可以生成 <$clinit$>$(\ )$ 方法。但是与类不同的是,只有父接口中的变量被使用时才会初始化父接口,这个原则同样适用于接口的实现类;
- <$clinit$>$(\ )$ 被保证是同步的。当一个线程执行时,其他线程会被阻塞。同一个类加载器下,一个类的初始化方法只会被执行一次。
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
之前,绝大多数程序都会使用到以下三个系统提供的类加载器:
- 启动类加载器,负责将存放在 <$JAVA_-HOME$>$\backslash lib$ 目录或者被 $-Xbootclasspath$ 参数指定的路径中存放的能够被
JVM
识别的类库加载到虚拟机内存中,比如 $java.lang$ 、 $java.util$ 等。启动类加载器无法被Java
程序直接引用,如果用户需要将加载请求委托给引导类加载器处理,可以直接使用 $null$ ; - 扩展类加载器 ( $Extension\ \ Class\ \ Loader$ ),在类 $sun.misc.Launcher\$ExtClassLoader$ 中以
Java
代码的形式实现,负责加载 $<JAVA_-HOME>\backslash lib\backslash ext$ 目录中,或者被 $java.exit.dirs$ 系统变量指定的路径中所有的类库,比如 $swing$ 、xml
解析器等。JDK 9
之后这种扩展机制被模块取代。由于是Java
代码实现,因此开发者可以直接使用; - 应用程序类加载器 ( $Application\ \ Class\ \ Loader$ ),由 $sun.misc.Launcher\$AppClassLoader$ 实现,是 $ClassLoader.getSystemClassLoader(\ )$ 方法的返回值,负责加载用户类路径 ( $ClassPath$ ) 上所有的类库,开发者同样可以在代码中使用,是程序中默认的类加载器。
加载位于网络上的静态文件服务器提供的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$ 不同,那么就可以做到隔离线程之间的类。