JVM - 类加载子系统
# 1. JVM类加载子系统概述
Java虚拟机(JVM)的体系结构中,类加载子系统(Class Loader Subsystem) 扮演着至关重要的角色。它负责将编译后的 Java 字节码(.class
文件)加载到 JVM 内存中,并进行后续的处理,为程序的执行做好准备。
上图展示了类加载子系统在 JVM 中的位置。更详细的 JVM 结构图如下:
如果我们想要从零开始构建一个 Java 虚拟机,核心需要关注哪些部分呢?
类加载器(ClassLoader):
- 核心职责:这是 JVM 的基础模块之一,其主要任务是将
.class
文件从文件系统、网络或其他来源加载到 JVM 内存中。加载后,它会将字节码数据转换成 JVM 内部的数据结构,并在方法区(或元空间)创建一个对应的java.lang.Class
对象实例。这个Class
对象就代表了加载的类,是后续所有操作(如反射、实例化)的入口。 - 核心机制 - 双亲委派模型:
- 工作流程:当一个类加载器收到加载类的请求时,它不会自己首先尝试加载,而是将这个请求委托给它的父加载器去完成。每一层级的加载器都会如此,层层向上委托,直到请求被传递到顶层的启动类加载器(Bootstrap ClassLoader)。只有当父加载器反馈自己无法完成这个加载请求时(在其搜索范围内没有找到所需的类),子加载器才会尝试自己去加载。
- 主要优势:
- 避免重复加载:保证同一个类在 JVM 中只会被加载一次,节省内存资源。
- 安全性:防止核心 API(如
java.lang.String
)被用户自定义的同名类所覆盖或篡改,保证了 Java 平台的基础稳定性和安全性。
- 核心职责:这是 JVM 的基础模块之一,其主要任务是将
执行引擎(Execution Engine):
- 核心职责:执行引擎是 JVM 的核心执行单元,负责解释或编译执行由类加载器加载进来的字节码指令。它将字节码翻译成底层操作系统可以识别的本地机器指令,并驱动程序运行。
- 关键组件:
- 解释器(Interpreter):逐条读取字节码指令,解释一句执行一句。优点是启动速度快,无需编译等待,但执行效率相对较低。
- 即时编译器(JIT Compiler - Just-In-Time Compiler):为了提高执行效率,JVM 会在运行时监测“热点代码”(被频繁执行的方法或代码块),并将其编译成本地机器码缓存起来。后续执行这些代码时,直接运行编译后的机器码,大大提升性能。HotSpot 虚拟机包含 C1(Client Compiler)和 C2(Server Compiler)两种 JIT 编译器。
- 垃圾收集器(Garbage Collector - GC):GC 是 Java 内存管理的核心。它自动追踪不再被引用的对象,并回收它们所占用的内存空间,从而避免了手动内存管理带来的复杂性和内存泄漏风险。不同的 GC 算法(如 Serial, Parallel, CMS, G1, ZGC, Shenandoah)适用于不同的应用场景。
# 2. 类加载器子系统的核心作用
类加载器子系统是 JVM 实现“一次编译,到处运行”的关键部分,它的核心职责是将存储在外部(如文件系统、网络)的 .class
文件加载到 JVM 内存中。
关键点:
- 加载来源:类加载器能够从本地文件系统、网络 URL 等多种来源获取
.class
文件的二进制字节流。 - 文件标识:
.class
文件必须符合 Java 虚拟机规范定义的格式,其头部包含一个特定的魔数(Magic Number):0xCAFEBABE
,类加载器会首先检查这个标识。 - 加载与执行分离:类加载器仅负责加载
.class
文件并生成对应的Class
对象。加载后的类是否能够被正确执行,则是由**执行引擎(Execution Engine)**来决定的。类加载器不关心类是否能运行,执行引擎也不关心类是如何被加载进来的。
类加载器的具体功能:
- 加载
.class
文件:根据类的全限定名(fully qualified name),定位并读取对应的.class
文件,获取其二进制字节流。 - 存储类信息:将从
.class
文件中解析出的类元信息(如类名、父类、接口、字段、方法等)存储在 JVM 的方法区(Method Area)(在 JDK 8 及之后,这部分主要存储在元空间 Metaspace)。 - 生成
Class
对象:在加载过程中,为每个加载的类在**堆(Heap)**内存中创建一个对应的java.lang.Class
对象实例。这个Class
对象封装了方法区内该类的所有数据结构,并作为 Java 程序访问类元信息的入口(例如通过反射)。 - 管理运行时常量池:方法区中还包含运行时常量池(Runtime Constant Pool),用于存储编译期生成的各种字面量(如字符串、数字常量)和符号引用。类加载时,
.class
文件中的常量池信息会被加载到运行时常量池。
生动的类比:
可以把类加载器想象成一位“星探”或“快递员”。星探负责发掘艺人(相当于加载 .class
文件),但艺人能否走红(类能否成功运行)取决于艺人自身的实力和机遇(由执行引擎决定)。快递员负责将包裹(.class
文件)送达目的地(JVM内存),但包裹里的物品是否符合要求、能否使用,则需要收件人(执行引擎)来判断。
类加载器的层次结构示意图:
(这张图展示了类加载器之间典型的父子层级关系,但注意这并非继承关系)
.class
文件加载到 JVM 的过程图示
下图形象地展示了 .class
文件如何通过类加载器加载到 JVM 中,并最终成为方法区中的元数据模板:
- 硬盘上的
.class
文件:可以看作是设计师绘制在纸上的蓝图(模板)。 - 加载到 JVM:当程序需要使用这个类时,类加载器(好比运输工具)会将这份蓝图加载到 JVM 中。
- 方法区的元数据模板:加载后的类信息被存储在方法区,成为一份“DNA 元数据模板”。JVM 根据这份模板可以在堆内存中创建出任意数量、结构相同的对象实例。
Class、ClassLoader 与对象实例的关系
.class
文件通过某个 ClassLoader 加载到 JVM 中,生成代表该类的唯一Class
对象(存储在堆中,其元数据在方法区)。- 程序可以根据这个
Class
对象 在堆中实例化出多个该类的对象实例。 - 每个对象实例都可以通过调用其
getClass()
方法获取到对应的那个Class
对象。 - 这个
Class
对象 又可以通过调用其getClassLoader()
方法获取到当初加载它的那个 ClassLoader 实例(对于启动类加载器加载的类,此方法返回null
)。
# 3. 详解类的加载过程
让我们通过一个简单的 Java 代码示例来理解其加载过程:
// 文件名: HelloLoader.java
public class HelloLoader {
// main 方法是程序的入口
public static void main(String[] args) {
// 打印一条消息
System.out.println("谢谢 ClassLoader 加载我...");
}
}
2
3
4
5
6
7
8
当运行 java HelloLoader
时,HelloLoader.class
文件的加载过程大致如下:
- 装载(Loading):找到
HelloLoader.class
文件并将其字节码加载到内存。 - 链接(Linking):
- 验证(Verify):确保字节码的格式正确性、符合 JVM 规范且无安全风险。如果文件被篡改或格式错误,会抛出异常。
- 准备(Prepare):为类中的静态变量分配内存并设置默认初始值(零值)。
- 解析(Resolve):将常量池中的符号引用替换为直接引用(内存地址)。
- 初始化(Initialization):执行类的初始化代码块(
<clinit>
方法),为静态变量赋予程序员指定的初始值。 - 调用
main
方法:初始化完成后,执行引擎调用HelloLoader
类的main
方法,程序开始执行。 - 输出结果:
main
方法中的println
语句被执行,输出指定信息。
完整的类加载过程流程图如下所示:
# 3.1 加载阶段 (Loading)
加载阶段是整个类加载过程的第一个环节,其核心目标是将类的二进制数据从外部读入内存,并转换为 JVM 内部所需的数据结构。
加载阶段主要完成以下三件事情:
通过类的全限定名获取定义此类的二进制字节流
- 过程:JVM 根据需要加载的类的全限定名(例如
com.example.MyClass
),通过相应的类加载器在类路径(Classpath)、JAR 包、网络或其他来源中查找对应的.class
文件。找到后,读取文件的内容,形成二进制字节流。 - 目的:将类的原始定义数据从存储介质读取到内存中,这是后续处理的基础。
- 过程:JVM 根据需要加载的类的全限定名(例如
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 过程:JVM 解析读取到的二进制字节流,提取其中定义的类信息,如类的访问修饰符、父类信息、实现的接口、字段(成员变量)、方法、构造器、常量池等。这些信息在
.class
文件中是以一种静态的、预定义的格式存储的。JVM 将这些静态结构信息转换成自己内部使用的一种运行时数据结构,并存储在方法区(或元空间)中。 - 目的:将静态的类定义信息转换为 JVM 在运行时能够理解和使用的数据结构,供后续的链接、初始化和执行阶段使用。
- 过程:JVM 解析读取到的二进制字节流,提取其中定义的类信息,如类的访问修饰符、父类信息、实现的接口、字段(成员变量)、方法、构造器、常量池等。这些信息在
在内存中(具体是在堆区)生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口- 过程:对于每一个加载到方法区的类,JVM 都会在堆内存中创建一个对应的
java.lang.Class
类的实例。这个Class
对象非常重要,它封装了在方法区中该类的所有运行时数据结构(元数据)。 - 目的:
Class
对象是 Java 反射机制的基础,也是程序代码访问和操作类信息的入口点。开发者可以通过这个Class
对象获取类的名称、方法、字段等信息,或者创建类的实例。
- 过程:对于每一个加载到方法区的类,JVM 都会在堆内存中创建一个对应的
关键概念解释
- 二进制字节流 (Binary Byte Stream):
.class
文件本质上是按照特定格式组织的二进制数据流,包含了编译后的 Java 代码和类的元信息。 - 静态存储结构 (Static Storage Structure):指在
.class
文件中定义的、在编译时就已经确定的类的结构信息,如字段表、方法表、常量池等。 - 方法区的运行时数据结构 (Runtime Data Structures in Method Area):JVM 在内存的方法区(或元空间)中,根据从
.class
文件解析出的信息,动态创建和组织的数据结构。这些结构包含了类的详细信息,供 JVM 在运行时使用。 - 元数据 (Metadata):描述数据的数据。在 Java 中,类的元数据就是描述类本身的信息,例如:
- 类的元数据:类名、访问修饰符 (
public
,private
等)、父类、实现的接口列表、注解信息等。 - 字段的元数据:字段名、类型、修饰符 (
static
,final
等)、注解信息等。 - 方法的元数据:方法名、返回类型、参数列表(类型、名称、顺序)、修饰符 (
public
,static
,synchronized
等)、抛出的异常类型、方法体字节码、注解信息等。 - 注解信息:附加在类、方法、字段等程序元素上的额外描述信息,本身也是一种元数据。
- 类的元数据:类名、访问修饰符 (
加载 .class
文件的常见方式:
- 从本地文件系统直接加载:最常见的方式,从
CLASSPATH
或指定路径下的.class
文件加载。 - 通过网络获取:例如从远程服务器下载字节码,Web Applet 和某些远程调用框架会使用。
- 从 ZIP 压缩包中读取:JVM 可以直接从
.zip
,.jar
,.war
等归档文件中加载.class
文件。这是 Java 应用部署的标准格式。 - 运行时计算生成:在程序运行时动态生成类的字节码。最典型的应用是动态代理技术(如 JDK Proxy, CGLIB)。
- 由其他文件生成:例如 JSP 文件在运行时会被编译成 Servlet 的
.class
文件进行加载。 - 从专有数据库中提取
.class
文件:某些特定应用场景可能将类文件存储在数据库中。 - 从加密文件中获取:为了保护代码不被轻易反编译,可以将
.class
文件加密存储,在加载时由自定义类加载器进行解密。
# 3.2 链接阶段 (Linking)
链接阶段发生在加载阶段之后,初始化阶段之前。它的主要任务是将加载到内存的类的二进制数据合并到 JVM 的运行时状态中。链接过程包括三个子阶段:验证、准备和解析。
# 3.2.1 验证 (Verify)
验证是链接阶段的第一步,也是确保 JVM 安全的关键环节。其目的是确保被加载的 .class
文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证主要包括以下四个方面的检查:
文件格式验证 (Format Verification):
- 检查字节流是否符合 Class 文件格式规范,例如:
- 是否以魔数
0xCAFEBABE
开头。 - 主、次版本号是否在当前 JVM 处理范围之内。
- 常量池中的常量类型是否不被支持。
- 指向常量的各种索引值是否指向了不存在的常量或不符合类型的常量。
- 是否以魔数
- 工具:可以使用如
Binary Viewer
或jclasslib
(Bytecode Viewer 插件 for IDEA/Eclipse) 等工具查看.class
文件的二进制内容。 - 下图展示了使用二进制查看器看到的
.class
文件开头的魔数CA FE BA BE
: - 如果加载的
.class
文件开头不是CAFEBABE
,或者文件结构损坏,验证将失败,抛出java.lang.VerifyError
。 - 使用 IDEA 的
jclasslib
插件查看字节码:安装后,编译
.java
文件生成.class
文件,然后在 IDEA 的View
菜单中选择Show Bytecode With Jclasslib
即可查看:
- 检查字节流是否符合 Class 文件格式规范,例如:
元数据验证 (Metadata Verification):
- 对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。例如:
- 这个类是否有父类(除了
java.lang.Object
之外,所有类都应当有父类)。 - 这个类的父类是否继承了不允许被继承的类(被
final
修饰的类)。 - 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的
final
字段,或者出现不符合规则的方法重载)。
- 这个类是否有父类(除了
- 对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。例如:
字节码验证 (Bytecode Verification):
- 这是整个验证过程中最复杂的一个阶段,主要工作是进行数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
- 例如,保证跳转指令不会跳转到方法体以外的字节码指令上;保证方法体中的类型转换是有效的。
- JDK 6 之后,引入了 StackMapTable 属性,用于优化字节码验证过程,使得类型检查可以在编译期完成大部分,减轻运行时的验证负担。
符号引用验证 (Symbolic Reference Verification):
- 发生在解析阶段将符号引用转换为直接引用的时候。这个验证可以看作是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。
- 例如:
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
- 符号引用中的类、字段、方法的访问性(
private
,protected
,public
,default
)是否可被当前类访问。
- 如果符号引用验证失败,将会抛出一个
java.lang.IncompatibleClassChangeError
的子类异常,如IllegalAccessError
,NoSuchFieldError
,NoSuchMethodError
等。
验证的重要性:验证阶段是非常重要的,但不是必须的(可以通过 -Xverify:none
参数关闭大部分验证)。关闭验证可以缩短类加载时间,但在生产环境中,除非能确保所有加载的代码都已充分验证和可信,否则不建议关闭。
# 3.2.2 准备 (Prepare)
准备阶段是正式为类中定义的静态变量(static variables)分配内存并设置其初始默认值的过程。
关键点:
- 内存分配对象:此阶段只处理类变量(静态变量),即被
static
修饰的变量。不包括实例变量,实例变量将在对象实例化时随着对象一起分配在 Java 堆中。 - 内存分配位置:这些类变量所使用的内存都将在方法区(或元空间)中进行分配。
- 设置初始值:这里所设置的初始值通常是数据类型的零值(zero value),而不是代码中显式赋予的值。
- 例如,对于
public static int value = 123;
,在准备阶段value
的值会被设置为0
,而不是123
。将value
赋值为123
的putstatic
指令是程序被编译后,存放在类构造器<clinit>()
方法之中,这个赋值动作将在初始化阶段才会执行。 - 各种基本数据类型的零值:
int
:0
long
:0L
short
:(short) 0
char
:'\u0000'
byte
:(byte) 0
boolean
:false
float
:0.0f
double
:0.0d
reference
(对象引用):null
- 例如,对于
示例代码说明
// 文件名: HelloApp.java
public class HelloApp {
// 静态变量 a
private static int a = 1; // 在准备阶段,a 被赋予默认值 0;在初始化阶段,才会被赋值为 1
// main 方法
public static void main(String[] args) {
// 输出 a 的值
System.out.println(a); // 输出会是 1 (初始化阶段完成后)
}
}
2
3
4
5
6
7
8
9
10
11
- 在准备阶段,JVM 在方法区为静态变量
a
分配内存,并将其值设置为0
。 - 在后续的初始化阶段,执行
<clinit>()
方法时,才会执行a = 1
的赋值操作。
特殊情况:final static
常量
如果类字段的字段属性表中存在 ConstantValue
属性(即同时被 final
和 static
修饰),那么在准备阶段,变量就会被初始化为 ConstantValue
属性所指定的值。
// 文件名: FinalExample.java
public class FinalExample {
// final static 常量 b
private static final int b = 2; // 在准备阶段,b 就直接被初始化为 2
// final static 常量 (编译期无法确定值,则和普通 static 变量一样)
private static final String c = System.getProperty("user.name"); // 准备阶段为 null, 初始化阶段才调用方法赋值
}
2
3
4
5
6
7
- 对于
b
,因为它是一个编译期常量(final static
且值在编译时可知),javac
编译时会为其生成ConstantValue
属性。因此,在准备阶段b
就直接被赋值为2
。 - 对于
c
,虽然也是final static
,但其值需要在运行时调用方法才能确定,编译期无法得知。所以它没有ConstantValue
属性,在准备阶段会被赋值为null
,在初始化阶段才会被赋予实际值。
总结准备阶段:
核心任务是为静态变量在方法区分配内存并设置零值。但对于编译期常量(final static
且值可知),则直接赋予代码中指定的值。实例变量不在此阶段处理。
# 3.2.3 解析 (Resolve)
解析阶段是 Java 虚拟机将常量池内的**符号引用(Symbolic References)替换为直接引用(Direct References)**的过程。
理解符号引用和直接引用
符号引用 (Symbolic Reference):
- 符号引用以一组符号来描述所引用的目标。这些符号可以是任何形式的字面量,只要能无歧义地定位到目标即可。
- 符号引用的内容存储在
.class
文件的常量池中。 - 它与虚拟机实现的内存布局无关。引用的目标并不一定已经加载到内存中。
- 常见的符号引用包括:
CONSTANT_Class_info
:类的全限定名。CONSTANT_Fieldref_info
:字段的描述符(类型)和名称。CONSTANT_Methodref_info
:方法的描述符(参数类型、返回值类型)和名称。CONSTANT_InterfaceMethodref_info
:接口方法的描述符和名称。CONSTANT_MethodType_info
:方法类型。CONSTANT_MethodHandle_info
:方法句柄。CONSTANT_Dynamic_info
和CONSTANT_InvokeDynamic_info
:动态调用点。
直接引用 (Direct Reference):
- 直接引用是直接指向目标内存地址的指针、相对偏移量或者是一个能间接定位到目标的句柄。
- 直接引用与虚拟机实现的内存布局直接相关。
- 如果有了直接引用,那说明引用的目标必定已经存在于内存之中。
解析的时机
JVM 规范并没有规定解析阶段发生的具体时间,只要求了在执行某些依赖符号引用的字节码指令(如 getfield
, putfield
, getstatic
, putstatic
, invoke*
系列等)之前,必须对它们所使用的符号引用进行解析。因此,虚拟机实现可以根据需要自行判断是在类被加载器加载时就对常量池中的符号引用进行解析(饿汉式/早期解析),还是等到一个符号引用将要被实际使用前才去解析它(懒汉式/晚期解析)。
对于同一个符号引用,如果之前已经被成功解析过,后续的解析请求通常会直接使用缓存的结果,避免重复解析。但对于 invokedynamic
指令,它用于支持动态语言,每次执行都需要重新解析,以保证动态性。
解析的目标
解析动作主要针对以下七类符号引用进行:
- 类或接口的解析 (
CONSTANT_Class_info
) - 字段解析 (
CONSTANT_Fieldref_info
) - 类方法解析 (
CONSTANT_Methodref_info
) - 接口方法解析 (
CONSTANT_InterfaceMethodref_info
) - 方法类型解析 (
CONSTANT_MethodType_info
) - 方法句柄解析 (
CONSTANT_MethodHandle_info
) - 调用点限定符解析 (
CONSTANT_InvokeDynamic_info
)
解析的过程相对复杂,涉及到权限验证、查找匹配的方法/字段等步骤。如果解析失败(例如找不到对应的类、字段、方法,或者没有访问权限),会抛出相应的异常(如 NoSuchFieldError
, NoSuchMethodError
, IllegalAccessError
等)。
总结解析阶段: 核心任务是将常量池中描述性的符号引用转换为实际内存地址的直接引用,为后续的字节码执行提供直接的目标访问信息。这个过程可能在类加载时进行,也可能延迟到首次使用符号引用时进行。
# 3.3 初始化阶段 (Initialization)
初始化阶段是类加载过程的最后一步。在此阶段,Java 虚拟机才真正开始执行类中定义的 Java 程序代码(或者说是字节码),主导权从 JVM 移交给应用程序。
核心任务:执行类构造器 <clinit>()
方法
<clinit>()
方法的由来:- 初始化阶段就是执行类构造器
<clinit>()
方法的过程。 <clinit>()
方法并不是由程序员在 Java 代码中直接编写的,而是 Javac 编译器 在编译.java
文件时,自动收集 类中所有 静态变量的赋值动作 和 静态语句块(static{}
块)中的语句 合并产生的。- 编译器收集的顺序是由语句在源文件中出现的顺序决定的。静态语句块中只能访问到定义在静态语句块之前的变量;定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
- 初始化阶段就是执行类构造器
<clinit>()
与<init>()
的区别:<clinit>()
(Class Initialization Method - 类初始化方法):- 针对类级别的初始化,主要用于初始化静态变量和执行静态代码块。
- 由编译器自动生成。
- 在类加载的初始化阶段执行。
- JVM 会保证在多线程环境下,一个类的
<clinit>()
方法会被正确地加锁、同步。如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()
方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()
方法完毕。
<init>()
(Instance Initialization Method - 实例初始化方法):- 针对对象实例级别的初始化,主要用于初始化成员变量(实例变量)和执行普通代码块以及构造函数的代码。
- 对应 Java 代码中的构造函数。如果类没有显式定义构造函数,编译器会添加一个默认的空参构造函数。
- 在创建类的实例时(例如使用
new
关键字)执行。
<clinit>()
的执行时机:- JVM 严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始),称为对类的主动引用。详见 6. 其他 部分。
父类
<clinit>()
优先执行:- JVM 会保证在子类的
<clinit>()
方法执行前,其父类的<clinit>()
方法已经执行完毕。 - 因此,在 JVM 中第一个被执行的
<clinit>()
方法的类肯定是java.lang.Object
。
- JVM 会保证在子类的
接口的
<clinit>()
方法:- 接口中也可以有静态变量赋值和静态代码块,因此也可能生成
<clinit>()
方法。 - 但接口的
<clinit>()
执行与类不同:执行接口的<clinit>()
方法不需要先执行父接口的<clinit>()
方法,只有当真正使用到父接口的时候(如引用父接口中定义的常量)才会初始化父接口。 - 接口的实现类在初始化时,也一样不会执行接口的
<clinit>()
方法。
- 接口中也可以有静态变量赋值和静态代码块,因此也可能生成
<clinit>()
方法是可选的:- 如果一个类中没有静态变量赋值操作,也没有静态语句块,那么编译器可以不为这个类生成
<clinit>()
方法。
- 如果一个类中没有静态变量赋值操作,也没有静态语句块,那么编译器可以不为这个类生成
示例代码分析:静态变量初始化顺序
// 文件名: ClassInitTest.java
public class ClassInitTest {
// 静态变量 num,初始赋值为 1
private static int num = 1;
// 静态代码块
static {
num = 2; // 重新赋值 num
number = 20; // 赋值静态变量 number
System.out.println(num); // 访问 num (编译通过,因为 num 在此之前已定义) -> 输出 2
// System.out.println(number); // 访问 number (编译报错:非法的前向引用 Illegal forward reference)
// 因为 number 在此行之后才定义
}
// 静态变量 number,初始赋值为 10 (注意:定义在 static 块之后)
private static int number = 10;
// 主方法
public static void main(String[] args) {
System.out.println(ClassInitTest.num); // 输出 2
System.out.println(ClassInitTest.number); // 输出 10
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<clinit>()
方法的执行逻辑分析:
- 编译器收集:编译器按顺序收集静态变量赋值和静态块代码:
num = 1
(来自第 3 行)num = 2
(来自第 7 行)number = 20
(来自第 8 行)System.out.println(num)
(来自第 9 行)number = 10
(来自第 14 行)
- 按顺序执行:初始化阶段执行
<clinit>()
时,按收集的顺序执行:num
首先被赋值为1
。- 然后
num
被赋值为2
。 - 然后
number
被赋值为20
。 - 然后打印
num
的值,此时为2
。 - 最后
number
被赋值为10
。
main
方法执行:- 访问
ClassInitTest.num
时,其值为最后确定的2
。 - 访问
ClassInitTest.number
时,其值为最后确定的10
。
- 访问
关于非法前向引用:在静态块中,可以为在其之后定义的静态变量赋值 (number = 20;
),但不能在定义之前引用它 (System.out.println(number);
会报错)。这是因为虽然链接的准备阶段已经为 number
分配了内存并设了零值,但从 Java 语法层面,初始化必须按代码顺序进行。
示例代码分析:父子类静态初始化顺序
// 文件名: ClinitTest1.java
public class ClinitTest1 {
// 静态内部类 Father
static class Father {
// 静态变量 A,初始赋值为 1
public static int A = 1;
// 静态代码块
static {
A = 2; // 重新赋值 A
}
}
// 静态内部类 Son,继承自 Father
static class Son extends Father {
// 静态变量 b,赋值为 父类的静态变量 A
public static int b = A;
}
// 主方法
public static void main(String[] args) {
// 访问 Son 类的静态变量 b,触发 Son 的初始化
System.out.println(Son.b); // 输出 2
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
执行流程分析:
- 执行
main
方法,需要访问Son.b
。 - 访问类的静态变量属于主动使用,触发
Son
类的初始化。 - JVM 发现
Son
类有父类Father
,根据规则,必须先初始化父类Father
。 - 执行
Father
类的<clinit>()
方法:- 首先执行
A = 1
。 - 然后执行静态块中的
A = 2
。 Father
初始化完成,此时Father.A
的值为2
。
- 首先执行
- 执行
Son
类的<clinit>()
方法:- 执行
b = A
。此时读取到的Father.A
的值是2
,所以b
被赋值为2
。 Son
初始化完成。
- 执行
main
方法打印Son.b
的值,即2
。
反编译 Father
的 <clinit>
方法字节码大致如下:
// Father.<clinit>() 方法字节码片段
0: iconst_1 // 将常量 1 压入操作数栈
1: putstatic #2 <com/example/ClinitTest1$Father.A> // 将栈顶的 1 赋值给静态字段 A
4: iconst_2 // 将常量 2 压入操作数栈
5: putstatic #2 <com/example/ClinitTest1$Father.A> // 将栈顶的 2 赋值给静态字段 A
8: return // 方法返回
2
3
4
5
6
这清晰地显示了先赋值 1,再赋值 2 的过程。
# 初始化阶段的线程安全
JVM 内部实现确保了一个类的 <clinit>()
方法在多线程环境中能够被正确地加锁、同步。如果多个线程同时尝试初始化同一个类,只有一个线程会执行该类的 <clinit>()
方法,其他线程必须阻塞等待,直到活动线程执行完毕。
示例代码:模拟类初始化阻塞
// 文件名: DeadThreadTest.java
public class DeadThreadTest {
public static void main(String[] args) {
// 创建并启动线程 t1
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t 线程t1开始");
// 尝试创建 DeadThread 实例,会触发 DeadThread 类的初始化
new DeadThread();
}, "t1").start();
// 创建并启动线程 t2
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t 线程t2开始");
// 尝试创建 DeadThread 实例,也会触发 DeadThread 类的初始化
new DeadThread();
}, "t2").start();
}
}
// 一个包含阻塞静态代码块的类
class DeadThread {
static {
// 确保静态块会执行
if (true) {
System.out.println(Thread.currentThread().getName() + "\t 初始化当前类 DeadThread");
// 无限循环,模拟耗时的初始化操作或死锁
while (true) {
// 故意阻塞
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
可能的输出结果 (顺序可能不同):
线程t1开始
线程t2开始
t1 初始化当前类 DeadThread
2
3
或者
线程t2开始
线程t1开始
t2 初始化当前类 DeadThread
2
3
分析:
- 线程 t1 和 t2 都尝试创建
DeadThread
实例,这会触发DeadThread
类的初始化。 - JVM 的同步机制确保只有一个线程(例如 t1)能够进入并执行
DeadThread
类的<clinit>()
方法。 - 该线程会打印 "初始化当前类 DeadThread",然后进入无限循环,永远不会结束
<clinit>()
方法的执行。 - 另一个线程(例如 t2)会一直阻塞,等待 t1 完成初始化。由于 t1 永远无法完成,t2 将永远阻塞下去。
- 这证明了
<clinit>()
方法的执行是线程安全的,并且只会被执行一次。如果<clinit>()
方法的执行耗时很长,就可能会导致多个线程阻塞,在实际应用中要尽量避免这种情况。
# 实例变量(普通变量)何时初始化?
实例变量(非 static
修饰的成员变量)的初始化是在创建类的实例对象时进行的,包含在实例初始化方法 <init>()
(即构造函数)中。其初始化时机和顺序遵循以下规则(结合静态初始化):
- 父类静态:执行父类的
<clinit>()
方法(静态变量赋值和静态代码块),按代码顺序。 - 子类静态:执行子类的
<clinit>()
方法(静态变量赋值和静态代码块),按代码顺序。 - 父类实例:执行父类的
<init>()
方法(普通成员变量赋值、普通代码块、父类构造函数体),按代码顺序。 - 子类实例:执行子类的
<init>()
方法(普通成员变量赋值、普通代码块、子类构造函数体),按代码顺序。
简单来说:静态优先于实例,父类优先于子类,同级别按代码顺序。
# 4. 详解类加载器分类
JVM 规范将所有类加载器分为两大类:
- 引导类加载器 (Bootstrap ClassLoader):也称为启动类加载器,是 JVM 自身的一部分,通常由 C++ 实现。
- 自定义类加载器 (User-Defined ClassLoader):JVM 规范将所有派生自抽象类
java.lang.ClassLoader
的类加载器都划分为自定义类加载器。这包括了 Java 核心库提供的加载器(如扩展类加载器、系统类加载器)以及开发者自己编写的加载器。
但在实际应用和开发者视角中,我们通常接触和讨论的是以下三种层级的类加载器:
- 启动类加载器 (Bootstrap ClassLoader)
- 扩展类加载器 (Extension ClassLoader)
- 应用程序类加载器 (Application ClassLoader),也常被称为系统类加载器 (System ClassLoader)。
重要提示:上图展示的类加载器之间的层级关系(父子关系)是通过**组合(Composition)关系来实现的,而不是面向对象中的继承(Inheritance)**关系。例如,应用程序类加载器的实例内部持有一个扩展类加载器的实例作为其 parent
。
我们可以通过代码来获取和观察这几种常见的类加载器:
// 文件名: ClassLoaderTest.java
import java.lang.String; // 导入 String 类
public class ClassLoaderTest {
public static void main(String[] args) {
// 1. 获取系统类加载器 (应用程序类加载器)
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println("系统类加载器: " + systemClassLoader); // 输出: sun.misc.Launcher$AppClassLoader@...
// 2. 获取系统类加载器的父加载器 -> 扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println("扩展类加载器: " + extClassLoader); // 输出: sun.misc.Launcher$ExtClassLoader@...
// 3. 获取扩展类加载器的父加载器 -> 引导类加载器
// 引导类加载器由 C++ 实现,无法直接在 Java 代码中获取到其对象引用,通常返回 null
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println("引导类加载器: " + bootstrapClassLoader); // 输出: null
// 4. 获取当前类 (ClassLoaderTest) 的类加载器
// 对于用户自定义的类,通常由系统类加载器加载
ClassLoader currentClassLoader = ClassLoaderTest.class.getClassLoader();
System.out.println("当前类的加载器: " + currentClassLoader); // 输出: sun.misc.Launcher$AppClassLoader@... (与系统类加载器是同一个实例)
// 5. 获取 Java 核心类库中类 (如 String) 的类加载器
// 核心类库由引导类加载器加载
ClassLoader stringClassLoader = String.class.getClassLoader();
System.out.println("String 类的加载器: " + stringClassLoader); // 输出: null (表示由引导类加载器加载)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
代码输出示例 (内存地址 @...
每次运行可能不同):
系统类加载器: sun.misc.Launcher$AppClassLoader@18b4aac2
扩展类加载器: sun.misc.Launcher$ExtClassLoader@1540e19d
引导类加载器: null
当前类的加载器: sun.misc.Launcher$AppClassLoader@18b4aac2
String 类的加载器: null
2
3
4
5
分析结果:
- 系统类加载器和扩展类加载器都是
sun.misc.Launcher
的内部类实例。 - 尝试获取引导类加载器时返回
null
,这是规范定义的行为。 - 我们自己编写的
ClassLoaderTest
类是由系统类加载器加载的。 - Java 核心库中的
String
类,其getClassLoader()
返回null
,这表明它是由引导类加载器加载的。
形象比喻
可以把引导类加载器想象成 JVM 这个“皇宫”里的“内务府总管”,负责管理最核心的事务(加载核心库),它是系统自带的,外部人员(Java 代码)无法直接指挥它。而扩展类加载器和系统类加载器则像是“地方官员”,负责管理特定区域(扩展目录、类路径)的事务,它们是 Java 实现的,可以通过代码获取到。普通民众(用户自定义类)默认归“系统类加载器”这位“地方官”管理。
# 4.1 启动类加载器 (Bootstrap ClassLoader)
- 实现方式:这个类加载器是虚拟机自身的一部分,通常使用 C/C++ 语言实现,嵌套在 JVM 内核中。
- 加载范围:负责加载 Java 最核心的类库,这些类库位于
<JAVA_HOME>/jre/lib
目录下(如rt.jar
,resources.jar
)或者被-Xbootclasspath
参数所指定的路径中的类。这些类库是 JVM 运行的基础。 - 继承关系:它不继承自
java.lang.ClassLoader
类,因此通过 Java 代码无法直接获取到对它的引用。 - 父加载器:它没有父加载器,处于类加载器层级结构的最顶端。
- 特殊职责:负责加载扩展类加载器和应用程序类加载器,并将它们设定为自己的子加载器(逻辑上的,非继承)。
- 安全限制:出于安全考虑,启动类加载器默认只加载包名为
java
,javax
,sun
等开头的类,防止核心类库被篡改。
# 4.2 扩展类加载器 (Extension ClassLoader)
- 实现方式:由 Java 语言编写,具体的实现类是
sun.misc.Launcher$ExtClassLoader
。 - 继承关系:派生于
java.lang.ClassLoader
类。 - 父加载器:其父加载器被设定为启动类加载器(虽然代码中
getParent()
返回的是null
,但逻辑上父是 Bootstrap)。(更准确地说,ExtClassLoader 的 parent 成员变量确实是 null,它通过其他机制实现向上委托给 Bootstrap ClassLoader) [注:此处原文可能不准确,但按双亲委派模型理解,其逻辑父是 Bootstrap]。 修正理解: 确实ExtClassLoader
的getParent()
返回的是null
。它查找父加载器的方式可能与AppClassLoader
不同。在ClassLoader.loadClass()
的默认实现中,会检查parent
是否为null
,如果是,则尝试委托给 JVM 内建的加载器(即 Bootstrap)。 - 加载范围:负责加载
<JAVA_HOME>/jre/lib/ext
目录下的,或者被java.ext.dirs
系统属性所指定的路径中的所有类库(JAR 包)。开发者可以把自己开发的类库打包成 JAR 文件放入ext
目录,就会由扩展类加载器加载。
# 4.3 应用程序类加载器 (Application ClassLoader / System ClassLoader)
- 实现方式:也由 Java 语言编写,具体的实现类是
sun.misc.Launcher$AppClassLoader
。 - 继承关系:派生于
java.lang.ClassLoader
类。 - 父加载器:其父加载器被设定为扩展类加载器。可以通过
getParent()
方法验证。 - 加载范围:负责加载用户类路径 (Classpath) 上所指定的类库。类路径可以通过
-classpath
或-cp
命令行参数、CLASSPATH
环境变量或者MANIFEST.MF
文件中的Class-Path
属性来指定。 - 默认加载器:它是 Java 程序中默认的类加载器。我们自己编写的大多数 Java 类都是由应用程序类加载器加载的。
- 获取方式:可以通过
ClassLoader.getSystemClassLoader()
静态方法获取到该类加载器的实例。
# 4.4 用户自定义类加载器 (User-Defined ClassLoader)
虽然 Java 应用的类加载工作在大多数情况下由上述三种类加载器协作完成就足够了,但在某些特定场景下,开发者可能需要创建自己的类加载器来实现特殊的加载逻辑。
自定义类加载器的常见用途:
- 隔离加载类:在复杂的应用中(如 Tomcat、OSGi 等容器),不同的模块或应用可能依赖同一个库的不同版本。使用不同的自定义类加载器实例去加载这些版本,可以实现类的隔离,避免版本冲突。
- 修改类加载方式:实现类的热部署(Hot Swap)或热加载,即在不重启应用的情况下,重新加载修改后的类文件。
- 扩展加载源:使程序能从非标准来源加载类,例如从网络动态下载、从数据库读取、根据特定算法动态生成字节码等。
- 防止源码泄漏:可以将编译后的
.class
文件进行加密处理,然后通过自定义类加载器在加载时进行解密,增加反编译的难度,保护源代码。
如何实现用户自定义类加载器:
- 继承
java.lang.ClassLoader
:开发者需要创建一个新的类,继承自抽象类java.lang.ClassLoader
。 - 重写
findClass(String name)
方法:- 在 JDK 1.2 之前,通常是重写
loadClass()
方法来完全控制加载逻辑。但这种做法容易破坏双亲委派模型。 - JDK 1.2 之后,官方推荐的做法是只重写
findClass(String name)
方法。loadClass()
的默认实现会先遵循双亲委派模型去尝试让父加载器加载。只有当所有父加载器都找不到该类时,loadClass()
才会调用子类(即我们自定义的加载器)的findClass()
方法。 - 在
findClass()
方法中,开发者需要实现自己的类查找逻辑:根据传入的类名name
,找到对应的字节码数据(例如从特定路径读取文件、解密数据、从网络下载等),然后调用defineClass()
方法将字节码(byte[]
数组)转换成Class
对象。
- 在 JDK 1.2 之前,通常是重写
- (可选)继承
URLClassLoader
:如果自定义加载器的需求只是从特定的文件路径或 URL 加载类,可以直接继承java.net.URLClassLoader
。URLClassLoader
已经实现了findClass()
方法(从指定的 URL 搜索类)和获取字节码流的逻辑,可以简化自定义加载器的编写。
自定义类加载器示例代码:
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
// 自定义类加载器,继承自 ClassLoader
public class CustomClassLoader extends ClassLoader {
private String classPath; // 自定义加载路径
// 构造函数,指定加载路径
public CustomClassLoader(String classPath) {
this.classPath = classPath;
}
// 重写 findClass 方法,实现自定义的类查找逻辑
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
// 从自定义路径加载类的字节码
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException("类未找到: " + name);
} else {
// 调用 defineClass 将字节码转换为 Class 对象
// 第一个参数是类的全限定名
// 第二个参数是类的字节码数据
// 第三个和第四个参数是字节码数据的起始偏移量和长度
return defineClass(name, classData, 0, classData.length);
}
} catch (IOException e) {
e.printStackTrace();
throw new ClassNotFoundException("加载类时发生 IO 错误: " + name, e);
}
}
// 从自定义路径加载类的字节码数据的辅助方法
private byte[] loadClassData(String className) throws IOException {
// 将包名中的点替换为文件路径分隔符
String fileName = classPath + File.separatorChar + className.replace('.', File.separatorChar) + ".class";
InputStream is = null;
ByteArrayOutputStream baos = null;
try {
// 读取 .class 文件
is = new FileInputStream(new File(fileName));
baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
return baos.toByteArray();
} catch (FileNotFoundException e) {
// 如果文件未找到,返回 null (或者可以选择抛出异常)
return null;
} finally {
// 关闭流
if (is != null) {
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (baos != null) {
try {
baos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
// 假设在 D:/myclasses/ 目录下有一个 com/example/MyClass.class 文件
// 注意:这里的 MyClass 不能与当前项目的类路径下的类冲突,否则 AppClassLoader 会先加载
String customPath = "D:/myclasses/";
String classNameToLoad = "com.example.MyClass"; // 要加载的类的全限定名
// 创建自定义类加载器实例,指定加载路径
CustomClassLoader customClassLoader = new CustomClassLoader(customPath);
try {
// 使用自定义类加载器加载类
// Class.forName 默认使用调用者的类加载器,这里指定使用自定义加载器
// 第三个参数 true 表示进行初始化
Class<?> clazz = Class.forName(classNameToLoad, true, customClassLoader);
// 创建类的实例
Object obj = clazz.newInstance();
// 输出加载该类的类加载器实例
System.out.println("MyClass 被加载器加载: " + obj.getClass().getClassLoader());
// 验证加载器是否是我们自定义的
System.out.println("加载器是 CustomClassLoader 的实例吗? " + (obj.getClass().getClassLoader() instanceof CustomClassLoader));
} catch (ClassNotFoundException | InstantiationException | IllegalAccessException e) {
System.err.println("加载或实例化类失败: " + classNameToLoad);
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
(请注意,运行 main
方法前,需要确保 D:/myclasses/com/example/MyClass.class
文件存在且有效)
# 4.5 验证各加载器负责的加载目录
我们可以通过代码来验证之前提到的启动类加载器和扩展类加载器负责加载哪些路径下的类库。
import java.net.URL;
import java.security.Provider; // 核心库类,应由 Bootstrap 加载
// import sun.security.ec.CurveDB; // 扩展库类,JDK 9+ 可能已移除或改变位置,
// 如果需要测试 ExtClassLoader,可能需要找 JRE 8 的 ext 目录下的类
// 或者在 JDK 9+ 中使用其他位于 ext 目录(如果存在)的类
// 例如,如果安装了 JavaFX,某些版本的 jfxrt.jar 可能在 ext
public class ClassLoaderPathTest {
public static void main(String[] args) {
System.out.println("********* 启动类加载器 (Bootstrap ClassLoader) 加载路径 ************");
// 获取 Bootstrap ClassLoader 加载的 API 路径
URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
for (URL url : urls) {
// 打印每个路径
System.out.println(url.toExternalForm());
}
// 尝试获取核心库中 Provider 类的加载器
// 由于 Provider 是核心类,应由 Bootstrap 加载器加载,所以这里返回 null
ClassLoader providerClassLoader = Provider.class.getClassLoader();
System.out.println("\nProvider 类的加载器: " + providerClassLoader); // 输出: null
System.out.println("\n********* 扩展类加载器 (Extension ClassLoader) 加载路径 ************");
// 获取 java.ext.dirs 系统属性,该属性指定了扩展目录
String extDirs = System.getProperty("java.ext.dirs");
if (extDirs != null) {
// 遍历并打印每个扩展目录路径
for (String path : extDirs.split(System.getProperty("path.separator"))) { // 使用系统路径分隔符
System.out.println(path);
}
} else {
System.out.println("java.ext.dirs 系统属性未定义 (在 JDK 9+ 中可能已被移除)");
}
// 尝试获取扩展库中某个类的加载器 (注意:需要找到实际存在于 ext 目录的类)
// 例如,如果在 JRE 8 的 ext 目录中有 sunec.jar,可以尝试加载 CurveDB
// ClassLoader curveDBClassLoader = null;
// try {
// // 注意:类名可能需要根据实际 JAR 包调整
// curveDBClassLoader = Class.forName("sun.security.ec.CurveDB").getClassLoader();
// } catch (ClassNotFoundException e) {
// System.out.println("\n未能在扩展目录找到 sun.security.ec.CurveDB 类 (或该类已不存在于 JDK 当前版本)");
// }
// System.out.println("CurveDB 类的加载器 (示例): " + curveDBClassLoader); // 如果成功加载,应输出 ExtClassLoader 实例
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
JDK 8 下的输出结果示例 (路径和地址会因安装和系统而异):
********* 启动类加载器 (Bootstrap ClassLoader) 加载路径 ************
file:/C:/Program%20Files/Java/jdk1.8.0_192/jre/lib/resources.jar
file:/C:/Program%20Files/Java/jdk1.8.0_192/jre/lib/rt.jar
file:/C:/Program%20Files/Java/jdk1.8.0_192/jre/lib/sunrsasign.jar
file:/C:/Program%20Files/Java/jdk1.8.0_192/jre/lib/jsse.jar
file:/C:/Program%20Files/Java/jdk1.8.0_192/jre/lib/jce.jar
file:/C:/Program%20Files/Java/jdk1.8.0_192/jre/lib/charsets.jar
file:/C:/Program%20Files/Java/jdk1.8.0_192/jre/lib/jfr.jar
file:/C:/Program%20Files/Java/jdk1.8.0_192/jre/classes
Provider 类的加载器: null
********* 扩展类加载器 (Extension ClassLoader) 加载路径 ************
C:\Program Files\Java\jdk1.8.0_192\jre\lib\ext
C:\Windows\Sun\Java\lib\ext
未能在扩展目录找到 sun.security.ec.CurveDB 类 (或该类已不存在于 JDK 当前版本)
CurveDB 类的加载器 (示例): null
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
注意:JDK 9 引入了模块化系统,rt.jar
和 tools.jar
被移除,ext
目录机制也被废弃,java.ext.dirs
属性可能不再有效。因此,上述代码在 JDK 9 及以后版本运行,扩展类加载器相关的输出会不同,甚至可能找不到示例类。核心库类的加载器仍为 null
(Bootstrap)。
# 4.6 关于 java.lang.ClassLoader
类
java.lang.ClassLoader
是 Java 中所有类加载器(除了 Bootstrap ClassLoader)的抽象基类。它定义了类加载的核心逻辑和 API。开发者可以通过继承这个类来实现自定义的类加载行为。
ClassLoader
类的主要结构和继承关系:
(该图展示了
ClassLoader
是一个抽象类,SecureClassLoader
和 URLClassLoader
是其重要子类,而 ExtClassLoader
和 AppClassLoader
又是 URLClassLoader
的子类)
sun.misc.Launcher
的角色
sun.misc.Launcher
是一个 Java 虚拟机启动时会用到的入口类(非 public API,不保证兼容性)。它内部初始化了扩展类加载器(ExtClassLoader)和应用程序类加载器(AppClassLoader),并设置了它们之间的父子关系,是 JVM 标准类加载架构的启动器。
获取 ClassLoader
实例的常见途径:
获取某个类的加载器:通过
Class
对象的getClassLoader()
方法。ClassLoader loader = MyClass.class.getClassLoader();
1注意:对于数组类型,其
getClassLoader()
返回的是数组元素的类加载器。对于基本类型,该方法不可用。对于核心库类,返回null
。获取当前线程的上下文类加载器 (Context ClassLoader):通过
Thread.currentThread().getContextClassLoader()
。- 这是 Java EE 规范引入的一种打破双亲委派模型的机制,常用于 SPI(Service Provider Interface)等场景。父加载器可以通过上下文类加载器去调用子加载器加载的类。
ClassLoader contextLoader = Thread.currentThread().getContextClassLoader();
1- 默认情况下,线程的上下文类加载器是应用程序类加载器。
获取系统类加载器 (AppClassLoader):通过
ClassLoader.getSystemClassLoader()
静态方法。ClassLoader systemLoader = ClassLoader.getSystemClassLoader();
1获取调用者的类加载器:在某些框架或库中,可能需要知道调用当前方法的类的加载器,例如
java.sql.DriverManager.getCallerClassLoader()
(已被标记为废弃,不推荐使用)。
代码示例:获取不同 ClassLoader
// 文件名: ClassLoaderTest2.java
public class ClassLoaderTest2 {
public static void main(String[] args) {
try {
// 1. 获取核心库 String 类的加载器
ClassLoader stringLoader = Class.forName("java.lang.String").getClassLoader();
System.out.println("String 类的加载器: " + stringLoader); // 输出: null (Bootstrap)
// 2. 获取当前线程的上下文类加载器
ClassLoader contextLoader = Thread.currentThread().getContextClassLoader();
System.out.println("当前线程上下文加载器: " + contextLoader); // 输出: sun.misc.Launcher$AppClassLoader@...
// 3. 获取系统类加载器的父加载器 (即扩展类加载器)
ClassLoader extLoader = ClassLoader.getSystemClassLoader().getParent();
System.out.println("系统加载器的父加载器 (扩展类加载器): " + extLoader); // 输出: sun.misc.Launcher$ExtClassLoader@...
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
代码输出示例:
String 类的加载器: null
当前线程上下文加载器: sun.misc.Launcher$AppClassLoader@18b4aac2
系统加载器的父加载器 (扩展类加载器): sun.misc.Launcher$ExtClassLoader@1540e19d
2
3
# 5. 双亲委派机制 (Parents Delegation Model)
Java 虚拟机在加载类文件时,默认采用的是 按需加载(Lazy Loading) 的策略,即只有当程序首次主动使用某个类时,JVM 才会将其对应的 .class
文件加载到内存中并生成 Class
对象。而在执行类加载这个动作时,JVM 使用了双亲委派模型(Parents Delegation Model)。
核心思想:该模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。当一个类加载器收到类加载的请求时,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。这个过程会一直向上委托,直到达到顶层的启动类加载器。只有当父类加载器反馈自己无法完成这个加载请求(即在其搜索范围内没有找到所需的类)时,子加载器才会尝试自己去加载。
# 5.1 工作原理详解
双亲委派模型的工作流程可以用以下步骤描述:
- 接收请求:当一个类加载器(比如 AppClassLoader)收到加载某个类(例如
com.example.MyClass
)的请求时。 - 向上委派:该加载器不会立即查找和加载,而是先检查缓存(看是否已加载过),如果没有,则将加载请求委托给它的父加载器(ExtClassLoader)。
- 递归向上:ExtClassLoader 同样不会立即加载,而是继续将请求委托给它的父加载器(Bootstrap ClassLoader)。
- 顶层尝试加载:请求最终到达顶层的 Bootstrap ClassLoader。Bootstrap ClassLoader 会在自己负责的加载范围(核心库路径)内查找是否存在
com.example.MyClass
。 - 查找结果反馈:
- 成功:如果 Bootstrap ClassLoader 找到了并成功加载了该类,则直接返回加载成功的
Class
对象,整个加载过程结束。 - 失败:如果 Bootstrap ClassLoader 在其范围内找不到该类,它会通知其子加载器(ExtClassLoader):“我加载不了,你来试试”。
- 成功:如果 Bootstrap ClassLoader 找到了并成功加载了该类,则直接返回加载成功的
- 逐级向下尝试:ExtClassLoader 收到父加载器失败的通知后,才会在自己负责的加载范围(扩展库路径
ext
目录)内查找com.example.MyClass
。- 成功:如果 ExtClassLoader 找到了并成功加载,则返回
Class
对象,加载结束。 - 失败:如果 ExtClassLoader 也找不到,它会通知其子加载器(AppClassLoader):“我也加载不了,你来试试”。
- 成功:如果 ExtClassLoader 找到了并成功加载,则返回
- 最终尝试:AppClassLoader 收到父加载器失败的通知后,最后才会在自己负责的加载范围(用户类路径
Classpath
)内查找com.example.MyClass
。- 成功:如果 AppClassLoader 找到了并成功加载,则返回
Class
对象,加载结束。 - 失败:如果 AppClassLoader 也找不到,那么它会抛出
ClassNotFoundException
异常,整个加载过程失败。
- 成功:如果 AppClassLoader 找到了并成功加载,则返回
双亲委派机制流程图解
# 5.2 双亲委派机制实例分析
示例 1:尝试加载自定义的 java.lang.String
创建一个与核心库类同包同名的类:
// 注意:这个类需要放在没有包定义的默认目录下编译运行, // 或者放在项目的 src 目录下,但不要指定 package 语句 // (实际中应避免这样做) // 文件名: String.java public class String { // 静态代码块 static { System.out.println("我是自定义的 String 类的静态代码块"); } }
1
2
3
4
5
6
7
8
9
10在另一个类中尝试加载和使用
java.lang.String
:// 文件名: StringTest.java public class StringTest { public static void main(String[] args) { // 尝试创建 String 实例 java.lang.String str = new java.lang.String(); // 这里的 String 是 java.lang.String System.out.println("hello, scholar"); // 获取当前 StringTest 类的加载器 StringTest test = new StringTest(); System.out.println(test.getClass().getClassLoader()); } }
1
2
3
4
5
6
7
8
9
10
11
12
输出结果:
hello, scholar
sun.misc.Launcher$AppClassLoader@18b4aac2
2
分析:
- 程序没有输出 "我是自定义的 String 类的静态代码块"。
- 这说明程序实际加载和使用的是 JDK 核心库中的
java.lang.String
类,而不是我们自己编写的那个String
类。 - 原因在于双亲委派机制:当
StringTest
需要加载java.lang.String
时,AppClassLoader 将请求委派给 ExtClassLoader,ExtClassLoader 再委派给 Bootstrap ClassLoader。Bootstrap ClassLoader 在核心库路径下找到了java/lang/String.class
文件并成功加载。由于父加载器成功加载,AppClassLoader 就不会再尝试加载我们自己编写的那个String
类了。
如果尝试运行自定义的 String
类(假设添加了 main
方法):
// 文件名: String.java (位于 java/lang 目录下)
package java.lang; // 显式指定包名
public class String {
static {
System.out.println("我是自定义的 String 类的静态代码块");
}
// 尝试添加 main 方法
public static void main(String[] args) {
System.out.println("hello, String from custom class");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
尝试编译并运行 java java.lang.String
会报如下错误:
错误信息:
错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为: public static void main(String[] args)
分析:
- 即使我们自定义的
String
类有main
方法,运行请求依然会被双亲委派机制引导至 Bootstrap ClassLoader 加载的核心库java.lang.String
。 - 核心库的
String
类中并没有main
方法,因此 JVM 报告找不到main
方法的错误。 - 这进一步证明了双亲委派机制对核心库的保护作用。
关键结论
- 核心库优先:双亲委派机制保证了 Java 核心库(如
java.lang
,java.util
等)中的类会被优先加载,且由顶层的 Bootstrap ClassLoader 加载。 - 防止篡改:用户无法通过定义同名类来覆盖或篡改核心库的行为,确保了 Java 平台的稳定和安全。
- 命名规范:开发者应避免创建与核心库类同名的类,尤其是在
java.*
包下。
示例 2:尝试在 java.lang
包下创建自定义类
// 文件名: ShkStart.java (位于 java/lang 目录下)
package java.lang; // 显式指定包名
public class ShkStart {
public static void main(String[] args) {
System.out.println("hello!");
}
}
2
3
4
5
6
7
8
编译可能成功,但尝试运行 java java.lang.ShkStart
时会抛出异常:
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.SecurityException: Prohibited package name: java.lang
at java.lang.ClassLoader.preDefineClass(ClassLoader.java:662)
# ... (堆栈信息省略)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)
2
3
4
5
分析:
- JVM 的类加载器在定义类(调用
defineClass
方法)之前会进行安全检查(preDefineClass
方法)。 - 检查发现尝试定义的类的包名是
java.lang
,这是一个受保护的核心包名。 - 出于安全考虑,JVM 禁止用户在
java.*
这样的核心包下定义自己的类。 - 因此,即使类名没有冲突,也会抛出
SecurityException
,阻止类的加载。
关键结论
- 包名保护:除了类名冲突,核心包名(如
java.lang
)本身也是受保护的,不允许用户自定义类放置在这些包下。 - 安全防线:这是 JVM 的另一层安全机制,进一步防止对核心库的干扰和潜在的安全风险。
示例 3:JDBC SPI 与线程上下文类加载器
Java 提供了 SPI (Service Provider Interface) 机制,允许核心库(由 Bootstrap 加载)调用由应用程序类加载器(AppClassLoader)加载的第三方实现。典型的例子就是 JDBC。
java.sql.DriverManager
是 Java 核心库的一部分,由 Bootstrap ClassLoader 加载。- 各种数据库厂商提供的 JDBC 驱动(如
mysql-connector-java.jar
)通常放在应用程序的classpath
下,由 AppClassLoader 加载。 DriverManager
需要能够找到并加载这些由 AppClassLoader 加载的驱动实现类(如com.mysql.cj.jdbc.Driver
)。
按照标准的双亲委派模型,Bootstrap ClassLoader 无法向下请求 AppClassLoader 去加载类。为了解决这个问题,Java 引入了线程上下文类加载器 (Thread Context ClassLoader)。
- 工作方式:
DriverManager
(运行在 Bootstrap 加载的环境)在需要加载驱动时,不会直接使用自己的加载器,而是获取当前线程的上下文类加载器。- 默认情况下,线程上下文类加载器是 AppClassLoader。
DriverManager
使用这个上下文类加载器(AppClassLoader)去加载并实例化位于classpath
下的 JDBC 驱动实现。
- 效果:这相当于一种“反向委派”或者说是“破坏双亲委派”的机制,使得父加载器可以借助子加载器来加载类,解决了 SPI 场景下的类加载问题。
关于破坏双亲委派机制的更多场景(如 JNDI, OSGi, Tomcat 等),可以参考 再谈类的加载器。
# 5.3 沙箱安全机制 (Sandbox Security Mechanism)
Java 能够在不受信任的环境(如浏览器 Applet)或需要严格权限控制的场景(如服务器)下运行代码,其基础是沙箱(Sandbox)安全模型。
什么是沙箱?
沙箱是一个受限制的程序运行环境。其核心思想是将 Java 代码的执行限定在一个特定的、受控的范围内,并严格限制该代码对本地系统资源(如文件系统、网络、内存、CPU 等)的访问权限。通过这种方式,即使运行的是不可信的代码,也能有效防止其对宿主系统造成破坏。
沙箱机制的关键组成:
- 类加载体系:双亲委派机制是沙箱的第一道防线,它保证了核心 API 不会被篡改。加载不可信代码通常会使用独立的、受限的类加载器。
- 字节码校验:验证阶段确保加载的代码符合 JVM 规范,没有明显危害。
- 访问控制器 (Access Controller):这是 Java 安全模型的核心。它根据代码来源(CodeSource)、当前用户身份以及系统配置的安全策略(Policy),在代码尝试访问敏感资源(如读写文件、打开网络连接)时进行权限检查。
- 安全管理器 (Security Manager):
java.lang.SecurityManager
类是一个具体的安全策略执行者。当应用程序启动了安全管理器后,所有对敏感资源的操作都会先经过安全管理器的checkXXX()
方法检查,如果没有足够的权限,则会抛出SecurityException
。 - 安全策略文件 (Policy File):用于配置哪些代码来源(例如特定目录下的代码、特定签名的代码)拥有哪些权限。
沙箱如何限制资源访问?
- 文件系统:限制代码只能读写指定目录下的文件,或者完全禁止文件访问。
- 网络:限制代码只能连接到指定的服务器和端口,或者完全禁止网络访问。
- 内存/CPU:虽然 Java 本身不直接提供精细的 CPU 时间片控制,但可以通过线程优先级、
Thread.yield()
以及外部监控工具间接管理。内存主要通过 GC 和堆大小限制来管理。 - 其他资源:如调用本地方法(JNI)、访问系统属性、创建子进程等,都可以通过安全策略进行限制。
所有运行在 JVM 中的 Java 程序都可以指定沙箱环境,并通过配置安全策略来定制其安全级别。
关于不同 JDK 版本沙箱安全机制的演进细节,可以参考 再谈类的加载器。
# 5.4 双亲委派机制的优势总结
双亲委派模型作为 Java 类加载器的默认机制,带来了显著的好处:
避免类的重复加载,节省资源:
- 当一个类被请求加载时,请求会沿着父子关系链一直向上传递。一旦某个父加载器成功加载了该类,这个
Class
对象就会被缓存起来。后续任何加载器(包括子加载器和该加载器自身)再次收到对同一个类的加载请求时,会直接返回缓存中的Class
对象,而不会重新加载。 - 这保证了在 JVM 的整个生命周期中,对于同一个全限定名的类,只存在一个
Class
对象实例(由同一个加载器加载),有效避免了资源的浪费。
- 当一个类被请求加载时,请求会沿着父子关系链一直向上传递。一旦某个父加载器成功加载了该类,这个
保护程序安全,防止核心 API 被随意篡改:
- 这是双亲委派模型最重要的优势。由于加载请求总是优先委派给父加载器,尤其是最终会到达顶层的 Bootstrap ClassLoader,因此 Java 核心库中的类(如
java.lang.Object
,java.lang.String
等)总是会被 Bootstrap ClassLoader 优先加载。 - 这意味着用户编写的、与核心库同名的类(即使放在
classpath
下)永远没有机会被加载,因为 Bootstrap ClassLoader 会先一步加载核心库的版本。 - 这从根本上杜绝了用户代码通过覆盖核心类来破坏 Java 基础运行机制或进行恶意攻击的可能性,保障了 JVM 和 Java 平台的安全稳定。例如,用户无法通过自定义
java.lang.String
类来窃取敏感信息,也无法通过自定义java.lang.ClassLoader
来破坏类加载规则(除非采用打破双亲委派的方式)。
- 这是双亲委派模型最重要的优势。由于加载请求总是优先委派给父加载器,尤其是最终会到达顶层的 Bootstrap ClassLoader,因此 Java 核心库中的类(如
# 6. 其他重要概念
# 6.1 JVM 如何判断两个 Class 对象是否为同一个类?
在 Java 虚拟机中,要判断两个 Class
对象是否代表同一个类,必须同时满足以下两个条件:
- 类的完整类名(包名 + 类名)必须完全相同。
- 加载这两个类的类加载器(ClassLoader)实例必须是同一个。
换句话说,即使两个 Class
对象来源于同一个 .class
文件,并且被同一个 Java 虚拟机加载,但只要加载它们的 ClassLoader
实例不同,那么这两个 Class
对象在 JVM 看来就是两个不同的类。
JVM 内部如何管理类加载器信息?
- 当一个类被加载时(特别是被用户自定义类加载器加载时),JVM 会在其内部表示(通常是在方法区或元空间存储的类型信息)中记录下加载该类的 ClassLoader 的一个引用。
- 当 JVM 在进行符号引用解析,需要将一个类型引用到另一个类型时(例如,方法调用、字段访问),它会确保这两个相关类型的类加载器是兼容的或相同的(具体规则取决于解析的上下文)。这对于维持类型安全和隔离性至关重要。
示例代码:不同加载器加载同一个类
// 文件名: ClassLoaderEqualityTest.java
// 假设有一个简单的类 MySampleClass.java, 编译后放在 d:/temp/
// package com.example;
// public class MySampleClass {}
import java.lang.reflect.Method;
public class ClassLoaderEqualityTest {
public static void main(String[] args) {
try {
// 创建两个不同的自定义类加载器实例,加载路径相同
String path = "d:/temp/";
String className = "com.example.MySampleClass";
CustomClassLoader loader1 = new CustomClassLoader(path);
CustomClassLoader loader2 = new CustomClassLoader(path);
// 使用 loader1 加载 MySampleClass
Class<?> class1 = loader1.loadClass(className);
System.out.println("Class 1 loaded by: " + class1.getClassLoader());
// 使用 loader2 加载 MySampleClass
Class<?> class2 = loader2.loadClass(className);
System.out.println("Class 2 loaded by: " + class2.getClassLoader());
// 判断两个 Class 对象是否相等 (== 比较引用)
System.out.println("class1 == class2 ? " + (class1 == class2)); // 输出: false
// 判断两个 Class 对象是否 logically equal (equals 方法默认也是比较引用)
System.out.println("class1.equals(class2) ? " + class1.equals(class2)); // 输出: false
// 尝试将一个实例赋值给另一个类型的变量
Object obj1 = class1.newInstance();
// Object obj2 = class2.newInstance();
// MySampleClass sample = (MySampleClass) obj1; // 编译时需要 MySampleClass 可见
// boolean instanceOfCheck = obj1 instanceof MySampleClass; // 编译时需要 MySampleClass 可见
// 使用反射调用,演示类型不兼容
Object obj2 = class2.newInstance();
Method setMethod = class1.getMethod("setSample", class2); // 尝试找到接受 class2 类型参数的方法 (如果存在)
// 会抛出 NoSuchMethodException,因为 class1 和 class2 是不同类型
} catch (Exception e) {
e.printStackTrace();
}
}
}
// 复用之前的 CustomClassLoader 定义...
// class CustomClassLoader extends ClassLoader { ... }
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
输出结果 (部分):
Class 1 loaded by: CustomClassLoader@...
Class 2 loaded by: CustomClassLoader@...
class1 == class2 ? false
class1.equals(class2) ? false
2
3
4
分析:
- 虽然
class1
和class2
都代表com.example.MySampleClass
,并且内容完全一样,但因为它们是由两个不同的CustomClassLoader
实例 (loader1
和loader2
) 加载的,JVM 视它们为两个完全不同的类。 - 这导致
class1 == class2
和class1.equals(class2)
都返回false
。 - 这也意味着,由
loader1
加载的类的实例不能赋值给由loader2
加载的类的变量,反之亦然,否则会抛出ClassCastException
。它们之间的方法调用也会因类型不匹配而出错。这正是类加载器实现隔离性的基础。
# 6.2 类的主动使用与被动使用
Java 程序对类的使用方式可以分为两大类:主动使用(Active Use) 和 被动使用(Passive Use)。这两种使用方式的主要区别在于是否会触发类的初始化阶段。
# 主动使用 (Active Use)
当程序代码中出现以下七种情况之一时,如果类尚未初始化,则必须立即对其进行初始化。这被称为对类的主动使用。
创建类的实例:
- 使用
new
关键字创建对象时,例如MyClass obj = new MyClass();
。 - 通过反射调用
Class.newInstance()
或Constructor.newInstance()
创建实例时。 - 通过克隆 (
clone()
) 方法创建对象时(需要类已初始化)。 - 通过反序列化 (
ObjectInputStream.readObject()
) 创建对象时。
- 使用
访问或修改类或接口的静态变量 (static field):
- 读取一个类或接口的静态变量时,例如
int value = MyClass.staticVar;
。 - 设置一个类或接口的静态变量时,例如
MyClass.staticVar = 10;
。 - 注意:如果静态变量是编译期常量(
final static
且值在编译时已知),并且对该常量的引用在编译时直接嵌入到调用方的常量池中(常量传播优化),那么直接引用该常量不会触发定义常量的类的初始化(属于被动使用)。例如System.out.println(MyFinalClass.COMPILE_TIME_CONSTANT);
。
- 读取一个类或接口的静态变量时,例如
调用类的静态方法 (static method):
- 例如
MyClass.staticMethod();
。
- 例如
反射调用:
- 使用
Class.forName("com.example.MyClass");
来加载并初始化类。注意ClassLoader.loadClass("...")
方法只进行加载,默认不进行初始化。
- 使用
初始化一个类的子类:
- 当初始化一个子类时,JVM 规定必须先初始化其父类。例如,
new ChildClass();
会先触发ParentClass
的初始化,然后再触发ChildClass
的初始化。
- 当初始化一个子类时,JVM 规定必须先初始化其父类。例如,
Java 虚拟机启动时被标明为启动类的类:
- 即包含
public static void main(String[] args)
方法的那个主类,JVM 启动时会首先初始化它。
- 即包含
JDK 7 开始提供的动态语言支持相关:
- 如果一个
java.lang.invoke.MethodHandle
实例最后的解析结果是REF_getStatic
,REF_putStatic
,REF_invokeStatic
的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
- 如果一个
# 被动使用 (Passive Use)
除了上述七种主动使用的情况外,所有其他引用类的方式都不会触发类的初始化,称为对类的被动使用。常见的被动使用场景包括:
通过子类引用父类的静态字段:
- 当通过子类来引用父类中定义的静态字段时,只会触发父类的初始化,而不会触发子类的初始化。
- 例如
System.out.println(SubClass.parentStaticVar);
只会初始化ParentClass
。
定义该类的数组:
- 通过数组定义来引用类,不会触发此类的初始化。
- 例如
MyClass[] array = new MyClass[10];
不会初始化MyClass
,但会加载MyClass
(因为需要知道数组元素的大小和类型),并会触发数组类[Lcom.example.MyClass;
的加载(数组类是 JVM 在运行时动态创建的)。
引用类的编译期常量:
- 当引用一个类的静态常量(
final static
且值在编译时确定)时,如果该常量的值在编译阶段已经传播到引用处的常量池中,那么对该常量的引用实际上被转化为对自身常量池的引用,不会触发定义该常量的类的初始化。 - 例如
System.out.println(MyConstants.COMPILE_TIME_FINAL_STATIC_INT);
。
- 当引用一个类的静态常量(
总结
- JVM 对类的加载是懒惰的:只有在首次主动使用时才会进行初始化。
- 初始化触发条件有限:只有明确的七种情况属于主动使用,会触发初始化。
- 区分加载与初始化:被动使用虽然不触发初始化,但可能触发类的加载、验证和准备阶段(例如创建数组时需要加载元素类型)。
- 理解主动/被动使用的区别对于分析类加载时机、静态代码块/静态变量的执行顺序以及排查
NoClassDefFoundError
或ExceptionInInitializerError
等问题非常重要。 - 类加载器相等性判断:JVM 通过类名和加载器实例共同确定类的唯一性,是理解类隔离、热部署等高级特性的基础。