序言
在 Java 开发中,JVM 类加载机制是一个非常基础、但又容易被理解得比较零散的知识点。我们经常会遇到一些问题:.java 文件编译成 .class 文件之后,JVM 是如何将它变成运行时可以使用的类的?一个类从加载到初始化经历了哪些阶段?JVM 中有哪些类加载器,它们之间是什么关系?为什么 Java 默认采用双亲委派模型?JDBC 能够加载第三方数据库驱动、Tomcat 能够隔离不同 Web 应用,又为什么需要特殊的类加载机制?
这些问题实际上都围绕着 JVM 的类加载机制展开。Java 源代码经过编译后会生成字节码文件,但字节码文件本身并不能直接作为运行时类型使用。JVM 需要获取类的字节码,建立对应的运行时结构,并完成验证、连接和初始化等工作,之后程序才能正常使用这个类。
理解类加载机制,需要先区分两个容易混淆的概念:类加载生命周期和双亲委派模型。前者描述一个类从字节码文件进入 JVM、最终成为可用运行时类型的过程;后者描述多个类加载器收到加载请求时如何协作,决定优先由哪个加载器尝试加载。
因此,本文将从类加载生命周期出发,先介绍类加载器的职责与体系,再深入分析双亲委派模型的执行过程和设计目的,最后通过 JDBC、SPI、Tomcat、Spring Boot 以及 Arthas 等场景,说明这些机制在实际开发和生产排查中的应用。
JVM 类加载机制
什么是类加载?
Java 源文件经过编译器编译后会生成 .class 文件。Class 文件中保存了类的结构信息,例如类名、父类、接口、字段、方法、常量池以及方法对应的字节码指令等。
但是,Class 文件通常只是磁盘上的一个文件,或者来自 JAR 包、网络、数据库、动态生成器等其他位置。JVM 要使用其中定义的类,就需要先获取对应的字节码,再将其转换成 JVM 内部可识别和管理的运行时类型。这个过程就是类加载(Class Loading)。
类加载并不要求字节码一定来自磁盘上的 .class 文件。自定义类加载器可以从网络、数据库、加密文件或内存中的字节数组获取类定义,因此类加载机制也为插件化、动态生成和模块隔离等能力提供了基础。
还需要区分类加载与对象实例化。以 User user = new User(); 为例,JVM 首先需要确保 User 类已经完成必要的加载和初始化,然后才会为对象分配内存并执行实例初始化逻辑。类加载解决的是“类型如何进入 JVM”,对象实例化解决的是“如何创建这个类型的对象”,两者不是同一个过程。
类加载的完整生命周期
一个类从被加载到最终被卸载,通常可以从以下几个阶段理解:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析合称为连接(Linking)。
1 | flowchart TD |
注意:解析并不一定必须紧跟在准备阶段之后立即完成。JVM 规范允许实现根据需要采用提前解析或延迟解析,只要保证类在初始化之前已经完成必要的验证和准备即可。
加载:获取字节码并建立运行时类结构
加载阶段主要完成三件事情。
第一,获取类的二进制字节流。 JVM 需要获得目标类的字节码。字节码来源可以是文件系统、JAR 包、网络、数据库,也可以是程序运行时动态生成的字节数组。
第二,将字节流转换为 JVM 内部的运行时数据结构。 JVM 会根据 Class 文件中的内容建立类的相关信息,例如类的层次关系、字段、方法、常量池等。具体数据结构属于 JVM 实现细节,不同虚拟机可能存在差异。
第三,在堆中生成与该类型对应的 java.lang.Class 对象。 Java 程序可以通过这个对象访问类的运行时信息,例如:
1 | Class<?> clazz = User.class; |
以 HotSpot JVM 为例,类的元数据主要存放在元空间(Metaspace),而 Java 层代表该类型的 Class 对象通常存放在堆中。需要注意,这是 HotSpot 的实现方式;从 JVM 规范角度,更适合表述为 JVM 建立了对应的运行时类结构。
连接:验证、准备与解析
验证(Verification)
验证阶段的主要目的是检查 Class 文件是否符合 JVM 规范,避免不合法的字节码进入后续执行阶段。它可以理解为 JVM 对类文件进行的一系列格式和安全检查。
常见检查内容包括:
| 检查类型 | 主要检查内容 |
|---|---|
| 文件格式验证 | 魔数、版本号、常量池等结构是否符合 Class 文件格式 |
| 元数据验证 | 类的继承关系、父类和接口是否合法,是否满足语言约束 |
| 字节码验证 | 操作数栈、局部变量表、类型转换和控制流等是否符合规则 |
| 符号引用验证 | 被引用的类、字段和方法是否存在,访问权限是否合法 |
例如,Java 编译器通常会生成符合规范的字节码;如果有人使用字节码工具直接修改 Class 文件,导致字节码中的类型使用或操作数栈状态不合法,JVM 就可能在验证阶段抛出 VerifyError。
需要注意,验证阶段并不是检查业务逻辑是否正确。例如,return a / b; 在 b 为 0 时会产生算术异常,但这属于程序运行时的业务执行问题,不是字节码验证需要解决的问题。
准备(Preparation)
准备阶段主要为类变量(也就是通常所说的静态变量)分配存储空间,并设置对应的初始值。这里需要特别区分“默认初始化”和“执行 Java 代码赋值”。
例如:
1 | public class User { |
对于普通静态变量,准备阶段通常会先设置数据类型对应的默认值,而不是直接执行源代码中的赋值表达式:
| 静态变量 | 准备阶段的初始值 |
|---|---|
int age = 18 |
0 |
boolean enabled = true |
false |
String username = "Tom" |
null |
long count = 100L |
0L |
普通静态变量的显式赋值通常会在后面的初始化阶段执行。不过,编译期常量是一个特殊情况:如果 static final 基本类型或 String 变量使用编译期常量表达式初始化,编译器可能会将其值写入 Class 文件的 ConstantValue 属性,JVM 可以在准备阶段完成该常量的赋值。
因此,不能笼统地认为所有静态变量在准备阶段都只会得到默认值,也不能认为所有静态变量都会等到初始化阶段才有值。判断关键在于它是否属于编译期常量。
解析(Resolution)
解析阶段的主要任务是将运行时常量池中的符号引用转换为直接引用。
符号引用可以理解为通过名称和描述信息指向一个目标,例如类的全限定名、字段名称、方法名称和方法描述符。编译后的 Class 文件可以通过这些符号信息描述类之间的引用关系。
直接引用则是 JVM 能够用于定位目标的引用形式,可能表现为指针、偏移量或虚拟机内部的数据结构,并不一定等同于开发人员通常理解的物理内存地址。
例如,OrderService 调用 UserService.queryUser() 时,Class 文件中会包含对目标类型和方法的符号信息。JVM 在解析时,需要将这些符号信息与运行时实际存在的类型、字段或方法关联起来。
解析不一定在类初始化之前一次性全部完成。JVM 可以采用提前解析,也可以在程序首次使用某个符号引用时再完成解析。延迟解析能够避免过早处理暂时不会使用的引用。
初始化:执行静态初始化逻辑
初始化阶段与前面的加载、验证和准备有所不同。初始化阶段会真正执行 Java 代码中定义的静态变量赋值操作和静态代码块。
JVM 会执行编译器生成的类初始化方法 <clinit>()。这个方法不是开发人员编写的构造方法,而是编译器根据类中的静态变量显式赋值语句和静态代码块生成的特殊方法。
1 | public class User { |
初始化时,静态变量赋值和静态代码块中的语句按照它们在源代码中出现的顺序执行。上面的示例中,age 先被赋值为 18,随后在静态代码块中变成 20,最后输出初始化信息。
什么情况下会触发类初始化?
类被加载,并不意味着它会立即初始化。JVM 只有在发生特定的主动使用行为时,才会触发类初始化。常见情况包括:
- 创建类的实例,例如执行
new User()。 - 读取或设置由该类声明的非编译期常量静态字段。
- 调用该类声明的静态方法。
- 使用反射方式初始化类,例如默认初始化语义下的
Class.forName("com.example.User")。 - 初始化一个类时,需要先初始化它的父类。
- 某些接口初始化场景,例如初始化类时,需要先初始化其声明了默认方法的相关父接口。
通过 Class.forName() 可以观察初始化行为:
1 | // 默认会初始化目标类 |
主动使用与被动使用
理解初始化时,还需要掌握主动使用与被动使用的区别。下面几个示例比较典型。
通过子类访问父类静态变量:
1 | class Parent { |
这里的 age 是由 Parent 声明的,因此访问 Child.age 只会触发 Parent 初始化,不会仅仅因为使用了子类名称就初始化 Child。
通过类字面量获取 Class 对象:
1 | Class<?> clazz = User.class; |
获取类字面量不会直接触发 User 的初始化。
访问编译期常量:
1 | public class Constant { |
如果 MAX 是编译期常量,编译器可能已经将数值直接写入调用方的字节码,因此访问它通常不会触发 Constant 初始化。
类初始化的线程安全
JVM 会保证类初始化过程的同步与线程安全。当多个线程同时触发一个尚未初始化的类时,JVM 会协调初始化过程,确保类初始化逻辑不会被多个线程并发重复执行。
这也是静态内部类单例模式能够利用类初始化机制实现延迟初始化和线程安全的原因之一:
1 | public class Singleton { |
第一次调用 getInstance() 时,才会触发 Holder 初始化,随后创建单例对象。
卸载:类什么时候可以被回收?
类的运行时数据并不是永远驻留在 JVM 中。在满足条件时,JVM 可以卸载某个类,并回收与它相关的运行时元数据。
理解类卸载时,最重要的是关注定义该类的类加载器是否能够被垃圾回收。如果一个普通类加载器已经不再被引用,且它所加载的类及相关对象没有其他强引用链阻止回收,那么该类加载器及其定义的类就有机会被回收。
因此,类卸载通常不是简单地判断“这个类有没有对象实例”,还需要考虑类加载器、Class 对象、静态变量、线程、线程上下文类加载器、ThreadLocal 等引用关系。只要某条引用链仍然让类加载器保持可达,就可能阻止类加载器回收,进而阻止其定义的类卸载。
Bootstrap ClassLoader 由 JVM 内部实现,不会像普通 Java 对象那样被垃圾回收,因此它加载的核心类通常不会在应用运行期间卸载。自定义类加载器则有机会被回收,这也是 Tomcat、插件系统等框架能够通过创建新类加载器实现应用重新加载的重要基础。
小结
类加载生命周期描述的是一个类如何从字节码文件成为 JVM 中可用的运行时类型:加载阶段获取字节码并建立运行时结构;连接阶段完成验证、准备和解析;初始化阶段执行静态初始化逻辑;使用阶段由程序创建对象、调用方法;满足条件后,类及相关运行时数据才可能被卸载。
需要重点掌握准备阶段与初始化阶段的区别,以及解析可以延迟执行这一点。理解这些内容之后,接下来就可以进一步讨论:JVM 是通过谁来获取并定义这些类的?
类加载器体系
什么是类加载器?
类加载器(ClassLoader)是负责查找类的字节码并将其定义为 JVM 运行时类型的机制入口。它根据类的二进制名称确定从哪里获取对应字节码,再通过 JVM 提供的能力完成类定义。
类加载器并不等同于 JVM。类加载器负责获取和提供类定义,JVM 则负责建立运行时类结构、执行验证和连接,以及后续的初始化和运行。
类加载器除了加载类,也可以通过资源查找 API 加载配置文件等资源:
1 | InputStream inputStream = UserService.class.getClassLoader() |
在排查类加载问题时,常用的入口包括:
| API | 作用 |
|---|---|
loadClass(String) |
请求加载指定二进制名称的类 |
findClass(String) |
在当前类加载器的搜索范围内查找类,通常由子类实现 |
defineClass(...) |
将字节数组定义为 JVM 中的类 |
findLoadedClass(String) |
检查当前加载器是否已经加载过指定类 |
getParent() |
获取当前类加载器的父加载器 |
getResource(String) |
查找指定名称的资源 |
JVM 内置类加载器
Java 提供了几种内置类加载器。需要注意,JDK 8 与 JDK 9 之后的类加载器体系存在变化,主要区别是原来的扩展类加载器被平台类加载器取代。
JDK 8 的类加载器体系
1 | flowchart TD |
Bootstrap ClassLoader:启动类加载器
启动类加载器负责加载 Java 核心运行时所需的类,例如 java.lang.Object、java.lang.String 等。JDK 8 中,这些类主要来自传统运行时类库,例如 rt.jar。
Bootstrap ClassLoader 由 JVM 内部实现,不是普通的 Java 对象。因此,通过下面的代码获取 Object 的类加载器,通常会得到 null:
1 | System.out.println(Object.class.getClassLoader()); |
这里的 null 并不意味着 Object 没有被加载,而是表示它由 JVM 内部的启动类加载器定义。
Extension ClassLoader:扩展类加载器
扩展类加载器在 JDK 8 中主要负责加载 JRE 扩展目录中的类库,典型目录为 $JAVA_HOME/jre/lib/ext。这一扩展机制已经在 JDK 9 中移除。
Application ClassLoader:应用类加载器
应用类加载器也称为系统类加载器(System ClassLoader),主要负责加载应用程序类路径(ClassPath)中的类。例如使用 java -cp 指定的类路径,通常由应用类加载器搜索。
1 | ClassLoader loader = ClassLoader.getSystemClassLoader(); |
JDK 9 及之后的类加载器体系
JDK 9 引入 Java 平台模块系统(JPMS),同时调整了原有的扩展机制。Extension ClassLoader 被 Platform ClassLoader 替代,内置类加载器体系通常可以概括为 Bootstrap、Platform 和 Application 三层。
1 | flowchart TD |
Platform ClassLoader:平台类加载器
平台类加载器负责加载 Java 平台相关的类。它承接了原扩展类加载器的一部分职责,但不能简单理解为“所有 JDK 自带类都由它加载”;一部分核心类仍由 Bootstrap ClassLoader 加载。
Application ClassLoader:应用类加载器
应用类加载器仍然负责应用程序的类路径和相关应用模块中的类。可以通过下面的代码查看当前 JDK 中的类加载器:
1 | public class ClassLoaderDemo { |
在典型的 JDK 9+ HotSpot 实现中,应用类加载器的父加载器是 Platform ClassLoader;Platform ClassLoader 的 getParent() 通常返回 null,这是因为它的父加载器是 JVM 内部的 Bootstrap ClassLoader。
JDK 8 与 JDK 9+ 的区别
| 对比项 | JDK 8 | JDK 9+ |
|---|---|---|
| 启动类加载器 | Bootstrap | Bootstrap |
| 中间层加载器 | Extension | Platform |
| 应用类加载器 | Application | Application |
| 扩展机制 | jre/lib/ext 等传统机制 |
原扩展机制移除,采用模块化运行时体系 |
| 核心类组织 | 传统运行时类库 | 模块化运行时映像等 |
对于仍然使用 JDK 8 的项目,需要熟悉 Bootstrap、Extension 和 Application 三种加载器;如果项目升级到 JDK 11、17 或 21,则需要理解 Platform ClassLoader 和模块化系统带来的变化。
自定义类加载器
内置类加载器能够满足大多数普通 Java 应用的类加载需求,但有些场景需要从特殊位置获取字节码,例如从网络、数据库、加密文件或插件目录加载类。这时可以通过继承 ClassLoader 实现自定义类加载器。
自定义类加载器的实现
常见方式是重写 findClass(),在其中完成字节码查找和读取,再调用 defineClass() 将字节数组定义为类。
1 | import java.io.IOException; |
这里示例从指定目录读取字节码。实际使用时,需要确保目标目录不在父加载器已经能够搜索到的路径中,否则默认父加载器优先策略可能会先返回父加载器定义的同名类。
loadClass、findClass 和 defineClass 的区别
这三个方法经常一起出现,但职责并不相同:
| 方法 | 主要职责 |
|---|---|
loadClass() |
承担类加载入口,通常包含检查已加载类、委托父加载器和调用 findClass() 等流程 |
findClass() |
在当前类加载器的搜索范围内查找字节码,通常由自定义类加载器重写 |
defineClass() |
将字节数组转换为 JVM 中的类定义 |
因此,大多数遵循父加载器优先策略的自定义类加载器只需要重写 findClass(),不需要重写 loadClass()。如果直接重写 loadClass(),就意味着需要自行维护加载顺序、并发加载、重复定义等问题。
类的身份:为什么同名类也可能不是同一个类?
理解类加载器时,一个非常重要的结论是:JVM 判断普通类的运行时身份,不仅看类的二进制名称,还要看定义该类的类加载器。
更准确地说,类的身份由“类的二进制名称 + 定义类加载器”共同确定。这里要区分定义类加载器(Defining Loader)和发起类加载器(Initiating Loader):发起加载请求的加载器不一定最终亲自定义这个类。例如,Application ClassLoader 发起加载 java.lang.String 的请求,但最终定义 String 的是 Bootstrap ClassLoader。
假设两个相互独立的类加载器分别定义了同名的 com.example.User,即使类名完全相同,JVM 也会把它们视为不同的运行时类型。
1 | flowchart TD |
这也是一些多类加载器环境中出现下面异常的原因:
1 | ClassCastException: |
异常信息中的两个类名看起来相同,但它们可能由不同的类加载器定义,因此不能相互转换。
小结
类加载器负责获取并定义类。JDK 8 与 JDK 9+ 的内置类加载器体系有所不同,但都包含启动层和应用层等职责明确的加载器。自定义类加载器可以扩展字节码来源,而类的运行时身份由二进制名称和定义类加载器共同决定。
到这里,我们已经知道 JVM 中存在多个类加载器。接下来的问题是:当一个类加载器需要加载类时,为什么通常不会立刻自己查找,而是先委托父加载器?这正是双亲委派模型要回答的问题。
双亲委派模型
什么是双亲委派模型?
双亲委派模型(Parent Delegation Model)是 Java 默认类加载器体系中采用的一种协作机制。它的核心思想是:当一个类加载器收到类加载请求时,优先将请求委托给父加载器处理;只有父加载器无法完成加载时,当前类加载器才尝试自己加载。
这里的“双亲”并不是指两个父加载器。通常一个类加载器只有一个 parent 引用,父子关系描述的是类加载器之间的委派层次。
双亲委派的执行流程
假设 Application ClassLoader 需要加载 com.example.User,默认加载流程可以概括为:
- 先检查当前类加载器是否已经加载过目标类。
- 如果没有加载过,则优先委托父加载器加载。
- 父加载器如果还有父加载器,就继续向上委托,直到 Bootstrap 层。
- 如果父加载器无法加载,加载请求再逐级返回。
- 最终由当前类加载器尝试在自己的搜索范围中查找并定义类;如果仍然找不到,则抛出
ClassNotFoundException。
1 | flowchart TD |
因此,双亲委派的请求传递方向是从子加载器向父加载器逐级委托;如果父加载器加载失败,才逐级返回子加载器尝试。可以简单记为:先向上委托,再向下尝试。
通过 ClassLoader 源码理解双亲委派
理解双亲委派模型,最直接的方式是阅读 ClassLoader.loadClass()。下面是保留核心逻辑的简化伪代码,重点用于理解执行顺序,并不是可直接复制编译的完整 JDK 源码:
1 | protected Class<?> loadClass(String name, boolean resolve) |
需要关注三个关键方法:
findLoadedClass():检查当前加载器是否已经定义过指定名称的类。parent.loadClass():把加载请求委托给父加载器,这是默认双亲委派逻辑的核心。findClass():父加载器无法加载时,由当前加载器在自己的搜索范围中查找类。
默认实现还会使用类加载锁来协调并发加载。JDK 的 ClassLoader API 鼓励遵循委派模型的自定义加载器重写 findClass(),而不是直接重写 loadClass()。
为什么需要双亲委派模型?
避免核心类库被应用随意替换
Java 提供了大量基础类库,例如 java.lang.String、java.lang.Object、java.lang.Thread 和 java.util.ArrayList。这些类是 Java 运行环境的基础,如果应用程序可以随意定义同名类并优先加载,就可能破坏运行环境中的核心类型约定。
在默认的父加载器优先模型下,当 Application ClassLoader 收到 java.lang.String 的加载请求时,会先委托给父加载器,最终由 Bootstrap ClassLoader 定义 JDK 提供的 String,而不是优先使用应用类路径中的同名类。
此外,现代 JDK 还对 java.* 等受保护包以及模块边界有额外约束。因此,核心类库保护并不只依赖双亲委派这一项机制。
降低核心类型被恶意或意外替换的风险
如果第三方代码能够轻易替换 JVM 依赖的基础类型,可能造成类型语义不一致,影响运行环境的可靠性。父加载器优先机制让核心运行时类由更高层级的加载器统一定义,降低了应用程序通过普通类路径替换核心类的风险。
需要强调,双亲委派不是完整的安全沙箱。Java 平台安全还涉及字节码验证、模块访问控制、运行时权限边界等机制,不能认为只要采用双亲委派就不存在安全问题。
维护共享类型的一致性
多个业务模块可能需要共同使用 java.lang.String、java.util.List 等基础类型。如果各模块随意定义自己的同名核心类型,就可能出现方法参数、返回值和对象转换不兼容的问题。
通过父加载器优先机制,各模块可以复用上层加载器已经定义的共享类,降低同一核心类型被重复定义的概率。
双亲委派模型的常见误区
误区一:双亲委派是 JVM 规范强制要求所有类加载器遵守的规则。
不是。ClassLoader 的默认实现采用父加载器优先的委派策略,但自定义类加载器可以重写 loadClass(),实现子加载器优先或其他加载策略。双亲委派是 Java 默认类加载体系中的重要设计,不是所有类加载器都不可改变的强制规则。
误区二:父加载器能够直接访问子加载器加载的类。
通常不能。父子关系表示请求委派方向,不代表父加载器能够自动搜索子加载器的类路径。父加载器并不知道子加载器的全部资源位置,这也是后面 JDBC 场景需要线程上下文类加载器的原因之一。
误区三:所有类最终都由 Bootstrap ClassLoader 加载。
不是。Bootstrap 只负责它能够加载的核心运行时类。业务类通常由 Application ClassLoader 定义;插件类、Web 应用类也可能由各自的自定义类加载器定义。
误区四:只要类名相同,加载结果就一定相同。
不是。类的运行时身份还取决于定义类加载器。不同类加载器可以分别定义同名类,得到不同的运行时类型。
小结
双亲委派模型的关键是“先委托父加载器,再由当前加载器尝试”。这种机制让核心类和共享类型优先由上层加载器统一定义,降低核心类被应用类路径覆盖的风险,也有助于维护类型一致性。
但双亲委派并不是所有应用场景都必须采用的唯一策略。当框架需要加载应用侧实现,或不同应用需要隔离依赖时,就需要更灵活的类加载方式。JDBC 和 Tomcat 就是两个典型案例。
双亲委派模型的突破与应用
JDBC:通过 SPI 和线程上下文类加载器加载驱动
JDBC 是理解双亲委派模型突破的经典案例。
Java 提供了 java.sql.Driver 接口和 DriverManager 驱动管理类,而 MySQL、PostgreSQL 等数据库厂商会在自己的驱动 JAR 中提供接口实现。
例如,应用程序引入 MySQL Connector/J 后,就可以通过 JDBC API 获取连接:
1 | Connection connection = DriverManager.getConnection( |
这里存在一个值得思考的问题:JDBC API 属于 Java 平台类库,而 MySQL 驱动通常位于应用程序依赖中。上层框架如何发现由应用侧类加载器加载的驱动实现?
SPI 的基本思想
SPI(Service Provider Interface)是一种服务发现机制。它通常由框架定义服务接口,由第三方提供实现类,再通过约定的配置文件或模块声明,让框架发现并加载这些实现。
以传统 classpath 下的 JDBC 驱动为例,驱动 JAR 中通常会包含如下文件:
1 | META-INF/services/java.sql.Driver |
文件内容指向服务实现类,例如:
1 | com.mysql.cj.jdbc.Driver |
应用也可以通过 ServiceLoader 主动发现服务实现:
1 | ServiceLoader<Driver> loader = |
线程上下文类加载器(TCCL)
ServiceLoader.load(service) 默认使用当前线程的上下文类加载器(Thread Context ClassLoader,简称 TCCL)查找服务提供者。可以通过下面的代码获取或设置它:
1 | ClassLoader loader = |
对于应用程序主线程而言,上下文类加载器通常是系统类加载器。这样,位于平台类库中的框架代码就可以借助线程上下文类加载器,发现应用类路径中的第三方实现。
JDBC 驱动发现过程
1 | flowchart TD |
在现代 JDBC 驱动中,通常不再需要开发人员手动调用 Class.forName("com.mysql.cj.jdbc.Driver") 才能完成驱动发现。DriverManager 初始化时会尝试通过服务提供者机制加载驱动;其实现说明也指出,服务提供者查找会使用触发初始化线程的上下文类加载器。
因此,这并不是简单地让父加载器反向委托给子加载器,而是框架主动使用 TCCL 去查找应用侧实现。SPI 负责描述服务与实现的发现约定,TCCL 则提供合适的类加载器。
Tomcat:通过独立类加载器实现 Web 应用隔离
Tomcat 是另一个经典案例。假设一个 Tomcat 实例中同时部署两个 Web 应用:
- Application A 使用 Spring Framework 5。
- Application B 使用 Spring Framework 6。
如果两个应用的所有依赖都由同一个类加载器加载,那么相同全限定类名的不同版本可能发生冲突。Tomcat 需要为不同 Web 应用建立独立的类加载空间,使每个应用能够优先使用自己的依赖。
Tomcat 的类加载器体系
Tomcat 默认的简化类加载器层次可以表示为:
1 | flowchart TD |
其中:
- Bootstrap:JVM 的启动类加载器。
- System:加载 Tomcat 启动时需要的系统类路径内容。
- Common:加载 Tomcat 内部和 Web 应用之间共享的类库。
- Webapp ClassLoader:每个 Web 应用通常拥有自己的类加载器,负责加载该应用的
WEB-INF/classes和WEB-INF/lib。
Tomcat 还允许通过配置增加 Server 或 Shared 等类加载器;它们不是默认简化层次中必然存在的部分。Server 类加载器用于 Tomcat 内部,Shared 类加载器可以供多个 Web 应用共享类库。
Webapp ClassLoader 为什么采用本地优先?
普通类加载器默认采用父加载器优先策略,而 Tomcat 的 Webapp ClassLoader 默认对多数应用类采用本地优先:先查看 Web 应用自己的目录和 JAR,再向父加载器委托。这样,Application A 可以加载自己的 Spring 5,Application B 可以加载自己的 Spring 6。
1 | flowchart TD |
这里需要特别注意:Tomcat 并不是对所有类都无条件采用子加载器优先。JVM 基础类,以及 Tomcat 实现的 Servlet、JSP、EL、WebSocket 等规范 API 类会受到特殊保护,通常优先委托父加载器,避免 Web 应用自行携带不兼容的 API 类破坏容器运行环境。
Tomcat 还可以通过 delegate="true" 配置调整搜索顺序,使 Web 应用采用父加载器优先策略。因此,准确的说法是:Tomcat 默认对多数 Web 应用类采用本地优先,同时对受保护的核心类和规范 API 保留父加载器优先规则。
Tomcat 如何实现应用隔离?
因为 Application A 和 Application B 分别由不同的 Webapp ClassLoader 定义自己的 Spring 类,即使类的全限定类名相同,也会被 JVM 视为不同的运行时类型。应用之间通过各自的类加载空间,减少了依赖版本冲突。
这也意味着,不同 Web 应用之间不能随意传递由各自加载器定义的任意实现类。如果确实需要共享对象,通常应该通过 Common 层可见的接口、DTO 或其他稳定边界进行交互。
Tomcat 热部署为什么与类加载器有关?
当 Web 应用重新部署时,Tomcat 可以停止旧应用、清理旧 Webapp ClassLoader 的相关资源,再创建新的 Webapp ClassLoader 加载新版本应用。
1 | flowchart TD |
但如果旧应用创建的线程没有停止,线程池仍然持有应用类,ThreadLocal 没有清理,或者某些静态变量、注册表、监听器仍然引用旧应用对象,那么旧类加载器就可能一直保持可达,导致类无法卸载,形成类加载器泄漏。因此,应用停止时需要正确释放线程、线程池、ThreadLocal、JDBC 驱动注册、事件监听器等资源。
OSGi 与模块化类加载
OSGi 是一种模块化 Java 运行环境。它将应用拆分为多个 Bundle,每个模块可以声明依赖、导入和导出包,并通过模块化的类加载策略实现隔离和共享。
OSGi 可以支持模块独立安装、启动、停止和更新,也能让不同模块拥有相对独立的依赖空间。它说明复杂 Java 应用不一定适合将所有类都放在同一个类加载器中。
另外,JDK 9 引入的 Java 平台模块系统(JPMS)主要解决模块依赖、模块可读性和包访问控制等问题。它与类加载器协同工作,但并不等同于 OSGi,也不代表传统 ClassLoader 机制被取代。
小结
双亲委派是 Java 默认类加载器体系中的常见协作方式,但复杂框架往往需要更灵活的加载策略。JDBC 通过 SPI 和线程上下文类加载器发现第三方实现;Tomcat 通过 Webapp ClassLoader 隔离不同 Web 应用的依赖;OSGi 则通过模块化类加载管理模块之间的依赖边界。
理解这些案例时,不要简单地把它们都称为“打破双亲委派”。更准确的分析方式是看具体场景改变了哪一部分:是通过 TCCL 选择了另一个加载器,还是重写加载顺序,抑或是建立了独立的模块类加载空间。
类加载机制在 Java 项目中的实践
Spring Boot 应用启动与类加载
以 Spring Boot 项目为例,启动应用时,JVM 首先需要加载启动入口类并执行 main() 方法,随后由 Spring Boot 启动框架创建 Spring 容器,完成组件扫描、配置解析、BeanDefinition 注册和 Bean 实例化等工作。
1 | flowchart TD |
这里需要区分 JVM 类加载和 Spring Bean 创建。UserService 首先必须是 JVM 中已经定义的 Java 类;Spring 则根据组件扫描和配置决定是否将它注册为 Bean,并负责后续的对象实例化、依赖注入和生命周期管理。
此外,Spring Boot 的可执行 JAR 可能使用框架自己的启动器和类加载器实现,例如某些版本会使用专门的 LaunchedClassLoader。不能简单认为所有 Spring Boot 应用中的业务类一定由普通 Application ClassLoader 直接加载,具体需要结合 Spring Boot 版本和打包方式判断。
如何排查类加载问题?
使用 JVM 参数查看类加载日志
在 JDK 8 中,可以使用:
1 | java -verbose:class -jar app.jar |
在 JDK 9 及之后的版本中,可以使用统一日志机制:
1 | java -Xlog:class+load=info -jar app.jar |
如果还需要观察类卸载日志,可以增加:
1 | java -Xlog:class+load=info,class+unload=info -jar app.jar |
这些日志可以帮助我们确认某个类是否已经加载、类的来源以及类加载和卸载事件。生产环境开启详细类加载日志前,需要考虑日志量和磁盘占用。
通过 Java 代码查看类加载器
1 | Class<?> userClass = User.class; |
如果需要比较两个类是否由同一个加载器定义,可以查看它们各自的 getClassLoader() 结果。不过,更直接的类型判断仍然应该结合 Class 对象本身,例如 classA == classB。
使用 Arthas 排查类加载问题
在生产环境中,如果不方便修改业务代码,可以借助 Arthas 查看 JVM 中已经加载的类和类加载器信息。
1. 使用 sc 查看已经加载的类
1 | sc com.example.User |
如果需要查看类的详细信息,包括类加载器、类的来源等,可以使用:
1 | sc -d com.example.User |
如果同一个全限定类名由多个类加载器分别定义,sc 可能会返回多条记录。这对于定位同名类重复加载、依赖冲突非常有帮助。
2. 使用 classloader 查看类加载器信息
1 | # 查看类加载器统计信息 |
其中,classLoaderHash 需要先从当前 JVM 的类加载器列表中获取。Arthas 的命令参数会随版本演进,实际使用时应以当前安装版本的 help classloader 和官方文档为准。
常见类加载异常
ClassNotFoundException
ClassNotFoundException 表示程序通过显式的类加载方式查找某个类,但没有找到对应的类定义。常见入口包括 Class.forName() 和 ClassLoader.loadClass()。
1 | Class.forName("com.example.User"); |
常见原因包括类名写错、缺少依赖 JAR、类路径配置错误,或者指定的类加载器无法访问目标类。排查时需要先确认类是否存在,再确认当前加载器能够搜索到它。
NoClassDefFoundError
NoClassDefFoundError 属于 LinkageError。它表示 JVM 在程序运行过程中需要某个类定义,但无法正常获得或使用该类。常见原因包括运行时依赖缺失、类定义状态异常,以及类初始化失败后再次使用该类。
如果首次初始化某个类时发生异常,后续再次使用时可能看到类似 Could not initialize class 的 NoClassDefFoundError。因此,排查时不要只看最后一次异常,还需要寻找第一次触发失败的根因。
| 对比项 | ClassNotFoundException | NoClassDefFoundError |
|---|---|---|
| 类型 | Exception | LinkageError |
| 常见场景 | 显式请求加载类但未找到 | 运行时需要类定义却无法正常使用 |
| 常见排查点 | 类名、依赖、类路径、指定加载器 | 运行时依赖、链接状态、初始化失败原因 |
ClassCastException
如果异常中出现类似 com.example.User cannot be cast to com.example.User 的信息,需要考虑同名类是否由不同类加载器定义。排查时可以使用 Arthas sc -d 查看每个类的来源和定义加载器,再确认对象实际对应的 Class 是否一致。
LinkageError
LinkageError 是链接相关错误的父类,常见子类包括 NoClassDefFoundError、VerifyError、ClassFormatError、UnsupportedClassVersionError、ClassCircularityError 和 IncompatibleClassChangeError 等。
这类问题通常需要结合完整堆栈、运行时 JAR 版本、字节码版本、类加载器来源以及类之间的依赖关系进行分析。
类加载与热部署
普通类加载器一旦定义了某个类,通常不能简单地通过再次加载同名 Class 文件来替换已经定义的类。如果需要在运行期间更新类的实现,可以根据场景选择不同机制。
方式一:创建新的类加载器。 插件系统或应用容器可以创建新的类加载器加载新版本类。由于新类由不同加载器定义,它与旧类是不同的运行时类型,因此通常需要重新创建对象,并处理接口兼容和状态迁移。
方式二:使用 Java Instrumentation。 Java Agent 可以通过 Instrumentation 提供的类转换、重定义或重转换能力修改已加载类的字节码。但这类能力受到 JVM 约束,通常不能随意增加或删除字段、方法,也不能任意改变继承关系。
方式三:使用框架提供的重启机制。 Spring Boot DevTools 等开发工具可以通过独立的类加载器组织稳定依赖与业务代码,在业务代码变化后创建新的重启类加载器,重新加载应用类。这种机制主要服务于开发环境的快速重启,不等同于任意修改生产环境中的类结构。
小结
在实际 Java 项目中,类加载机制不仅影响程序启动,也会影响框架扫描、依赖隔离、热部署和故障排查。遇到类找不到、同名类转换失败、依赖版本冲突或热部署后元空间持续增长等问题时,应该把类加载器及其引用关系纳入排查范围。
JVM 参数可以帮助观察加载和卸载日志,Java 代码可以查看类加载器归属,Arthas 则可以在运行中的 JVM 中搜索类、查看类来源和类加载器层次。
总结
通过前面的介绍,可以从三个层面理解 JVM 类加载机制。
第一,类加载生命周期解决“一个类如何进入 JVM”的问题。 JVM 通过加载获取字节码,通过验证、准备和解析完成连接,再通过初始化执行静态初始化逻辑,之后程序才能正常使用该类型;满足条件时,类及其运行时数据才可能被卸载。
第二,类加载器体系解决“由谁负责加载类”的问题。 Bootstrap、Extension/Platform、Application 以及自定义类加载器承担不同的类加载职责。类的运行时身份不仅由类名决定,还与定义类加载器有关。
第三,双亲委派模型解决“多个类加载器如何协作”的问题。 默认策略是先检查当前加载器,再委托父加载器,父加载器无法加载时再由当前加载器尝试。这有助于保护核心类库并维护共享类型的一致性。
不过,双亲委派不是所有场景下唯一的加载策略。JDBC 借助 SPI 和线程上下文类加载器发现第三方驱动,Tomcat 借助 Webapp ClassLoader 隔离不同应用,OSGi 则通过模块化类加载管理模块依赖。这些机制体现了 Java 类加载体系的灵活性。
归根结底,类加载生命周期关注类的创建过程,类加载器关注类从哪里来、由谁定义,双亲委派关注加载请求如何在加载器之间传递。 把这三层关系梳理清楚,再结合 JDBC、Tomcat 和生产排查案例理解,整个 JVM 类加载机制就会变得连贯起来。
参考资料
- Java Virtual Machine Specification - Loading, Linking, and Initializing
- Java ClassLoader API
- Java ServiceLoader API
- Java DriverManager API
- Apache Tomcat Class Loader How-To
- Arthas sc 命令
- Arthas classloader 命令
文章信息
| 时间 | 说明 |
|---|---|
| 2025-01-02 | 初稿 |