← 返回题目列表

如何自定义类加载器?什么场景需要打破双亲委派?

困难 第 24 / 34 题 更新于 2026/07/28
类加载器自定义ClassLoader双亲委派SPI

简化版

自定义类加载器:继承 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 而非 loadClassloadClass 的默认实现里写了双亲委派(先问父加载器、父加载不了才调 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)」。