JVM虚拟机之虚拟机类加载机制
概述
约定一:,在实际情况中,每个 Class 文件都有代表着 Java 语言中的一个类或接口的可能,后文中直接 对“类型”的描述都同时蕴含着类和接口的可能性,而需要对类和接口分开描述的场景会特别指明;
约定二:所提到的“Class文件”并非特指某个存在于 具体磁盘中的文件,而应当是一串二进制字节流,无论其以何种形式存在,包括但不限于磁盘文件、 网络、数据库、内存或者动态产生等。
类的生命周期
- 加载 (Loading)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
- 初始化 (Initialization)
- 使用(Using)
- 卸载(Unloading)

加载与初始化的时机
-
关于在什么情况下需要开始类加载过程的第一个阶段“加载”,《Java虚拟机规范》中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。
-
但是对于初始化阶段,《Java虚拟机规范》 则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
- 遇到
new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。new:在 Java 代码中使用 new 关键字实例化对象的时候会生成对应的new字节码指令。getstatic、putstatic:读取或设置一个类型的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外) 的时候。invokestatic:调用一个类型的静态方法的时候。
- 使用 java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
- 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
- 当使用 JDK 7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
- 当一个接口中定义了 JDK 8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
- 遇到
package com.suwian.classinit;
/**
* 被动使用类字段演示一:
* 通过子类引用父类的静态字段,不会导致子类初始化
**/
class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
/**
* 非主动使用类字段演示
**/
public class NotInitialization {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
} SuperClass init!
123 -XX:+TraceClassLoading 参数观察到此操作是会导致子类加载的,如下图:
package com.suwian.classinit;
/**
* 被动使用类字段演示二:
* 通过数组定义来引用类,不会触发此类的初始化
**/
public class NotInitialization2 {
public static void main(String[] args) {
SuperClass[] sca = new SuperClass[10];
}
} anewarray 触发。这个类代表了一个元素类型为com.suwian.SuperClass 的一维数组。package com.suwian.classinit;
/**
* 被动使用类字段演示三:
* 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
**/
class ConstClass {
static {
System.out.println("ConstClass init!");
}
public static final String HELLOWORLD = "hello world";
}
/**
* 非主动使用类字段演示
**/
public class NotInitialization3 {
public static void main(String[] args) {
System.out.println(ConstClass.HELLOWORLD);
}
} 0 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
3 ldc #4 <hello world>
5 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
8 return - 接口也有初始化过程, 这点与类是一致的。
- 编译器仍然会为接口生成“<clinit>()”类构造器 ,用于初始化接口中所定义的成员变量。
- 接口与类真正有所区别的是:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。
类加载的过程
加载
概述
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
- 从 ZIP 压缩包中读取:这很常见,最终成为日后 JAR、EAR、WAR格式的基础。
- 从网络中获取:这种场景最典型的应用就是Web Applet。
- 运行时计算生成:这种场景使用得最多的就是动态代理技术,在 java.lang.reflect.Proxy 中,就是用了ProxyGenerator.generateProxyClass() 来为特定接口生成形式为 “*$Proxy” 的代理类的二进制字节流。
- 由其他文件生成:典型场景是JSP应用,由JSP文件生成对应的Class文件。
- 从数据库中读取:这种场景相对少见些,例如有些中间件服务器(如SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。
- 可以从加密文件中获取:这是典型的防 Class 文件被反编译的保护措施,通过加载时解密 Class 文件来保障程序运行逻辑不被窥探。
数组类的加载
元素类型(Element Type,指的是数组去掉所有维度的类型)
组件类型(Component Type,指的是数组去掉一个维度的类型)
例如:
// strArr 的元素类型和组件类型都是 String String[] strArr = new String[10]; // intArr 的元素类型是 int,组件类型是 int[] int[][] intArr = new int[10][10]; // doubleArr 的元素类型是 double,组件类型是 double[][] double[][][] doubleArr = new double[10][10][10]
- 如果数组的组件类型是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组 C 将被标识在加载该组件类型的类加载器的类名称空间上(这点很重要,一个类型必须与类加载器一起确定唯一性)。
- 如果数组的组件类型不是引用类型(例如 int[] 数组的组件类型为 int),Java 虚拟机将会把数组 C 标记为与引导类加载器关联。
- 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为 public,可被所有的类和接口访问到。
验证
概述
《Java虚拟机规范》的早期版本(第1、2版)对这个阶段的检验指导是相当模糊和笼统的,规范中仅列举了一些对 Class 文件格式的静态和结构化的约束,要求虚拟机验证到输入的字节流如不符合Class文件格式的约束,就应当抛出一个 java.lang.VerifyError 异常或其子类异常,但具体应当检查哪些内容、如何检查、何时进行检查等,都没有足够具体的要求和明确的说明。直到2011年《Java虚拟机规范(Java SE 7版)》出版,规范中大幅增加了验证过程的描述(篇幅从不到10页增加到130 页),这时验证阶段的约束和验证规则才变得具体起来。
文件格式验证
- 是否以魔数 0xCAFEBABE 开头。
- 主、次版本号是否在当前 Java 虚拟机接受范围之内。
- 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
- CONSTANT_Utf8_info 型的常量中是否有不符合 UTF-8 编码的数据。
- Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息。
元数据验证
-
这个类是否有父类(除了java.lang.Object 之外,所有的类都应当有父类)。
-
这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)。
-
如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
-
类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
字节码验证
-
保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个 int 类型的数据,使用时却按 long 类型来加载入本地变量表中”这样的情况。从字节码指令的层面上来说,保证不会出现类似:
bipush 128这样的指令(bipush指令的操作数值范围在[-128,127]之间)。 -
保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
-
保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
这里涉及了离散数学中一个很著名的问题——“停机问题”(Halting Problem),即不能通过程序准确地检查出程序是否能在有限的时间之内结束运行。在我们讨论字节码校验的上下文语境里,通俗一点的解释是通过程序去校验程序逻辑是无法做到绝对准确的,不可能用程序来准确判定一段程序是否存在 Bug。
-XX:-UseSplitVerifier 选项来关闭掉这项优化,或者使用参数 -XX:+FailOverToOldVerifier 要求在类型校验失败的时候退回到旧的类型推导方式进行校验。而到了 JDK 7 之后,尽管虚拟机中仍然保留着类型推导验证器的代码,但是对于主版本号大于 50(对应JDK 6)的 Class 文件,使用类型检查来完成数据流分析校验则是唯一的选择,不允许再退回到原来的类型推导的校验方式。符号引用验证
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
- 符号引用中的类、字段、方法的可访问性(private、protected、public、<package>)是否可被当前类访问。
验证阶段的调优
-Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。准备
- 在JDK 7及之前,变量所使用的内存都在方法区中进行分配的,在 HotSpot 虚拟机中对应的就是永久代。
- 在JDK 8及之后,类变量则会随着 Class 对象一起存放在 Java 堆中。
- 这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。
public static final int value = 123;
此时,在准备阶段虚拟机就会将 value 赋值为 ConstantValue 设置的值。
解析
-
符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同, 但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的 Class 文件格式中。
-
直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
需要操作到符号引用的字节码指令:anewarray、 checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、 invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield 和 putstatic
- 如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直能够成功;
- 如果第一次解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常,哪怕这个请求的符号在后来已成功加载进 Java 虚拟机内存之中。
- 对
invokedynamic指令来说,当碰到某个前面已经由invokedynamic指令 触发过解析的符号引用时,这个解析结果对于其他invokedynamic指令来说是无效的,必须等到程序实际运行到这条指令时,解析动作才能进行。
| 符号引用类型 | 常量池类型名称 |
|---|---|
| 类或接口 | CONSTANT_Class_info |
| 字段 | CONSTANT_Fieldref_info |
| 类方法 | CONSTANT_Methodref_info |
| 接口方法 | CONSTANT_InterfaceMethodref_info |
| 方法类型 | CONSTANT_MethodType_info |
| 方法句柄 | CONSTANT_MethodHandle_info |
| 调用点限定符 | CONSTANT_Dynamic_info和CONSTANT_InvokeDynamic_info |
类或接口的解析(CONSTANT_Class_info)
- 如果 C 不是一个数组类型,那虚拟机将会把代表 N 的全限定名传递给 D 的类加载器去加载这个类 C(或者接口 C)。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。
- 如果 C 是一个数组类型,并且数组的元素类型为对象,也就是 N 的描述符会是类似 “[Ljava/lang/Integer” 的形式,那将会按照第一点的规则加载数组元素类型。如果 N 的描述符如前面所假设的形式,需要加载的元素类型就是 “java.lang.Integer”,接着由虚拟机生成一个代表该数组维度和元素的数组对象。
- 如果上面两步没有出现任何异常,那么 C 在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认 D 是否具备对 C 的访问权限。如果发现不具备访问权限,将抛出 java.lang.IllegalAccessError 异常。
- JDK 9 引入模块化之后,除了验证是否是 public 修饰的,还得验证模块之间的访问权限。
字段解析(CONSTANT_Fieldref_info)
- 如果 C 本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 否则,如果在 C 中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口, 如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 否则,如果 C 不是 java.lang.Object 的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 如果前三步成功获取到这个字段的直接引用,之后就会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出 java.lang.IllegalAccessError 异常。否则,查找失败,抛出 java.lang.NoSuchFieldError 异常。
方法解析(CONSTANT_Methodref_info)
- 类 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在类 C 的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在类 C 实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出 java.lang.AbstractMethodError 异常。
- 如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出java.lang.IllegalAccessError异常。否则,宣告方法查找失败,抛出 java.lang.NoSuchMethodError。
接口方法解析(CONSTANT_InterfaceMethodref_info)
- 在接口 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方 法的直接引用,查找结束。
- 否则,在接口 C 的父接口中递归查找,直到 java.lang.Object 类(接口方法的查找范围也会包括 Object类中的方法)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。由于Java的接口允许多重继承,如果 C 的不同父接口中存有多个简单名称和描述符都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找,《Java 虚拟机规范》中并 没有进一步规则约束应该返回哪一个接口方法。
- 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。
- 在JDK 9 之前,Java接口中的所有方法都默认是 public 的,也没有模块化的访问约束,所以不存在访问权限的问题,接口方法的符号解析就不可能抛出 java.lang.IllegalAccessError 异常。
- 在JDK 9 之后,JDK 9 中增加了接口的静态私有方法,也有了模块化的访问约束,所以从 JDK 9 起,接口方法的访问也完全有可能因访问权限控制而出现 java.lang.IllegalAccessError 异常。
初始化
- 类的初始化阶段是类加载过程的最后一个步骤。
- 初始化阶段就是执行类构造器 <clinit>() 方法的过程。<clinit>() 方法是由编译器(前端编译器:javac)自动收集类中的所有类变量的赋值动作和静态语句块( static{} 块)中的语句合并产生的。
- 赋值的顺序是和语句在源文件中出现的顺序是一样的。
- 静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问(否则编译器会提示“非法前向引用”)。
- Java 虚拟机会保证在子类的 <clinit>() 方法执行前,父类的 <clinit>() 方法已经执行完毕。所以在Java 虚拟机中第一个被执行的 <clinit>() 方法的类型肯定是 java.lang.Object。
- 如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法。
- 执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。
- 接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法。
- Java 虚拟机必须保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行完毕 <clinit>() 方法。
- 同一个类加载器下,一个类型只会被初始化一次,即 <clinit>() 方法只会执行一次。
类加载器
- 一种是启动类加载器(BootstrapClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;
- 另外一种就是其他所有的类加载器,这些类加载器都由 Java 语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。
- 启动类加载器(Bootstrap Class Loader):这个类加载器负责加载存放在 <JAVA_HOME>\lib目录,或者被
-Xbootclasspath参数所指定的路径中存放的,而且是 Java 虚拟机能够识别的(按照文件名识别,如 rt.jar、tools.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机的内存中。 - 扩展类加载器(Extension Class Loader):这个类加载器是在类 sun.misc.Launcher$ExtClassLoader 中以 Java 代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者被 java.ext.dirs 系统变量所指定的路径中所有的类库。
- 应用程序类加载器(Application Class Loader):这个类加载器由 sun.misc.Launcher$AppClassLoader 来实现。由于应用程序类加载器是 ClassLoader 类中的 getSystemClassLoader() 方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
- 最初是为了满足Java Applet的需求而设计出来的(在今天用在浏览器上的 Java Applet 技术基本上已经被淘汰)。
- 在类层次划分、OSGi、程序热部署、代码加密等领域大放异彩。
类与类加载器
- 如果两个类“相等”,那么这两个类的全限定名和加载这两个类的类加载器一定是一样的,这两个条件确定了一个类的唯一性。
- 这里所指的“相等”,包括代表类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括了使用 instanceof 关键字做对象所属关系判定等各种情况。
双亲委派机制
JDK 9 之前

protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 首先,检查请求的类是否已经被加载过了
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException 说明父类加载器无法完成加载请求
}
if (c == null) {
// 在父类加载器无法加载时 再调用本身的findClass方法来进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
} JDK 9 之后

- 扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代。既然整个JDK都基于模块化进行构建(原来的 rt.jar 和t ools.jar 被拆分 成数十个 JMOD 文件),其中的 Java 类库就已天然地满足了可扩展的需求,那自然无须再保留 <JAVA_HOME>\lib\ext 目录,此前使用这个目录或者 java.ext.dirs 系统变量来扩展JDK功能的机制已经没 有继续存在的价值了,用来加载这部分类库的扩展类加载器也完成了它的历史使命。
- 平台类加载器和应用程序类加载器都不再派生自 java.net.URLClassLoader。现在启动类加载器、平台类加载器、应用程序类加载器全都继承于 jdk.internal.loader.BuiltinClassLoader,在BuiltinClassLoader中实现了新的模块化架构下类如何从模块中 加载的逻辑,以及模块中资源可访问性的处理。
- 有了 “BootClassLoader” 的存在,启动类加载器现在是在 Java 虚拟机内部和 Java 类库共同协作实现的类加载器,尽管有了 BootClassLoader 这样的 Java 类,但为了与之前的代码保持兼容,所有在获取启动类加载器的场景(譬如 Object.class.getClassLoader() )中仍然会返回 null 来代替,而不会得到BootClassLoader 的实例。


对双亲委派系统的破坏
-
双亲委派模型是在 JDK 1.2 之后才出现,但是此前类加载器的概念和抽象类 java.lang.ClassLoader 则在 Java 的第一个版本中就已经存在,用户可以直接通过重写 loadClass() 方法来打破双亲委派模型,JDK 1.2 之后官方就不建议这么做而是让用户去重写findClass() 方法。
-
Java 的 SPI(Service Provider Interface)的代码是由启动类加载器加载的,但是由于它需要通过 JNDI 来调用其他厂商实现的 JNDI 服务,这些服务通常是放在 ClassPath 路径下的,这时就需要启动类加载器去逆向委托线程上下文类加载器 (Thread Context ClassLoader)来加载 SPI 的具体实现,比较常见的就是 JDBC 的加载。
-
代码热替换(Hot Swap)、模块热部署(Hot Deployment)等引起的,OSGi 实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为 Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。在 OSGi 环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGi 将按照下面的顺序进行类搜索:
- 将以 java.* 开头的类,委派给父类加载器加载。
- 否则,将委派列表名单内的类,委派给父类加载器加载。
- 否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
- 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
- 否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器 加载。
- 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
- 否则,类查找失败。
作者: Suwian 发表日期:2023 年 3 月 24 日