开头先分享一个自己写的小工具,使用tauri2开发的粘贴板工具:https://jingchuanyuexiang.com
一. 开篇
之前写了Jvm知识点 和 Java类结构和类加载这两个用来记录Java的基础知识,最近回去翻看发现漏了Java类加载器相关的知识点,这里就补充一篇。如果有小伙伴看到这篇文章,建议先去看下Java类结构和类加载这个再回来看本篇。
二. 类加载器介绍
类加载器是一个负责加载类的对象,类加载器负责 将类的二进制表示(不一定非要来自文件系统)加载到 JVM 中,并最终定义为一个 java.lang.Class 对象。ClassLoader 是一个抽象类,给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据。典型的策略是将名称转换为文件名,然后从文件系统中读取该名称的“类文件”。每个非数组类、非基本类型的Java类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。
JVM 中内置了三个重要的 ClassLoader:
BootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++实现,在Java代码中并没有这个类,通常表示为null,并且没有父级,主要用来加载 JDK 内部的核心类库( %JAVA_HOME%/lib目录下的rt.jar、resources.jar、charsets.jar等 jar 包和类)以及被-Xbootclasspath参数指定的路径下的所有类。ExtensionClassLoader(扩展类加载器):主要负责加载$JAVA_HOME/jre/lib/ext/目录下的 jar 包和类以及被java.ext.dirs系统变量所指定的路径下的所有类,这里说的扩展类,实际并不是我们自己写的那些扩展类、也不是Spring等那些所谓的扩展类,而是JDK 官方提供、但不属于 Java 核心(rt.jar)的可选扩展库,比如:javax.crypto.*、jdk.nio.zipfs.*。但是在Jdk9之后ExtensionClassLoader被移除,jre/lib/ext目录消失,被PlatformClassLoader替代,原因是Java 模块化(JPMS),并且ext 机制破坏模块边界。AppClassLoader(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。
比如一个jdk21的Springboot3.3.x项目,使用内嵌 Tomcat 由java -jar启动 ,其中的类加载列举情况如下:
| 类的来源 | 示例类 | 负责加载的 ClassLoader |
|---|---|---|
| JDK 核心类 | java.lang.String | BootstrapClassLoader |
| JDK 扩展类 | javax.crypto.Cipher | PlatformClassLoader |
| Spring Boot / Spring | org.springframework.boot.SpringApplication | LaunchedClassLoader |
| 业务代码 | com.xxx.demo.DemoApplication | LaunchedClassLoader |
| 内嵌 Tomcat | org.apache.catalina.startup.Tomcat | LaunchedClassLoader |
| 第三方依赖 | com.fasterxml.jackson.ObjectMapper | LaunchedClassLoader |
| SPI 实现 | META-INF/services/* | LaunchedClassLoader |
| 数组类 | String[] | JVM 自动创建 |
上面这个表格基于 JDK21 + Spring Boot 使用内嵌 Tomcat,并通过 java -jar 方式启动;
若在 IDE 或 classpath 模式启动,业务类与依赖类通常由 AppClassLoader 加载。在 IDE 中直接运行 Spring Boot 项目时,应用以 classpath 方式启动,JVM 使用 AppClassLoader 加载所有业务类与依赖类;只有在使用 java -jar 运行时,Spring Boot 才会创建 LaunchedClassLoader 来支持嵌套 jar 的类加载。
-
对于一个由
java -jar启动的Springboot项目,实际加载我们jar包中的class文件的,是:LaunchedClassLoader这个Springboot自定义的类加载器,自定义这个类加载器的主要原因是:解决JVM 原生类加载器无法从嵌套 jar 中加载类的问题。其实很简单,我们的Springboot应用一般都是打成jar包的,你解压这个jar包就可以看到我们的依赖都在jar包的BOOT-INF/lib/*.jar中,jar是个压缩文件,上面说的Jvm内置的三个加载器都无法直接加载其中的jar,所以Springboot自定义了一个类加载器LaunchedClassLoader,这个加载器并不属于AppClassLoader,它是 Spring Boot 在运行期创建的自定义类加载器,二者同为URLClassLoader的子类,仅在委派模型中形成父子关系。这个LaunchedClassLoader你在源码中也是找不到的,他真正存在的位置是你jar包中的:app_xxx.jar\org\springframework\boot\loader\launch\LaunchedClassLoader.class,只有你打成jar包才能看到。LaunchedClassLoader并没有改变 JVM 的双亲委派机制,它只是在遵守双亲委派的前提下,定制了findClass/loadClass的类查找来源,以支持 嵌套 jar 的加载。 -
对于一个在IDE里面使用常用的debug模式启动一个springboot项目,那么你看到的Spring、Springboot、你的业务代码 这些都是由
AppClassLoader加载的,因为不需要通过jar包就能启动。
三. 双亲委派模型
双亲委派模型(Parent Delegation Model)是 JVM 类加载机制中的一种设计模式,用于保证 Java 类加载的 安全性、一致性和避免重复加载。它规定了类加载器之间的委派关系。双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。如果有必要我们也可以打破这种模式。
┌──────────────────┐
│ BootstrapLoader │
│ (引导类加载器) │
└──────────────────┘
▲
│
┌──────────────────┐
│ PlatformLoader │
│ (平台/扩展类加载器) │
└──────────────────┘
▲
│
┌──────────────────┐
│ AppClassLoader │
│ (应用类加载器) │
└──────────────────┘
▲
│
┌─────────────┐
│ 业务类/依赖 │
└─────────────┘
3.1 双亲委派模型的执行流程
// 当前加载器的父级加载器
private final ClassLoader parent;
//...省略其他代码
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) { // 对于同一个类名,获取一个锁,保证线程安全
// First, check if the class has already been loaded
// 首先,检查这个类是否已经被加载过
Class<?> c = findLoadedClass(name);
if (c == null) { // 如果尚未加载
long t0 = System.nanoTime(); // 记录当前时间,用于性能统计
try {
if (parent != null) {
// 如果存在父类加载器,先委派给父类加载器加载
c = parent.loadClass(name, false);
} else {
// 如果父类加载器为空(通常是BootstrapClassLoader),尝试从BootstrapClassLoader加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器没有找到该类,会抛出 ClassNotFoundException
// 这里捕获后什么都不做,交由当前类加载器自己去加载
}
if (c == null) {
// 如果父加载器也没有找到类,那么调用当前加载器的 findClass 方法去加载
long t1 = System.nanoTime(); // 再记录时间
c = findClass(name); // 调用子类自定义的 findClass 方法去实际加载类
// 记录类加载统计信息
PerfCounter.getParentDelegationTime().addTime(t1 - t0); // 父委派耗时
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); // 自己查找类耗时
PerfCounter.getFindClasses().increment(); // 自己查找类的次数累加
}
}
if (resolve) {
// 如果调用者要求解析类,则解析之(解析包括链接阶段,准备和验证)
resolveClass(c);
}
return c; // 返回加载的类对象
}
}
上面这个是java.lang.ClassLoader双亲委派模型的实现源码的翻译注释版本。从代码可以看出实际是一个递归的流程,结合上面的源码,简单总结一下双亲委派模型的执行流程:
- 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)
- 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器
loadClass()方法来加载类)。加载请求会沿着父加载器链逐级向上委派,最终由BootstrapClassLoader尝试加载。 - 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的
findClass()方法来加载类) - 如果子类加载器也无法加载这个类,那么它会抛出一个
ClassNotFoundException异常。
3.2 为什么要使用双亲委派模型
原因:JDK 默认采用双亲委派模型,是为了防止核心类被篡改、保证类加载结果的唯一性,并使类加载行为具有确定性和安全性。
在双亲委派模型下,类加载请求会优先委派给父类加载器处理,只有在父类加载器无法加载目标类时,子类加载器才会尝试自行加载。
- 首先,双亲委派模型能够防止 Java 核心类被篡改。例如
java.lang.String等核心类只能由引导类加载器(BootstrapClassLoader)加载,应用程序即使在自身类路径中提供同名类,也无法覆盖核心类,从而保证了 JVM 的基础安全。 - 其次,该模型保证了类定义的唯一性。在 JVM 中,一个类由“类的全限定名 + 定义它的类加载器”共同确定。通过双亲委派机制,父加载器已经加载过的类会被子加载器直接复用,避免了同名类被多次加载而导致的类型不一致问题。
- 最后,双亲委派模型使类加载行为具有确定性和可预测性,降低了 JVM 和类加载器实现的复杂度。统一的加载顺序有助于不同 JVM 实现之间保持一致行为,也是 Java 作为通用运行时平台的重要设计基础。
双亲委派不是 JVM 的强制机制,而是 ClassLoader.loadClass 的默认实现策略,只有在明确理解其风险的前提下才应被打破。
3.3 打破双亲委派模型
在我们常用的环境里,最常见的并且需要了解的就是Tomcat打破了双亲委派模型,在标准的 JVM 双亲委派模型中,类加载请求会优先委派给父类加载器处理,只有在父类加载器无法加载时,子类加载器才会尝试自行加载。然而,Tomcat 并未完全遵循这一默认模型,而是对其进行了有目的的调整。
3.3.1 Tomcat 是如何打破双亲委派模型的
Tomcat 为每一个 Web 应用创建独立的类加载器(WebAppClassLoader)。该类加载器在加载类时,并非严格遵循“先父后子”的顺序,而是采用一种受控的 child-first 策略:
- 对于 Web 应用自身的类和第三方依赖(WEB-INF/classes、WEB-INF/lib),优先由 WebAppClassLoader 自行加载;
- 对于 Java 核心类(如 java.*)和容器自身的关键类,仍然遵循父类加载器优先加载。
这种机制并非彻底放弃双亲委派,而是对加载顺序进行局部反转,即在特定范围内由子加载器优先加载类。
┌──────────────────────┐
│ BootstrapLoader │
│ (JVM 引导类加载器) │
└──────────────────────┘
▲
│
┌──────────────────────┐
│ PlatformClassLoader │
│ (JDK 平台类加载器) │
└──────────────────────┘
▲
│
┌──────────────────────┐
│ AppClassLoader │
│ (应用类加载器) │
└──────────────────────┘
▲
│
┌──────────────────────────────────┐
│ CommonClassLoader │
│ (Tomcat 公共类加载器) │
└──────────────────────────────────┘
▲ ▲
│ │
┌──────────────────────┐ ┌──────────────────────┐
│ CatalinaClassLoader │ │ SharedClassLoader │
│ (Tomcat 容器类) │ │ (Web 应用共享类) │
└──────────────────────┘ └──────────────────────┘
▲
│
┌────────────────────────┐
│ WebAppClassLoader │
│ (每个 Web 应用一个) │
└────────────────────────┘
CommonClassLoader作为CatalinaClassLoader和SharedClassLoader的父加载器。CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用。因此,CommonClassLoader是为了实现公共类库(可以被所有 Web 应用和 Tomcat 内部组件使用的类库)的共享和隔离。CatalinaClassLoader和SharedClassLoader能加载的类则与对方相互隔离。CatalinaClassLoader用于加载 Tomcat 自身的类,为了隔离 Tomcat 本身的类和 Web 应用的类。
SharedClassLoader作为WebAppClassLoader的父加载器,专门来加载 Web 应用之间共享的类比如 Spring、Mybatis。- 每个 Web 应用都会创建一个单独的
WebAppClassLoader,并在启动 Web 应用的线程里设置线程线程上下文类加载器为WebAppClassLoader。各个WebAppClassLoader实例之间相互隔离,进而实现 Web 应用之间的类隔。
Tomcat 的WebAppClassLoader对 Web 应用自身的类采用child-first加载策略,即优先从WEB-INF/classes和WEB-INF/lib中加载;而对 Java 核心类及容器相关类仍然遵循父类加载器优先的原则,从而在保证安全性的同时实现 Web 应用之间的类隔离。
3.3.2 Tomcat 为什么必须打破双亲委派模型
- 支持多 Web 应用的类隔离
Tomcat 的核心职责是在同一个 JVM 中同时运行多个 Web 应用。
如果严格遵循双亲委派模型:所有 Web 应用的依赖类都会被父类加载器加载、不同应用之间将共享同一份类定义、一旦依赖版本不同,就会发生冲突。
通过child-first策略:每个 Web 应用可以加载自己的依赖版本,Web 应用之间实现真正的类隔离。 - 避免第三方依赖版本冲突
不同 Web 应用可能依赖同一个库的不同版本,例如:应用 A 依赖 spring-web 5.x、应用 B 依赖 spring-web 6.x,如果由父加载器统一加载:只能加载一个版本,另一个应用必然出错。Tomcat 通过优先加载应用自身依赖,解决了这一问题。 - 支持 Web 应用的热部署与热重载
Tomcat 支持对单个 Web 应用进行重新部署或热重载:卸载旧的WebAppClassLoader,创建新的类加载器并重新加载应用类。如果所有类都由父加载器加载:类将无法被卸载,会造成内存泄漏。打破双亲委派,使 Web 应用类只由子加载器加载,从而可被整体回收。
3.3.3 线程上下文类加载器
线程上下文类加载器(Thread Context ClassLoader,简称 TCCL)是 JVM 为了解决父加载器无法访问子加载器所加载类的问题,而引入的一种运行时类加载补充机制。它的本质是:把“类由谁来加载”的决定权,从“类调用方”转移到“线程的执行上下文”上。
在 Java 中,每个线程都维护了一个上下文类加载器:
ClassLoader cl = Thread.currentThread().getContextClassLoader();
那么为啥突然说道这个线程上下文类加载器呢?原因就是:在双亲委派模型中:父类加载器 无法看到 子类加载器 加载的类,这是 JVM 的安全设计。
举例说明就是我们所用到的SPI,例如:jdk中的java.sql.Driver,我们链接mysql一般用com.mysql.cj.jdbc.Driver,但是com.mysql.cj.jdbc.Driver是由WebAppClassLoader / AppClassLoader加载的,java.sql.Driver是由BootstrapClassLoader加载的,在双亲委派模型下:父加载器无法访问子加载器加载的类,BootstrapClassLoader 不可能直接加载应用类,所以,如果 SPI 这样写:
Class.forName("com.mysql.cj.jdbc.Driver");
就必然会报错了。
所以引入了线程上下文类加载器。
在 Tomcat / Spring Boot 中,请求线程是由容器创建,容器会在进入应用逻辑前:Thread.currentThread().setContextClassLoader(webAppClassLoader);
SPI 并非天生依赖线程上下文类加载器,但在双亲委派模型下,父类加载器无法访问子类加载器定义的类,而 SPI 的实现类通常位于应用类加载器中,因此 SPI 在实现层面必须借助线程上下文类加载器完成扩展类的加载。SPI + TCCL,本质上是在不破坏双亲委派模型前提下,解决“框架调用应用实现”的问题。
3694

被折叠的 条评论
为什么被折叠?



