类加载器
类加载器负责把类的二进制字节流加载进 JVM,并决定这个类属于哪个命名空间。理解类加载器,是理解 Spring Boot 打包、插件化、热部署、依赖冲突和 ClassNotFoundException 的基础。
类与类加载器
判断类是否“相等”
任意一个类,都由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间。
因此,比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那么这两个类就必定不相等。
这里的“相等”,包括代表类的 Class 对象的 equals() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况。
加载器种类
系统提供了 3 种类加载器:
- 启动类加载器(Bootstrap ClassLoader): 负责将存放在
<JAVA_HOME>\lib目录中的,并且能被虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。 - 扩展类加载器(Extension ClassLoader): 负责加载
<JAVA_HOME>\lib\ext目录中的所有类库,开发者可以直接使用扩展类加载器。 - 应用程序类加载器(Application ClassLoader): 由于这个类加载器是 ClassLoader 中的
getSystemClassLoader()方法的返回值,所以一般也称它为“系统类加载器”。它负责加载用户类路径(classpath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
当然,如果有必要,还可以加入自己定义的类加载器。
flowchart TD
A["Bootstrap ClassLoader<br/>JDK 核心类"] --> B["Extension / Platform ClassLoader<br/>扩展或平台类"]
B --> C["Application ClassLoader<br/>classpath 应用类"]
C --> D["Custom ClassLoader<br/>插件、热部署、容器隔离"]双亲委派模型
什么是双亲委派模型
双亲委派模型是描述类加载器之间的层次关系。它要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。(父子关系一般不会以继承的关系实现,而是以组合关系来复用父加载器的代码)
类加载器工作原理
工作过程
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(找不到所需的类)时,子加载器才会尝试自己去加载。
在 java.lang.ClassLoader 中的 loadClass 方法中实现该过程。
flowchart TD
A["收到类加载请求"] --> B{"父加载器是否存在"}
B -->|是| C["委派给父加载器"]
C --> D{"父加载器能否加载"}
D -->|能| E["返回父加载器加载的 Class"]
D -->|不能| F["当前加载器尝试加载"]
B -->|否| F
F --> G{"是否找到字节码"}
G -->|是| H["定义 Class"]
G -->|否| I["抛出 ClassNotFoundException"]为什么使用双亲委派模型
像 java.lang.Object 这些存放在 rt.jar 中的类,无论使用哪个类加载器加载,最终都会委派给最顶端的启动类加载器加载,从而使得不同加载器加载的 Object 类都是同一个。
相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在 classpath 下,那么系统将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证。
双亲委派怎么被打破
先说准确结论
所谓“打破双亲委派”,通常不是删除父加载器,也不是让所有类都由子加载器加载,而是针对某些类改变标准 loadClass 的顺序或可见性规则。
标准双亲委派顺序:
findLoadedClass
↓ 未加载
parent.loadClass
↓ 父加载器找不到
findClass常见打破方式:
findLoadedClass
↓ 未加载
findClass 先尝试当前加载器
↓ 当前加载器找不到
parent.loadClass 再委派父加载器或者不直接改变 loadClass,而是通过线程上下文类加载器,让父层定义的框架代码反过来使用子层加载器发现实现类。
因此,“打破”可以分为两类:
| 类型 | 做法 | 代表场景 |
|---|---|---|
| 子优先加载 | 重写 loadClass,特定业务类先 findClass | Tomcat WebApp、插件隔离、热部署 |
| 反向委派 | 父层代码主动使用线程上下文类加载器 TCCL | JDBC、JNDI、SPI、框架扩展发现 |
标准 loadClass 源码做了什么
下面是 JDK 8 ClassLoader.loadClass 的核心结构,省略了非核心细节:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> clazz = findLoadedClass(name);
if (clazz == null) {
try {
if (parent != null) {
clazz = parent.loadClass(name, false);
} else {
clazz = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException ignored) {
// 父加载器找不到,当前加载器再尝试。
}
if (clazz == null) {
clazz = findClass(name);
}
}
if (resolve) {
resolveClass(clazz);
}
return clazz;
}
}关键点:
findLoadedClass防止同一个加载器重复定义同名类。- 标准实现先调用
parent.loadClass。 - 只有父加载器抛出
ClassNotFoundException,当前加载器才调用findClass。 loadClass决定委派策略;findClass通常只负责“当前加载器去哪里找字节码”。- 自定义加载器只重写
findClass,但不重写loadClass,通常没有打破双亲委派。
flowchart TD
A["调用loadClass"] --> B["findLoadedClass检查是否已加载"]
B --> C{"是否已经加载"}
C -- "是" --> D["直接返回Class"]
C -- "否" --> E["父加载器loadClass"]
E --> F{"父加载器是否找到"}
F -- "找到" --> D
F -- "找不到" --> G["当前加载器findClass"]
G --> H{"是否找到字节码"}
H -- "是" --> I["defineClass并返回"]
H -- "否" --> J["抛ClassNotFoundException"]方式一:重写 loadClass 实现子优先
下面是教学版子优先类加载器。它用于说明委派顺序,不建议把代码原样用于生产插件系统。
import java.net.URL;
import java.net.URLClassLoader;
public class ChildFirstClassLoader extends URLClassLoader {
public ChildFirstClassLoader(URL[] urls, ClassLoader parent) {
super(urls, parent);
}
@Override
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> loaded = findLoadedClass(name);
if (loaded != null) {
return loaded;
}
// JVM核心类和父子之间共享的API必须继续父优先。
if (mustDelegateToParent(name)) {
return super.loadClass(name, resolve);
}
Class<?> clazz;
try {
// 顺序被改变:先从当前加载器自己的URL中找。
clazz = findClass(name);
} catch (ClassNotFoundException notFoundByChild) {
// 当前加载器找不到,再交给父加载器。
clazz = super.loadClass(name, false);
}
if (resolve) {
resolveClass(clazz);
}
return clazz;
}
}
private boolean mustDelegateToParent(String name) {
return name.startsWith("java.")
|| name.startsWith("javax.")
|| name.startsWith("jdk.")
|| name.startsWith("sun.")
|| name.startsWith("com.example.plugin.api.");
}
}这段代码真正“打破”的位置是:
clazz = findClass(name);它被放到了父加载器调用之前。但仍然保留了父优先白名单,因为如果 API 接口也由插件加载器重新加载,会出现“类名完全相同,却不能强制转换”的问题。
为什么核心类仍然不能随意子优先
假设插件包里放了一份 java.lang.String,并不意味着就能替换 JVM 的 String:
java.*核心命名空间有 JVM 和安全限制;- Bootstrap ClassLoader 已经加载核心类型;
- JVM 内部、标准库和对象布局都依赖同一套核心类定义;
- 破坏核心类型唯一性会让 Java 类型系统失去基础。
所以生产级 Child First 一定是“选择性子优先”,不是所有包无条件子优先。
自己定义一个 java.lang.String 会怎么样
例如创建源文件:
package java.lang;
public class String {
public String() {
System.out.println("custom String");
}
}不能只回答“会被双亲委派交给 Bootstrap,所以自定义 String 无效”。完整结果要按阶段和 JDK 版本区分。
情况一:JDK 9+ 通常在编译阶段就被模块系统拒绝
JDK 9 引入模块系统后,java.lang 属于 java.base 模块。普通应用试图向这个包中定义类,javac 通常会报类似错误:
package exists in another module: java.base也就是说,普通项目甚至无法正常生成这个 java.lang.String.class。这是模块边界和拆分包限制,不需要等到双亲委派运行阶段。
情况二:旧版JDK即使编译出Class,正常应用仍使用JDK String
在某些旧 JDK 8 工具链或特殊编译方式下,可能得到同名 class 文件。普通应用通过 Application ClassLoader 加载 java.lang.String 时,标准 loadClass 先委派父加载器:
ApplicationClassLoader请求java.lang.String
↓
委派Platform/Extension层
↓
最终委派Bootstrap ClassLoader
↓
Bootstrap已经加载JDK自带String
↓
直接返回JDK String.class
↓
应用加载器不会再findClass自定义版本可以验证:
public class StringLoaderDemo {
public static void main(String[] args) {
ClassLoader loader = java.lang.String.class.getClassLoader();
System.out.println(loader);
System.out.println(java.lang.String.class
.getProtectionDomain()
.getCodeSource());
}
}String.class.getClassLoader() 通常输出:
null这里的 null 表示由 JVM 内部的 Bootstrap ClassLoader 加载,不是“String 没有类加载器”。
情况三:Child First强行定义也会被禁止
有人可能想到:既然父优先拿到 JDK String,那就写 Child First,直接读取自定义字节码并调用:
defineClass("java.lang.String", bytes, 0, bytes.length);普通自定义 ClassLoader 通常会在定义 java.* 类时被 JVM/ClassLoader 安全检查拒绝,出现:
java.lang.SecurityException: Prohibited package name: java.lang这是第二道保护。双亲委派保证标准加载顺序和核心类唯一性,禁止普通加载器定义 java.* 又进一步保护核心命名空间。
情况四:为什么不能假设能用它替换JDK String
String 在 JVM 中不是普通业务类:
- 类加载、常量池、字符串常量和字节码描述都依赖它;
- JVM 和 JDK 大量本地/内部代码假设它具有标准字段和行为;
- 字符串字面量在应用主逻辑执行前就已经大量存在;
- Bootstrap ClassLoader 极早加载核心类;
- JDK 9+ 的
java.base模块进一步封装核心包; - 普通自定义加载器禁止定义
java.*。
因此,在现代普通 Java 应用中,通过 classpath 放一个 java.lang.String 不能替换 JDK String。
历史上的旧 JDK 曾存在修改启动类路径、修改运行时镜像或使用底层 Instrumentation 等特殊方式,但那不属于普通业务类加载,也不应作为“可以覆盖 String”的项目方案。强行替换核心类可能导致 JVM 无法启动、链接错误或不可预测行为。
如果只是在自己的包里定义 String 呢
下面是允许的,因为全限定名不是 java.lang.String:
package com.example.model;
public class String {
}但在同一个源码中写:
package com.example.model;
public class Demo {
private String value;
}这里的 String 会优先解析为同包的:
com.example.model.String而不是:
java.lang.String若确实要使用 JDK String,需要写全限定名:
private java.lang.String value;这种命名虽然能编译,但会让阅读、导入、泛型和反射代码非常混乱,生产项目不应把业务类命名为 String、Object、System 等核心类型名称。
这个问题与类唯一性的关系
普通业务包中的同名类,只要全限定名不同,就是完全不同的类:
java.lang.String
com.example.model.String如果全限定名也相同,但定义加载器不同,JVM 类型身份理论上仍由:
全限定类名 + 定义类加载器共同确定。不过 java.* 核心命名空间有额外禁止规则,普通自定义加载器不能拿这个理论去定义第二份 java.lang.String。
面试标准回答
JDK 9+ 普通项目声明
package java.lang,通常编译阶段就会因为java.lang属于java.base模块而报package exists in another module: java.base。在旧 JDK 或特殊方式下即使生成了自定义java.lang.String.class,标准双亲委派也会先由 Bootstrap ClassLoader 返回 JDK 自带 String,应用加载器不会再加载自定义版本。如果重写 Child First 并直接defineClass,普通自定义加载器又会因为禁止定义java.*包而抛SecurityException: Prohibited package name: java.lang。所以普通应用不能通过 classpath 自定义 String 替换 JDK String。
为什么要打破:插件依赖隔离
假设主应用使用:
guava-20.jar插件 A 使用:
guava-31.jar如果完全父优先,插件 A 可能拿到主应用的 Guava 20,调用新版本方法时出现:
NoSuchMethodError子优先可以让插件自己的实现依赖由插件加载器加载,同时将共享 API 委派父加载器:
flowchart TD
A["ApplicationClassLoader"] --> B["加载共享Plugin接口"]
A --> C["PluginClassLoader A"]
A --> D["PluginClassLoader B"]
C --> E["插件A实现与Guava 31"]
D --> F["插件B实现与其他依赖版本"]
E --> G["实现对象可转换为父加载器的Plugin接口"]
F --> G共享接口必须由共同父加载器加载。如果插件包再加载一份同名接口:
主应用的 com.example.Plugin
加载器 = AppClassLoader
插件中的 com.example.Plugin
加载器 = PluginClassLoaderJVM 认为它们是两个不同类型,可能抛出:
ClassCastException: com.example.Plugin cannot be cast to com.example.Plugin异常文本看起来类名相同,真正差异是定义它们的类加载器不同。
Tomcat怎么打破双亲委派
Tomcat 要同时解决:
- 不同 Web 应用可以使用不同版本的业务依赖;
- Java 核心类和 Servlet API 不能被每个 Web 应用随意覆盖;
- Tomcat 自身实现类不应暴露给 Web 应用替换;
- Web 应用应该能优先加载
WEB-INF/classes、WEB-INF/lib中的多数类。
可以简化理解为:WebAppClassLoader 对 Web 应用私有类采用 WebApp First/Child First,但对 Java、Servlet 等受保护 API 仍然 Parent First。
flowchart TD
A["Bootstrap"] --> B["System"]
B --> C["Common ClassLoader"]
C --> D["WebAppClassLoader 应用A"]
C --> E["WebAppClassLoader 应用B"]
D --> F["A的WEB-INF/classes与lib"]
E --> G["B的WEB-INF/classes与lib"]因此应用 A 和 B 可以使用不同版本的 Spring、日志库或业务依赖,但共享 Tomcat/JDK 级 API。实际规则受 Tomcat 版本和配置影响,不能简单回答“Tomcat 完全不使用双亲委派”。
方式二:线程上下文类加载器 TCCL
为什么需要反向委派
Bootstrap/Platform 层加载的标准 API 只看得到自己的父级和自身,正常情况下看不到 Application ClassLoader 中第三方实现类。
例如:
java.sql.Driver 接口
由平台/启动层加载
MySQL Driver 实现
在应用classpath,由ApplicationClassLoader加载按严格父到子可见性,父层 API 无法直接发现子层实现。线程上下文类加载器允许框架代码获取当前线程指定的加载器:
ClassLoader contextLoader =
Thread.currentThread().getContextClassLoader();再用它加载实现或资源:
ServiceLoader<MyService> loader =
ServiceLoader.load(MyService.class, contextLoader);这形成了“父加载器定义接口,子加载器提供实现,父层代码借助 TCCL 加载子层实现”的反向路径。
flowchart TD
A["父层加载器加载SPI接口"] --> B["框架调用ServiceLoader"]
B --> C["读取当前线程TCCL"]
C --> D["TCCL查找META-INF/services"]
D --> E["加载应用或插件中的实现类"]
E --> F["实现类转换为父层共享接口"]TCCL使用Demo
ClassLoader old = Thread.currentThread().getContextClassLoader();
ClassLoader pluginLoader = createPluginClassLoader();
try {
Thread.currentThread().setContextClassLoader(pluginLoader);
ServiceLoader<Plugin> plugins =
ServiceLoader.load(Plugin.class, pluginLoader);
for (Plugin plugin : plugins) {
plugin.execute();
}
} finally {
Thread.currentThread().setContextClassLoader(old);
}必须在 finally 中恢复。线程池线程会复用,如果不恢复 TCCL:
- 后续任务可能错误加载上一个应用/插件的类;
- 插件 ClassLoader 被线程长期引用,无法卸载;
- Metaspace 和插件对象可能泄漏;
- 热部署后旧版本仍然存活。
JDBC与SPI为什么常被说成打破双亲委派
JDBC 的 API 位于 JDK 平台层,驱动实现位于应用依赖。JDBC 4 之后驱动可以通过 META-INF/services/java.sql.Driver 被发现。其本质不是让 Bootstrap 直接看见所有子类,而是标准库/SPI 机制在合适位置借助调用方或线程上下文加载器发现应用层实现。
面试时不要只说:
JDBC 打破了双亲委派。
更准确的回答:
JDBC/SPI 需要父层定义的接口发现子层 classpath 中的实现,因此借助线程上下文类加载器或调用方加载器建立反向可见路径。这不是全局取消双亲委派,而是对服务发现加载器来源的特殊选择。
OSGi为什么比简单Child First更复杂
OSGi 不是一棵单纯父子树,而是 Bundle 按导入/导出包建立模块依赖图。一个类可能先从本 Bundle 查找,再按 Import-Package 委托指定 Bundle,最后按动态导入规则处理。
它打破的是“所有加载器都沿固定父链向上”的单一路径,换来模块版本并存、动态安装卸载能力,也增加了依赖解析、类可见性和诊断复杂度。
打破后会出现哪些问题
| 问题 | 根因 | 典型现象 |
|---|---|---|
| 类型不兼容 | 同名类由不同加载器定义 | 同名类之间 ClassCastException |
| LinkageError | 同一加载约束遇到不兼容定义 | loader constraint violation |
| NoSuchMethodError | 实际加载到错误依赖版本 | 编译有方法,运行没有 |
| Metaspace泄漏 | 旧ClassLoader仍被线程/静态字段引用 | 热部署多次后Metaspace上涨 |
| SPI找不到实现 | TCCL错误或资源不可见 | ServiceLoader结果为空 |
| 安全边界破坏 | 子优先覆盖不应覆盖的API | 容器/框架运行异常 |
怎么排查到底由谁加载
代码中打印:
Class<?> type = target.getClass();
System.out.println(type.getName());
System.out.println(type.getClassLoader());
System.out.println(type.getProtectionDomain()
.getCodeSource()
.getLocation());
System.out.println(Thread.currentThread()
.getContextClassLoader());JVM 参数:
JDK 8:
-verbose:classJDK 9+:
-Xlog:class+load=info
-Xlog:class+unload=info运行中可用:
jcmd <pid> VM.classloader_stats
jcmd <pid> GC.class_histogramArthas:
sc -d com.example.Plugin
classloader -t
classloader -l排查依赖来源还要结合 Maven/Gradle 依赖树、Fat Jar 内容、Tomcat WEB-INF/lib 和容器共享 lib。
面试标准回答
双亲委派怎么被打破
双亲委派的标准逻辑在 ClassLoader.loadClass:先 findLoadedClass,再委派父加载器,父找不到才调用当前加载器的 findClass。打破通常有两种:第一,重写 loadClass,对业务或插件类先 findClass、失败再委派父加载器,Tomcat WebApp 和插件隔离属于选择性子优先;核心类与共享API仍保持父优先。第二,通过线程上下文类加载器,让父层定义的SPI/JDBC接口反向发现应用层实现。打破后要处理同名类不兼容、依赖版本冲突、ClassLoader/Metaspace泄漏和安全边界。
重写findClass算不算打破
通常不算。标准 loadClass 本来就在父加载器找不到后调用 findClass。只有改变了 loadClass 的父子查找顺序、绕过标准父链,或通过TCCL等机制建立反向加载路径,才通常称为打破或调整双亲委派。
为什么Tomcat不能完全父优先
如果完全父优先,多个Web应用会被迫使用容器或共同父加载器中的同一依赖版本,无法隔离各自 WEB-INF/lib。Tomcat对Web应用私有类采用选择性WebApp优先,对Java核心、Servlet API和容器关键类仍父优先,从而兼顾隔离和核心类型安全。
为什么打破后同名类不能强转
JVM 判断类型身份使用“类全限定名 + 定义它的类加载器”。两个加载器分别定义的 com.example.User 即使字节码完全相同,也是两个不同类型。因此共享接口必须由共同父加载器加载,插件只加载实现和私有依赖。
类加载器常见问题
| 问题 | 原因 | 排查方式 |
|---|---|---|
ClassNotFoundException | 运行时 classpath 找不到类 | 检查依赖包、启动命令、打包结果 |
NoClassDefFoundError | 编译有类,运行缺失或初始化失败 | 看完整异常链和依赖版本 |
ClassCastException 明明类名相同 | 同名类由不同加载器加载 | 打印 obj.getClass().getClassLoader() |
| 依赖版本冲突 | 多个 jar 含同名类 | 用 Maven dependency tree 或查看 fat jar |
| 热部署异常 | 旧类加载器未释放 | 检查线程、静态变量、缓存引用 |
代码 Demo:打印类加载器
下面程序可以直观看到不同类由哪个类加载器加载。
public class ClassLoaderDemo {
public static void main(String[] args) {
System.out.println(String.class.getClassLoader());
System.out.println(ClassLoaderDemo.class.getClassLoader());
System.out.println(ClassLoaderDemo.class.getClassLoader().getParent());
System.out.println(ClassLoaderDemo.class.getClassLoader().getParent().getParent());
}
}运行:
javac ClassLoaderDemo.java
java ClassLoaderDemoString 由启动类加载器加载,打印结果通常是 null;业务类通常由应用类加载器加载。
