类加载机制:双亲委派模型与打破
提出问题
类加载器是 Java 虚拟机的基石,负责将 .class 字节码文件加载到内存并生成对应的 java.lang.Class 对象。双亲委派模型(Parent Delegation Model)是 JDK 1.2 以来的默认加载策略,但许多面试者只记住了"向上委派,向下加载"的口诀,却说不清为什么要有这个模型、在什么场景下必须打破它、以及 Tomcat 和 JDBC 分别是怎么打破的。
生产线上,ClassLoader 导致的问题非常顽固:某个线上应用发布后 Metaspace 持续增长,两周后触发 OOM 进程被 Kill;或者同一个 jar 的不同版本冲突导致 NoSuchMethodError 反复横跳。这些都是 P7+ 工程师必须能独立定位的问题。理解类加载机制的本质,等于理解 Java 运行时隔离和安全性的第一道防线。
分析问题
双亲委派模型的完整链路
双亲委派模型定义了三层类加载器架构:
| 加载器 | JDK 8 名称 | JDK 9+ 名称 | 加载范围 | 实现语言 |
|---|---|---|---|---|
| 启动类加载器 | Bootstrap ClassLoader | Bootstrap ClassLoader | $JAVA_HOME/lib/rt.jar、java.lang.* 等核心类 | C++(JVM 内建) |
| 扩展/平台类加载器 | Extension ClassLoader | Platform ClassLoader | jre/lib/ext → JDK 9+ 加载平台模块 | Java |
| 应用类加载器 | Application ClassLoader | Application ClassLoader | classpath(-cp 或 CLASSPATH)上的类 | Java |
当 ClassLoader.loadClass() 被调用时,完整的执行流程如下:
// JDK 默认实现,核心逻辑约 30 行
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 第 1 步:检查该类是否已被当前 ClassLoader 加载过
// 避免重复加载,也是类唯一性的保证之一
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
// 第 2 步:委派给父加载器
// 递归向上,直到 Bootstrap ClassLoader
c = parent.loadClass(name, false);
} else {
// 第 3 步:没有父加载器,直接交给 Bootstrap
// Bootstrap 是 C++ 实现,通过 JNI 调用
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器无法加载 → 不处理,继续
}
if (c == null) {
// 第 4 步:父加载器链全都无法加载,自己尝试 findClass()
// 子类通过重写 findClass() 实现自定义加载逻辑
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}双亲委派模型的时序图:
Application ClassLoader Extension ClassLoader Bootstrap ClassLoader
│ │ │
│── loadClass("com.example.X")─────│ │
│ │── loadClass("com.example.X")─│
│ │ │── findLoadedClass("com.example.X") → null
│ │ │── findBootstrapClassOrNull → null
│ │〈── ClassNotFoundException─│
│ │── findClass("com.example.X") → 找不到
│〈── ClassNotFoundException───────│
│── findClass("com.example.X") → 加载成功
│
│── loadClass("java.lang.String")──│
│ │── loadClass("java.lang.String")─│
│ │ │── findLoadedClass → 已加载
│ │〈── 返回 String.class───────│
│〈── 返回 String.class────────────│这个模型的核心价值有两个:
安全性:
java.lang.String永远由 Bootstrap 加载。如果用户代码里写一个com.lang.String想替换核心 API,由于双亲委派机制,Bootstrap 层已经加载了java.lang.String,你自己的String类根本不会被加载。JDK 9+ 模块化(JPMS)通过package封装的导出规则进一步加固了这一点——Bootstrap 层模块的包如果未导出,子加载器连加载同名包都不允许。唯一性:同一个类在同一个 ClassLoader 空间内只加载一次。JVM 中判断两个类是否"相同"的标准是:全限定类名 + 定义类加载器。同一个 ClassLoader 加载同一个类两次,只会返回同一个
Class<?>对象。这是findLoadedClass()的缓存作用。
JDBC 的 SPI 如何打破双亲委派
JDBC 是"反向打破"的经典案例。java.sql.DriverManager 在 rt.jar 中,由 Bootstrap ClassLoader 加载。但数据库驱动(如 com.mysql.cj.jdbc.Driver)在应用程序的 classpath 上,由 Application ClassLoader 加载。Bootstrap 无法访问 Application 的类——这是双亲委派自身的盲区。
解决方案:线程上下文类加载器(Thread Context ClassLoader, TCCL)
// DriverManager 中获取驱动的本质逻辑(JDK 源码简化)
public class DriverManager {
static {
// 初始化时加载所有 META-INF/services/java.sql.Driver 中声明的驱动
loadInitialDrivers();
}
private static void loadInitialDrivers() {
String drivers = AccessController.doPrivileged(
new GetPropertyAction("jdbc.drivers")
);
// 核心:通过 ServiceLoader 加载
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
try {
while (driversIterator.hasNext()) {
// 这行会触发驱动类的加载和注册
// 驱动类的构造函数中会调用 DriverManager.registerDriver(this)
driversIterator.next();
}
} catch (Throwable ignored) {
// 一个驱动加载失败不影响其他驱动
}
}
}ServiceLoader.load(Driver.class) 内部做了什么?
public static <S> ServiceLoader<S> load(Class<S> service) {
// 获取当前线程的上下文类加载器
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return new ServiceLoader<>(service, cl);
}关键点:ServiceLoader 通过 TCCL(默认是 Application ClassLoader)来加载实现类,而不是用 Bootstrap ClassLoader。这就是父加载器请求子加载器加载,对双亲委派模型的逆向打破。
踩坑案例:TCCL 设置不当
我遇到过的一个生产事故:一个 Spring Boot 应用使用了自定义线程池,在线程池中通过 DriverManager.getConnection() 获取数据库连接。自定义线程池中的线程没有继承原来的 TCCL,TCCL 为 null。结果运行时抛出 ClassNotFoundException: com.mysql.cj.jdbc.Driver。
// 问题代码
executor = Executors.newFixedThreadPool(10);
executor.submit(() -> {
// 这里 TCCL 为 null,DriverManager 无法加载驱动
Connection conn = DriverManager.getConnection(url, user, pass);
});
// 修复方案:提交任务时设置 TCCL
executor.submit(() -> {
ClassLoader original = Thread.currentThread().getContextClassLoader();
try {
Thread.currentThread().setContextClassLoader(
this.getClass().getClassLoader() // 或者从主线程传入
);
Connection conn = DriverManager.getConnection(url, user, pass);
} finally {
Thread.currentThread().setContextClassLoader(original);
}
});类似的 SPI 机制还有 JNDI、JAXP、JAXB、SLF4J 等。面试追问通常会问:如果 setContextClassLoader 设置不当会发生什么?答案是:驱动/SDK 加载失败,连接池初始化报 ClassNotFoundException,这是微服务多模块部署中常见的坑。
Tomcat 的 Webapp ClassLoader 如何打破双亲委派
Tomcat 的类加载器体系包含 4 层:
Bootstrap ClassLoader
↑
└── Common ClassLoader(加载 $CATALINA_HOME/lib/*.jar)
↑
├── Catalina ClassLoader(加载 Tomcat 内部类)
│
└── Shared ClassLoader(所有 Webapp 共享)
↑
└── WebappX ClassLoader(每个 Web 应用一个)
├── WEB-INF/lib/*.jar
└── WEB-INF/classes/关键区别在于 Webapp ClassLoader 先自己尝试加载,加载不到才委派给父加载器:
// Tomcat 的 WebappClassLoaderBase.loadClass() 简化逻辑
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 第 1 步:检查本地缓存(已加载过就直接返回)
Class<?> clazz = findLoadedClass0(name);
if (clazz != null) return clazz;
clazz = findLoadedClass(name);
if (clazz != null) return clazz;
// 第 2 步:检查系统类(java.* 等,仍由 Bootstrap 加载)
// 防止篡改 JDK 核心 API
if (name.startsWith("java.")) {
try {
return parent.loadClass(name, resolve);
} catch (ClassNotFoundException e) { /* 忽略 */ }
}
// 第 3 步:自己加载(WEB-INF/lib 和 WEB-INF/classes)
// 这是打破的关键:先己后人
try {
return findClass(name);
} catch (ClassNotFoundException e) { /* 忽略 */ }
// 第 4 步:自己加载不到,才委派给父加载器链
// 遵循双亲委派继续向上查找
return super.loadClass(name, resolve);
}为什么需要这种"先己后人"的设计?
场景:一个 Tomcat 部署两个应用,A 用 Spring 4.3.30,B 用 Spring 5.3.25。如果遵循双亲委派:
父加载器加载 spring-core-4.3.30.jar →
B 应用尝试加载 Spring 5 的 DispatcherServlet →
父加载器返回 Spring 4 的版本 →
B 应用运行时调用一个 4.x 没有的方法 → NoSuchMethodError每个 Webapp 自己的 ClassLoader 先加载自己 WEB-INF/lib 下的 jar,完美实现了类版本隔离。但有两个例外:
java.*开头的类仍然由 Bootstrap 加载,防止篡改核心 APIjavax.servlet.*等 Servlet API 类由Common ClassLoader加载,避免不同 Webapp 各自加载导致类型不兼容(类型不兼容的表现:A 应用传一个HttpServletRequest给 B 应用的代码,但两者由不同的 ClassLoader 加载,JVM 判定为不同类型,抛出ClassCastException)
实战:ClassLoader 泄漏排查
ClassLoader 泄漏是 Metaspace OOM 的常见元凶,尤其在 Spring Boot 热部署、Tomcat 重部署、动态代理生成大量代理类的场景。核心问题:旧 ClassLoader 被某个对象持有引用,导致该 ClassLoader 和它加载的所有类都无法被 GC。
一次真实的 Metaspace OOM 排查过程:
某 Spring Boot 应用使用 DevTools 热部署,每天部署 10 次,一周后触发 OOM,应用被 Kill。
排查步骤:
# 第 1 步:查看类加载器数量和加载的类数
jmap -clstats <pid> | head -30
# 典型输出:
# ClassLoader Classes Bytes Parent Alive? Type
# 0x00000007c06a3a80 1523 8.5M 0x... dead RestartClassLoader
# 0x00000007c086a090 1498 8.2M 0x... dead RestartClassLoader
# 0x00000007c0a2e6a0 1467 8.0M 0x... dead RestartClassLoader
# ... 共 50+ 个 dead 的 RestartClassLoader每个 dead 的 ClassLoader 占用 8MB 左右,50 个就是 400MB,Metaspace 默认大小只有 256MB(JDK 8 默认),直接被撑爆。
第 2 步:用 MAT 或 jhat 分析 heap dump,找出谁持有旧 ClassLoader 的引用。
# 生成 heap dump
jmap -dump:live,format=b,file=heap.hprof <pid>
# 用 MAT 打开后,执行 OQL 查询所有 ClassLoader 实例
# SELECT * FROM INSTANCEOF java.lang.ClassLoader常见的泄漏持有者:
| 持有者类型 | 典型场景 | 修复方法 |
|---|---|---|
| ThreadLocal | 在应用启动时 set 了一个 ThreadLocal,应用关闭时没 remove | 在 @PreDestroy 或监听器中调用 ThreadLocal.remove() |
| Log4j/Logback Logger | Logger 持有 ClassLoader 引用,应用关闭时 Logger 未销毁 | 使用 SLF4J 的 LoggerContext.stop() 清理 |
| 后台线程(ScheduledExecutorService) | 定时任务线程持有了启动时的 ClassLoader | 应用关闭时 shutdown() 线程池 |
| 已注册的 Listener | 未取消注册的 ServletContextListener、HttpSessionListener | 在 contextDestroyed() 中清理 |
| JDBC Driver | DriverManager 持有已注册的驱动引用 | 在 contextDestroyed() 中 DriverManager.deregisterDriver() |
预防措施(最佳实践清单):
# 启动参数加监控
-XX:+TraceClassLoading
-XX:+TraceClassUnloading
-XX:MetaspaceSize=256m # 设置初始大小,避免频繁 GC
-XX:MaxMetaspaceSize=512m # 设置上限,防止无限制增长对于热部署框架,Spring Boot DevTools 使用 RestartClassLoader 机制,每次重启创建新 ClassLoader 替代旧的。但需要确保所有持有 ClassLoader 引用的对象在应用关闭时被清理:
@Component
public class ClassLoaderCleanupListener {
@PreDestroy
public void cleanup() {
// 1. 清理 ThreadLocal
// 2. 取消注册所有 JDBC Driver
Enumeration<Driver> drivers = DriverManager.getDrivers();
while (drivers.hasMoreElements()) {
Driver driver = drivers.nextElement();
DriverManager.deregisterDriver(driver);
}
// 3. 关闭线程池
// 4. 清理 Logger 上下文
}
}总结
四种打破双亲委派的场景对比
| 场景 | 打破方式 | 核心原因 | 技术实现 | 注意事项 |
|---|---|---|---|---|
| JDBC SPI | 逆向:父 loader 通过 TCCL 请求子 loader | 核心 API 在 Bootstrap,实现类在 classpath | ServiceLoader.load() + Thread.currentThread().getContextClassLoader() | TCCL 设错会导致驱动加载失败,多线程场景需显式传递 |
| Tomcat 多应用 | 正向:Webapp loader 先自己加载 | 不同版本 jar 隔离 | WebappClassLoaderBase 重写 loadClass(),先己后人 | java.* 仍由 Bootstrap 加载,javax.servlet.* 由 Common 加载 |
| OSGi 模块化 | 完全自定义:网络化类加载器 | 模块间版本隔离 + 热更新 | 每个 bundle 一个 ClassLoader,通过 Import-Package 声明依赖 | 复杂度高,大型项目才用,调试困难 |
| 热部署(Spring Boot DevTools) | 创建新 ClassLoader 替换旧的 | 加载修改后的类 | RestartClassLoader 每次重启创建新实例 | 注意旧 ClassLoader 泄漏,详见上方实战排查 |
面试话术
"我会先确认面试官问的是哪个层面的打破。如果是 SPI,我会讲 TCCL 的机制和 ServiceLoader 的源码;如果是 Tomcat 隔离,我会讲 WebappClassLoader 先加载的源码设计;如果是 OSGi,我会讲模块化类加载器网络的版本策略。日常开发中,我最常遇到的是 Metaspace OOM,排查时先用 jmap -clstats 看 ClassLoader 数,再用 MAT 追 GC Root 路径,重点关注 ThreadLocal、JDBC Driver 注册、后台线程这几个常见泄漏点。"
灵魂拷问
- 为什么
Class.forName("com.mysql.cj.jdbc.Driver")在 JDBC 4.0 之后不需要手动写了?—— SPI 自动发现机制 - 两个 ClassLoader 加载了同一个类的两个副本,它们之间能互相转换吗?—— 不能,JVM 认为它们是不同的类型,
instanceof返回 false - 为什么
java.lang.String不能被自定义 ClassLoader 加载?—— JVM 安全规范,loadClass()内部对java.*有特殊判断 - 如果 Tomcat 的
Common ClassLoader和Webapp ClassLoader都加载了同一个类,谁优先?—— 取决于类名,如果是java.*或javax.servlet.*,Common 优先;否则 Webapp 优先
参考:JDK 源码
java.lang.ClassLoader、java.util.ServiceLoader;Tomcat 源码org.apache.catalina.loader.WebappClassLoaderBase;《深入理解 Java 虚拟机》第 7 章