1. 先搞清楚:类加载机制到底在干什么
做 Java 开发的,基本都背过“类加载机制、双亲委派模型”这道面试题。但说实话,我早年只是背结论,直到线上遇到一次 ClassCastException,两个 jar 里的同一个类因为被不同类加载器加载,导致强制转换直接炸掉,才彻底明白这套机制不是用来背的,而是用来保命的。今天把这块完整拆开讲一遍,重点放在“为什么会有双亲委派”和“什么场景逼着你去打破它”这两个问题上。
先对齐一个基本概念:类加载机制解决的是“把字节码变成 JVM 里可用的 Class 对象”这件事。一个类从 .class 文件(或者 jar 里的字节流)被读入内存,到可以被 new 出来,中间要经过加载、验证、准备、解析、初始化这几个阶段。“加载”这步本身不复杂,做的事就是根据类的全限定名去找字节流,读进来,转成一个 Class 对象。但难点在于:一堆类文件放在不同的目录、不同的 jar、不同的 Web 应用里,JVM 怎么决定该找哪一个、先找哪一个、用谁来加载?这就是类加载器要解决的问题。
1.1 JVM 内置的三级类加载器
JVM 默认有三层类加载器,从高到低分别是:
- 启动类加载器(Bootstrap ClassLoader):用 C++ 实现,负责加载 JVM 自身运行需要的核心类,比如
java.lang.*、java.util.*。它没有对应的 Java 对象,所以在代码里getClassLoader()返回null。 - 扩展类加载器(Extension ClassLoader)/ 平台类加载器(Platform ClassLoader):JDK 8 里叫 Extension,负责加载
lib/ext下的类;JDK 9 之后模块化改造,改叫 Platform,负责加载一些 Java 平台模块。这一层在业务代码里出现频率不高。 - 应用程序类加载器(App ClassLoader):也叫系统类加载器,负责加载 classpath 下的所有类。我们平时写的代码,绝大多数由它负责。
这三层不是继承关系,而是“父加载器”和“子加载器”的委派关系。什么概念呢?AppClassLoader 的父加载器是 PlatformClassLoader,PlatformClassLoader 的父加载器是 Bootstrap。每个加载器内部都持有一个父加载器的引用,这个链条就是双亲委派模型赖以工作的基础。
1.2 双亲委派不是一纸规范,而是代码逻辑
很多人以为双亲委派是 JVM 规范里写死的条款,但其实它只是 ClassLoader#loadClass 方法里的默认实现逻辑,属于“约定优于配置”。具体来说,当请求某个类被加载时,当前加载器不会直接自己上,而是先把请求扔给父加载器,父加载器再扔给爷爷,一直层层向上;等父加载器发现自己加载不了,才会把工作丢回给子加载器。
这个流程保证了同一个类在 JVM 里只会被最顶层的那个加载器加载一次。比如你写了一个 Hello.java,就算 classpath 里同时存在 JDK 自带的 java.lang.String,你也根本不可能让 AppClassLoader 去加载你自己的 String,因为请求一路上行到 BootstrapClassLoader 时,它发现 java.lang.String 自己能加载,就直接返回了。子加载器压根没机会插手。
1.3 双亲委派解决了哪两个问题
第一个是安全问题。如果类加载器没有父子优先的约定,黑客可以在 classpath 里塞一个自己写的 java.lang.String,把核心类的行为给篡改掉,那整个 JVM 的安全体系就崩了。所以双亲委派实际上是一种天然的沙箱机制,把核心 API 的加载权牢牢锁在 Bootstrap 手里。
第二个是避免类的重复加载。这里多说一句,JVM 判断两个类是不是同一个,不只是看类名是否一致,还要看“加载它们的类加载器”是否是同一个。同一个类文件,被两个不同类加载器各加载一次,那它们在 JVM 里就是两个完全不同的 Class 对象,互相之间进行类型转换都会抛异常。双亲委派让相同 FQCN(全限定类名)的类尽量被同一加载器命中,从根上避免了重复加载的混乱。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从源码看双亲委派模型的真实实现
这一节直接看 JDK 源码。虽然不同版本实现细节略有差异,但核心逻辑几十年没变过。以 JDK 8 为例,java.lang.ClassLoader#loadClass 的大致代码如下:
java复制protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 先检查该类是否已经被当前加载器加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 2. 有父加载器,先让父加载器去尝试
c = parent.loadClass(name, false);
} else {
// 3. 没有父加载器,说明顶层是 Bootstrap,直接引导加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器找不到类,忽略这个异常,继续往下走
}
if (c == null) {
// 4. 父加载器没找到,自己再去找字节流
long t1 = System.nanoTime();
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
这段代码就是双亲委派模型的全部秘密。用大白话翻译:先从自己已加载列表里查,查不到就往上抛;父加载器说“这个类我加载不了”,自己才动手通过 findClass 加载。
2.1 JDK 9 之后的模块化改动
从 JDK 9 开始,类加载机制做了一个比较大的调整,原来的 ExtensionClassLoader 被 PlatformClassLoader 取代,而且 BootClassLoader 也变成了一个用 Java 实现的 ClassLoader。另外底层用模块来管理,loadClass 内部会优先走模块的解析逻辑,一个类归属到哪个模块、由哪个加载器负责,不再像以前那样单纯靠 classpath 扫描判断。
不过双亲委派的核心思想没有变,父优先仍然是默认行为。平时用 openjdk 8 还是 17 写业务,几乎感知不到差异;真正受影响的是那些深度依赖类加载扩展点的框架,所以如果要做框架级开发,建议直接把 JDK 9+ 的 ModuleLayer 和 ClassLoader 关系一次性搞透,能省下大量排查问题的时间。
2.2 这一步最容易踩的坑:重写 findClass 还是重写 loadClass
很多入门教程会教你“自定义 ClassLoader 时重写 findClass 就行”。这句话没错,但它和“打破双亲委派”其实是两码事。
findClass 的定位是“让子类能自定义从哪儿读字节流”,它只是给 loadClass 兜底用的。默认流程仍然是父加载器优先,只有父加载器找不到时,你重写的 findClass 才会被回调。所以如果你只是想实现“从指定目录加载类”,重写 findClass 是标准做法,根本不算打破双亲委派。
想真正打破双亲委派,要动的是 loadClass 本身,把“先问父加载器”的逻辑改成“先自己加载,不行再找父加载器”,甚至完全绕开父加载器。这个选择至关重要,后面第五部分我会用完整例子演示两者区别。现在先把结论立在这里:自定义 ClassLoader 加载指定路径的类,重写 findClass;想脱离父加载器的控制,才需要重写 loadClass。
3. 那为什么非要“打破”双亲委派
既然双亲委派的安全性这么好,为什么还会有人想去破坏它?答案是:在某些特定场景下,父加载器的“全知全能”反而成为一种束缚。
3.1 JDBC 这类 SPI 机制的天然矛盾
拿最经典的 JDBC 来说。java.sql.DriverManager 是 JDK 自带的类,由 BootstrapClassLoader 加载。但 DriverManager 要加载的 MySQL 驱动 com.mysql.cj.jdbc.Driver 却在应用的 classpath 里,BootstrapClassLoader 根本找不到它。按照双亲委派的逻辑,Bootstrap 发现自己加载不了,会把这个任务向下传递给子加载器,理论上最后 AppClassLoader 能兜住。但问题在于 DriverManager 底层当初并没有把类加载逻辑写活,它需要一种机制,让“自上而下”的委派链条被打破,让启动类加载器能够反向访问应用类加载器里的类。
JDK 给出的方案是线程上下文类加载器(Thread Context ClassLoader)。DriverManager 在初始化驱动时,会通过 Thread.currentThread().getContextClassLoader() 拿到当前线程绑定的类加载器,通常就是 AppClassLoader,然后用它去加载具体的驱动实现类。这就是一次典型的“父加载器借助线程上下文反过来请求子加载器”的委派打破。这种模式后来被整个 SPI(Service Provider Interface)机制大量使用,Dubbo、Spring 的 SpringFactoriesLoader,都是类似原理。
3.2 Tomcat 必须为每个 Web 应用做类隔离
再往下挖一层。Tomcat 作为一个 Web 容器,要同时跑多个 Web 应用。如果两个应用依赖了同一个第三方库的不同版本,双亲委派模型直接让 AppClassLoader 统一加载是行不通的:第一个应用加载了 log4j 1.2.17,第二个应用想用 log4j 2.x,类名相同但实现完全不同,强行走同一个类加载器必然冲突。
Tomcat 的做法是自己实现了一套 WebappClassLoader,每个 Web 应用一个。这套加载器的逻辑是“逆向委派”:Web 应用自己目录下的类优先加载,自己加载不到,才请求父加载器。同时它还规定了一些目录例外,比如 JVM 核心包、Servlet API 相关类仍然走父加载器优先,避免 Web 应用覆盖容器核心类。总结来说,Tomcat 打破双亲委派的初衷是类隔离,讲究的是“每个 Web 应用有自己的 classpath 主权”。
3.3 热部署场景的倒逼
还有一个常见场景是热部署。在 IDEA 里改完代码按一下 Ctrl+F10,或者生产环境用 Arthas 的 redefine 命令热更新类,本质都是要“替换”掉 JVM 里已有的 Class。但 JVM 默认不允许对同一个类加载器已加载的类做重复定义,所以热部署框架最常见的套路就是新建一个类加载器,加载新版本的类,再通过反射把旧对象替换成新对象。
如果这新加载器还是遵守双亲委派,那么父加载器里已经存在的同名类会被直接返回,根本轮不到新类加载器去读新字节码,热部署就失效了。所以热部署框架必须让新类加载器先尝试自己加载目标类,自己没找到再往上委派。打破双亲委派在这里不是可选项,而是必选项。
4. 打破双亲委派的几种主流玩法
先说一句大实话:打破双亲委派不是炫技,也不是写业务代码时随手能做的事。绝大多数常规项目里,老老实实遵循双亲委派就是最优解。真正需要动手打破的,是中间件、容器、框架这一层。下面把业界常见做法做个归纳。
4.1 重写 loadClass,实现“子优先”
把 ClassLoader#loadClass 里的顺序反转:先看看自己有没有加载过这个类,没有就先尝试 findClass 自己加载,加载不到再 super.loadClass(name) 委派给父加载器。这种做法是最直接、最粗暴的“打破”,Tomcat 的 WebappClassLoader 就是这类思路的工程化实现。
但直接反转顺序有一个巨大的风险:你能打破顺序,也就能把 JDK 核心类给拦下来。比如你在 loadClass 里什么类都先自己加载,一旦要加载 java.lang.String,如果自己找不到对应的字节流,再往上委派,看起来没问题;但如果自己的 findClass 因为写得不严谨,真的从某个可疑路径里读出同名类,那就会导致核心类被污染。所以所有正规实现里都会加一层“白名单/黑名单”,明确哪些类必须交给父加载器处理,哪些类允许自己加载。
4.2 线程上下文类加载器:不改 loadClass 也能打破
线程上下文类加载器算是一个“官方后门”。它本身不改变任何 ClassLoader 的加载逻辑,只是提供了一个 Thread 级别的类加载器引用,供上下层代码互相调用时使用。
典型链路是这样的:BootstrapClassLoader 加载了 DriverManager,但驱动实现类不在 Bootstrap 的扫描范围内。DriverManager 里需要拿到驱动实现类时,直接获取当前线程的 contextClassLoader(正常就是 AppClassLoader),交给它去 loadClass。这样执行流还是从上层核心类发起的,但实际加载工作却绕回了子加载器。所有 SPI 框架几乎是清一色用这个思路,它不像重写 loadClass 那样需要自己维护一套加载器链,只是把“当前上下文该用哪个加载器”的决策权开放出来了。
4.3 OSGi 的网状委派模型
OSGi 走得比 Tomcat 更彻底,它的每个 Bundle 都有独立的 ClassLoader,并且支持声明式导入导出包。A Bundle 的类加载器想去加载 B Bundle 里的一个类时,会直接通过 Bundle.loadClass 这种平级调用来实现,而不是顺着上下级链条走。这种模型已经谈不上“双亲委派”了,更像是一张网。
代价也很明显:OSGi 对类加载器的管理极其复杂,类解析的规则要专门写规范文档,出了问题非常难排查。现在企业级开发里直接用 OSGi 的并不多,但它当年对隔离性的探索,影响了很多现代框架的设计思路。Spring Boot 的嵌套 jar 加载器,虽然没有完全脱离父子结构,但那套“每个外部 jar 可以由独立加载器加载”的思路,多少能看出些类似的影子。
4.4 各种打破方式对比
| 方案 | 核心思路 | 打破程度 | 典型应用 |
|---|---|---|---|
| 重写 loadClass 实现子优先 | 反转父与子的加载顺序 | 彻底但危险 | Tomcat、Jetty 的 Web 应用类加载器 |
| 线程上下文类加载器 | 从线程上临时拿一个加载器来用 | 不改变加载器逻辑,只改变“执行者” | JDBC SPI、Dubbo、Spring 扩展点 |
| 独立平级加载器(OSGi 模型) | 每个 Bundle 各自独立,网络互调 | 完全脱离父子结构 | OSGi 容器、插件化架构 |
5. 动手实操:写一个真正打破委派的 ClassLoader
纸上谈兵没意思,这一步我们直接上手。为了把问题展示清楚,我会做一个极其简单的实验:同一个类文件,一份丢在父加载器能扫到的 classpath 下,一份放在外部目录里,然后用自定义 ClassLoader 去加载外部目录的副本,验证它到底有没有绕过父加载器。
5.1 准备测试类
先建一个最简单的类,假设我们的核心角色是一匹马:
java复制package demo.loader;
public class MyHorse {
private String name = "原版";
static {
System.out.println("MyHorse 被加载,加载器:" + MyHorse.class.getClassLoader());
}
public String say() {
return "我是 " + name;
}
}
把这行编译后的 MyHorse.class 复制两份:一份放在项目的 classpath 下,另一份单独放到 /tmp/cl-demo/demo/loader/ 目录下。这么做的目的是模拟同一全限定名的类,在“不同位置”存在的情况。默认情况下,classpath 那份会被 AppClassLoader 加载,而 /tmp 下的那份谁也看不见。
5.2 写一个自定义 ClassLoader
下面的类同时保留两条加载路径:findClass 实现读外部文件,loadClass 改为“子优先”:
java复制package demo.loader;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.nio.file.Path;
import java.nio.file.Paths;
public class BrokeClassLoader extends ClassLoader {
private String baseDir;
public BrokeClassLoader(String baseDir, ClassLoader parent) {
super(parent);
this.baseDir = baseDir;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 把点号路径转成文件路径
String filePath = baseDir + "/" + name.replace('.', '/') + ".class";
System.out.println("[" + name + "] 尝试自己的 findClass 加载: " + filePath);
try (FileInputStream fis = new FileInputStream(filePath);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buf = new byte[4096];
int len;
while ((len = fis.read(buf)) != -1) {
baos.write(buf, 0, len);
}
byte[] classBytes = baos.toByteArray();
return defineClass(name, classBytes, 0, classBytes.length);
} catch (Exception e) {
throw new ClassNotFoundException("自定义加载失败: " + name, e);
}
}
@Override
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
// 第一步:看一下自己是不是已经加载过
Class<?> c = findLoadedClass(name);
if (c != null) {
System.out.println("[" + name + "] 命中已加载缓存");
return c;
}
// 第二部:关键试验点
// 如果连 java.*、javax.* 都自己先去加载,很容易出问题,
// 所以这里人为保护系统类
if (name.startsWith("java.") || name.startsWith("javax.")
|| name.startsWith("sun.") || name.startsWith("jdk.")) {
System.out.println("[" + name + "] 是 JDK 核心类,交给父加载器");
return super.loadClass(name, resolve);
}
// 默认场景:破坏双亲委派,自己先来
try {
c = findClass(name);
System.out.println("[" + name + "] 自己加载成功");
return c;
} catch (ClassNotFoundException e) {
System.out.println("[" + name + "] 自己加载不到,走父加载器兜底");
return super.loadClass(name, resolve);
}
}
public static void main(String[] args) throws Exception {
Path path = Paths.get("/tmp/cl-demo");
// 注意把父加载器传成 null,或传 AppClassLoader,效果完全不同
BrokeClassLoader loader = new BrokeClassLoader("/tmp/cl-demo",
Thread.currentThread().getContextClassLoader());
Class<?> clazz = loader.loadClass("demo.loader.MyHorse");
Object obj = clazz.newInstance();
System.out.println("加载得到的 loadClass 对象:" + clazz.getClassLoader());
System.out.println("反射调用 say():" + clazz.getMethod("say").invoke(obj));
// 对比:如果用 AppClassLoader 自己加载,是什么加载器
Class<?> appLoaded = Class.forName("demo.loader.MyHorse");
System.out.println("普通的 Class.forName 使用的是:" + appLoaded.getClassLoader());
}
}
注意我在 loadClass 中加了一行白名单保护:java.、javax.、sun.、jdk. 等核心包直接走父加载器。这一步不是可选的,你亲手跑一下就会知道,如果不加这行,类加载到一半系统类初始化很容易出问题,输出也会乱成一团。
5.3 运行结果分析
编译运行这段代码,控制台会打出关键信息。先说 MyHorse 里的静态块,它会打印自己的加载器。如果我们打破双亲委派成功,控制台输出应该类似这样:
code复制[demo.loader.MyHorse] 尝试自己的 findClass 加载: /tmp/cl-demo/demo/loader/MyHorse.class
[demo.loader.MyHorse] 自己加载成功
MyHorse 被加载,加载器:demo.loader.BrokeClassLoader@xxx
加载得到的 loadClass 对象:demo.loader.BrokeClassLoader@xxx
反射调用 say():我是 原版
普通的 Class.forName 使用的是:sun.misc.Launcher$AppClassLoader@xxx
此时同一个 demo.loader.MyHorse 类,被 BrokeClassLoader 从 /tmp 加载了一份完全独立的副本,又被 AppClassLoader 从 classpath 加载了另一份。如果你尝试直接强转:
java复制demo.loader.MyHorse horse = (demo.loader.MyHorse) obj;
大概率会立刻遇到:
java复制Exception in thread "main" java.lang.ClassCastException:
class demo.loader.MyHorse cannot be cast to class demo.loader.MyHorse
这就直观证明了:两个类加载器各自加载了一次同名类,它们在 JVM 眼里没有任何血缘关系。
5.4 打破委派后如何避免 ClassCastException
上面例子跑完,很多人的第一反应是“这自定义加载器太危险了,一强转就炸”。所以真实工程里,突破双亲委派绝不是把 loadClass 反转就完事,还要有一系列的配套设计。
最常见的手法是把“接口和实现分离”。父加载器只负责加载接口,子加载器负责加载实现类。调用方在代码里写好接口类型,具体实例由框架通过反射创建,使用时面向接口调用。这样就算实现类被多份加载,也永远不会走到强转实现类那一步。Tomcat、Jetty、OSGi 的 Web 应用类加载器,内部就是这么规避冲突的。
6. 常见问题与排查技巧实录
关于类加载这块的报错,如果没经验很容易被绕晕。我把自己踩过和围观过的几类典型问题整理成一份速查,遇到可以照着看。
6.1 NoClassDefFoundError 和 ClassNotFoundException 别搞混
ClassNotFoundException 一般在调用 Class.forName、loadClass 时显式抛出,意思是“我去找类了但没找到”,链路明确,相对好排查。
NoClassDefFoundError 则特别有迷惑性,它通常是类 A 在加载时,内部引用的类 B 没法被同一个加载器找到。这种情况往往不是 B 不存在,而是 B 存在于另一个隔离的 classpath 里,或者 B 本身加载到一半出错了。比如 A 是父加载器加载的,B 在子加载器里,A 的字节码引用到了 B,那 NoClassDefFoundError 就来了。排查时优先确认 A 和 B 的加载器关系,别只盯着文件是否存在。
6.2 ClassCastException 的三种典型表现
前面例子里的“同名类强转失败”是第一类。
第二种是在 Tomcat 多 Web 应用环境里常遇到的:两个应用都引用了同一个基础库,但类被各自的 WebappClassLoader 各加载一份,某段互相传递对象的代码在强转时报错。比如应用 A 抛给应用 B 一个 DTO,B 侧强转 DTO 爆 ClassCastException,最常见的解决办法是把这个 DTO 提升到容器级共享库,让两个应用都使用同一个父加载器加载它。
第三种出现在反射场景。有时业务框架通过某个类加载器加载对象,而业务代码以为对象属于另一个加载器。比如 spring.factories 里有声明的实现类,如果自定义加载器的父加载器和 Spring 容器用的加载器不一致,拿到实现类实例后一强转同样炸。此时多打一行 obj.getClass().getClassLoader() 往往就能锁定问题。
6.3 类加载器导致的内存泄漏
这块比较隐蔽。ClassLoader 一旦被 Tomcat 等容器回收不了,最常见的原因是某个线程还持有它的引用。比如线程池里的线程通过 ThreadLocal 缓存了一个对象,而这个对象所属的类是由 Web 应用类加载器加载的,一旦线程没被清理,整个 Web 应用的 ClassLoader 连带所有类都泄漏了。之前 Tomcat 老报 Memory leak related to ThreadLocal 就是这个问题。
排查时可以用 dump 堆,用 MAT 去看 java.lang.Thread 持有哪些 ThreadLocal,再展开对象,一直顺藤摸瓜到 ClassLoader。预防方面,记得在任务结束后把 ThreadLocal 里对应的值 remove 掉。
6.4 常见问题速查表
| 现象 | 可能原因 | 快速排查手段 |
|---|---|---|
| ClassNotFoundException | 类不在当前加载器扫描路径 | 打印加载器,确认 classpath 覆盖范围 |
| NoClassDefFoundError | 被依赖的类没被当前类加载器加载到 | 检查依赖类归属的加载器,确认是否被隔离 |
| ClassCastException(同名类) | 同一个类被不同加载器加载 | 两边各打印 getClassLoader(),比较是不是同一个 |
| 静态资源/单例失效 | 同一个类被加载两份,静态变量也随之分裂 | 确认 class 是否经过多份加载 |
| 加载后热部署不生效 | 类被父加载器缓存,新加载器没机会介入 | 看 findLoadedClass 返回了什么,检查是否有父加载器提前加载 |
7. 我的实操体会
说了这么多,最后分享一点我的个人体会。类加载机制看起来像一个离业务很远的底层面试题,但一旦遇到真正的问题,几乎都跟“谁加载了它”脱不了干系。我自己后来养成的习惯是,在写任何带有扩展点的基础组件时,先想清楚:
- 这个类将来会被哪个类加载器加载?
- 使用者传入的类,和我内部定义的接口类,两个加载器是什么关系?
- 如果需要在模块间传对象,能否保证两边使用的是同一份类定义?
绝大多数普通业务项目是不需要自定义类加载器的,直接沿用默认双亲委派就够了。真正需要打破它的场景,往往是容器、中间件、热部署、插件化这类需要“隔离”的复杂系统。重写 loadClass 本身不复杂,麻烦的是打破之后的治理:白名单怎么维护、冲突怎么规避、内存怎么回收。如果你只是想让代码从一个自定义目录加载类,那请优先重写 findClass,别一上来就动 loadClass。这套机制用得好了是隔离沙箱,用不好就是给自己埋定时炸弹,多打印几次 getClassLoader() 总不会错。
