如何自定义类加载器?什么场景需要打破双亲委派?
简化版
自定义类加载器:继承 ClassLoader,重写 findClass 方法(在里面读取字节码、调 defineClass 把字节码转成 Class 对象)。为什么重写 findClass 而不是 loadClass——因为 loadClass 里实现了「双亲委派」逻辑,重写 findClass 能复用双亲委派(先让父加载器加载,父加载不了才走你的 findClass),不破坏委派机制。什么场景要自定义类加载器:① 从非标准位置加载类(加密的字节码、网络、数据库);② 类隔离(不同应用/模块的同名类互不干扰,如 Tomcat 给每个 webapp 一个类加载器);③ 热部署(丢弃旧类加载器、用新的加载改动后的类)。什么场景要打破双亲委派(重写 loadClass):① Tomcat 的 WebappClassLoader 优先加载 webapp 自己的类(不先问父);② JDBC/SPI 用线程上下文类加载器让启动类加载器「反向」委托给应用加载器;③ OSGi/模块化的复杂类加载。
详细版
自定义类加载器的标准写法:
public class MyClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] bytecode = loadBytecode(name); // 从文件/网络/数据库/解密后拿字节码
if (bytecode == null) throw new ClassNotFoundException(name);
return defineClass(name, bytecode, 0, bytecode.length); // 字节码 → Class 对象
}
private byte[] loadBytecode(String name) { /* 读取字节码,可解密 */ }
}
为什么重写 findClass 而非 loadClass:loadClass 的默认实现里写了双亲委派(先问父加载器、父加载不了才调 findClass)。重写 findClass 保留双亲委派;重写 loadClass 才是「打破双亲委派」。
打破双亲委派的三个经典场景:
| 场景 | 怎么打破 | 为什么 |
|---|---|---|
| Tomcat WebappClassLoader | 重写 loadClass,优先加载 webapp 自己的类 | 不同 webapp 类隔离,且 web 应用的类优先于服务器的类 |
| JDBC / SPI | 线程上下文类加载器(TCCL) | 启动类加载器加载的接口(java.sql.Driver)要用应用的实现类 |
| 热部署 | 换新的类加载器加载改动的类 | 同一个类被不同类加载器加载 = 不同的 Class,实现「换新丢旧」 |
⚠️ 一个类的「身份」由「全限定名 + 类加载器」共同决定——同一个 .class 被两个不同的类加载器加载,会得到两个不同的 Class 对象,互相
instanceof返回 false、强转会ClassCastException。这既是「类隔离」的原理(Tomcat 让不同 webapp 的同名类互不干扰),也是「热部署」的原理(用新类加载器加载改动后的类,旧的连同旧类加载器一起被 GC)。理解这一点是理解类加载器所有高级用法的钥匙。
完整版教学
一、自定义类加载器的标准写法
自定义类加载器的核心是「继承 ClassLoader,重写 findClass」:
public class MyClassLoader extends ClassLoader {
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] data = loadBytecode(name); // ① 拿到字节码(从任意来源)
return defineClass(name, data, 0, data.length); // ② 字节码 → Class
}
}
关键三步:
① 继承 ClassLoader
② 重写 findClass:负责"拿到字节码"(从哪拿由你决定)
③ 调 defineClass:把字节码数组转成 Class 对象(这是 JVM 提供的)
字节码可以来自:
- 文件(标准)、网络、数据库
- 解密后的字节码(防止 class 被反编译)
- 动态生成的字节码(配合 ASM/ByteBuddy)
自定义类加载器的价值是「掌控字节码的来源」——JVM 默认从 classpath 的文件加载,自定义后可以从网络、数据库、解密后的数据加载。核心是 findClass(拿字节码)+ defineClass(字节码转 Class,JVM 提供)。理解「自定义类加载器 = 继承 ClassLoader + 重写 findClass 拿字节码 + defineClass 转 Class、能掌控字节码来源」,就理解了它的基本写法。
二、为什么重写 findClass 而不是 loadClass
理解「重写哪个方法」是理解双亲委派的关键:
ClassLoader.loadClass 的默认实现(伪代码):
loadClass(name) {
① 检查类是否已加载(加载过就直接返回)
② 委派给父加载器:parent.loadClass(name) ← 双亲委派在这
③ 父加载器加载不了(抛异常),才调 findClass(name) ← 你的逻辑
}
所以:
重写 findClass → 保留双亲委派(先问父、父不行才走你的)→ 安全、推荐
重写 loadClass → 你自己控制委派逻辑 → 可以打破双亲委派
这是核心机制:loadClass 里实现了双亲委派逻辑,findClass 是双亲委派失败后的兜底。所以重写 findClass 会保留双亲委派(推荐,不破坏 JVM 的类加载安全);重写 loadClass 才是打破双亲委派(自己控制委派顺序)。绝大多数自定义类加载器只需重写 findClass。理解「loadClass 含双亲委派逻辑、findClass 是委派失败的兜底、重写 findClass 保留委派/重写 loadClass 打破委派」,就理解了两个方法的分工——这是类加载器最核心的机制。
三、类的身份:全限定名 + 类加载器
理解自定义类加载器的高级用法,关键是理解「一个类的身份由什么决定」:
一个类的"唯一身份" = 全限定名 + 加载它的类加载器
推论:同一个 .class 文件,被两个不同的类加载器加载
→ 得到两个"不同的" Class 对象!
→ 它们互相 instanceof 返回 false
→ 强转会抛 ClassCastException
例子:
ClassLoaderA 加载 com.foo.User → Class A$User
ClassLoaderB 加载 com.foo.User → Class B$User
A$User 和 B$User 是"两个类",不能互相赋值
这是理解类加载器所有高级用法的「钥匙」——类的身份 = 全限定名 + 类加载器,所以同名类被不同加载器加载会变成「两个类」。这个特性既能用来「隔离」(让两个模块的同名类互不干扰),也能用来「热部署」(用新加载器加载改动后的类,得到「新的类」)。理解「类的身份 = 全限定名 + 类加载器、同名类被不同加载器加载 = 两个不同的 Class(instanceof false、强转异常)」,就拿到了理解类隔离和热部署的钥匙。
四、场景一:类隔离(Tomcat)
自定义类加载器的一个核心场景是「类隔离」——最典型的是 Tomcat:
Tomcat 的问题:一个 Tomcat 跑多个 web 应用(webapp)
- webappA 用 Spring 4,webappB 用 Spring 5
- 如果用同一个类加载器,同名类会冲突!
Tomcat 的方案:每个 webapp 一个 WebappClassLoader
- webappA 的 Spring 类 由 ClassLoaderA 加载
- webappB 的 Spring 类 由 ClassLoaderB 加载
- 因为"类身份 = 类名 + 加载器",两个 Spring 互不干扰
→ 类隔离:同名类、不同版本,和平共处
类隔离靠「每个应用/模块一个独立类加载器」实现——因为类身份含类加载器,不同加载器的同名类是「不同的类」,天然隔离。Tomcat 给每个 webapp 一个 WebappClassLoader,让多个 web 应用的同名/不同版本类互不干扰。这也是 OSGi、Java 模块化的思路。理解「类隔离靠每个应用一个独立类加载器、同名类因加载器不同而隔离、Tomcat 给每个 webapp 一个 WebappClassLoader」,就理解了类隔离的原理和应用。
五、场景二:热部署
自定义类加载器的另一个核心场景是「热部署」——不重启就更新类:
热部署的原理(靠"换类加载器"):
1. 类加载器 CL1 加载了 UserService(旧版本)
2. 改了 UserService 代码,重新编译
3. 创建新的类加载器 CL2,用它加载新的 UserService
4. 应用改用 CL2 加载的 UserService(新版本)
5. CL1 和它加载的旧类 没有引用了 → 被 GC
关键:不能用同一个类加载器"重新加载"(同名类加载过就不再加载)
→ 必须"换一个新的类加载器",才能加载"新的类"
→ 旧类加载器连同旧类一起被丢弃、GC
热部署的原理是「丢弃旧类加载器、用新类加载器加载改动后的类」——因为同一个类加载器不会重复加载同名类(加载过就缓存了),必须换新加载器才能加载「新版本的类」。旧加载器和它加载的所有类失去引用后被 GC。JRebel、Spring Loaded 等热部署工具就是这个原理(配合字节码增强)。这也解释了为什么频繁热部署可能导致「类加载器泄漏」(旧加载器没被 GC 掉)。理解「热部署靠换新类加载器加载改动的类、旧加载器和旧类被 GC、同一加载器不能重新加载同名类」,就理解了热部署的原理。
六、场景三:打破双亲委派(SPI/线程上下文类加载器)
有些场景必须「打破双亲委派」,最经典的是 JDBC/SPI:
SPI 的困境(以 JDBC 为例):
- java.sql.Driver 接口 由启动类加载器加载(在 rt.jar/java.base 里)
- MySQL 的驱动实现 com.mysql.Driver 在应用 classpath 里
→ 由应用类加载器加载
- 问题:启动类加载器加载的 DriverManager 要用 MySQL 驱动
但启动类加载器"看不到"应用 classpath 的类(双亲委派只能向上问父)
→ 委派方向反了!
解决:线程上下文类加载器(Thread Context ClassLoader, TCCL)
- Thread.currentThread().getContextClassLoader() 拿到应用类加载器
- 用它去加载 MySQL 驱动
→ "打破双亲委派":让上层的类能用下层加载器加载的实现
有些场景双亲委派「方向不对」,必须打破:SPI(JDBC、JNDI、JAXP) 的接口由启动类加载器加载,但实现类在应用 classpath(应用类加载器)——双亲委派只能「向上问父」,而这里需要「向下用子加载器」,方向反了。解决办法是线程上下文类加载器(TCCL):把应用类加载器「塞」到线程里,让上层代码通过 getContextClassLoader() 拿到它去加载实现类。Tomcat 的 WebappClassLoader 也打破双亲委派(优先加载 webapp 自己的类,实现隔离和 web 类优先)。理解「SPI 因接口在上层/实现在下层导致双亲委派方向反了、用线程上下文类加载器打破委派、Tomcat 也打破委派实现隔离」,就理解了打破双亲委派的必要场景。
记忆钩子:「自定义类加载器 = 继承 ClassLoader + 重写 findClass(拿字节码)+ defineClass(转 Class);重写 findClass 保留双亲委派、重写 loadClass 才打破;核心钥匙:类身份 = 全限定名 + 类加载器(同名类不同加载器 = 两个类)→ 类隔离(Tomcat 每 webapp 一个加载器)+ 热部署(换新加载器加载改动类、旧的被 GC);打破双亲委派场景:Tomcat WebappClassLoader 优先加载自己的类、SPI/JDBC 用线程上下文类加载器解决接口在上层/实现在下层的方向问题」。
七、常见误区与追问
- 误区:自定义类加载器要重写 loadClass。 通常重写 findClass 就够了——loadClass 里有双亲委派逻辑,findClass 是委派失败后的兜底;重写 findClass 保留双亲委派(推荐、安全),只有要「打破双亲委派」时才重写 loadClass。
- 误区:同一个类只会有一个 Class 对象。 类的身份 = 全限定名 + 类加载器;同一个 .class 被两个不同类加载器加载会得到两个不同的 Class 对象,互相 instanceof false、强转 ClassCastException——这是类隔离和热部署的基础。
- 误区:热部署可以用同一个类加载器重新加载类。 不行——同一个类加载器不会重复加载同名类(加载过就缓存了);热部署必须换一个新的类加载器加载改动后的类,旧加载器和旧类被 GC。
- 误区:双亲委派永远不能打破。 有些场景必须打破——SPI/JDBC(接口在上层、实现在下层,委派方向反了,用线程上下文类加载器)、Tomcat WebappClassLoader(优先加载 webapp 自己的类实现隔离)、OSGi。
- 追问:为什么 JDBC 要用线程上下文类加载器? java.sql.Driver 接口由启动类加载器加载,但 MySQL 驱动实现在应用 classpath(应用类加载器加载);双亲委派只能向上问父,而这里要向下用子加载器加载实现——方向反了,所以用线程上下文类加载器把应用加载器传下去打破委派。
- 追问:Tomcat 的类加载器结构是怎样的? 每个 webapp 一个 WebappClassLoader(隔离不同应用),它打破双亲委派优先加载 webapp 自己的类(WEB-INF/classes、lib);上面还有 Common、Catalina、Shared 等加载器分层,实现 Tomcat 自身类和应用类的隔离与共享。
- 追问:频繁热部署为什么可能内存泄漏? 热部署靠丢弃旧类加载器让其被 GC;如果旧类加载器被某处强引用(如某个静态字段、线程持有),它和它加载的所有类、Metaspace 都无法回收,多次热部署后 Metaspace 溢出(类加载器泄漏)。
八、加强记忆
自定义类加载器:继承 ClassLoader,重写 findClass(读字节码,可从文件/网络/数据库/解密后拿)+ 调 defineClass(字节码转 Class)。重写 findClass 保留双亲委派(loadClass 里含委派逻辑、findClass 是委派失败的兜底,推荐);重写 loadClass 才打破双亲委派。理解一切的钥匙:类的身份 = 全限定名 + 类加载器——同一个 .class 被不同加载器加载 = 两个不同的 Class(instanceof false、强转异常)。基于此的三大场景:① 类隔离(Tomcat 给每个 webapp 一个 WebappClassLoader,同名/不同版本类互不干扰);② 热部署(换新类加载器加载改动的类,旧加载器和旧类被 GC;同一加载器不能重新加载同名类;旧加载器被强引用则内存泄漏);③ 从非标准位置加载(加密字节码防反编译、网络、动态生成)。打破双亲委派的场景:Tomcat WebappClassLoader(优先加载 webapp 自己的类)、SPI/JDBC(接口在上层启动加载器、实现在下层应用加载器,委派方向反了,用线程上下文类加载器 TCCL 解决)、OSGi。一句话「自定义类加载器 = 继承 ClassLoader + 重写 findClass + defineClass,类身份 = 类名 + 加载器是钥匙(→ 隔离/热部署),重写 findClass 保留委派、重写 loadClass 或用 TCCL 打破委派(Tomcat/SPI)」。