Tomcat 类加载器为什么打破双亲委派
问题
Tomcat 的类加载器是如何设计的?为什么它要打破双亲委派模型?面试官问你这个问题,不是考察你背不背得出类加载器层级图,而是想看你是否理解"隔离性"和"兼容性"在 Java 中间件层面的真实矛盾。
从双亲委派说起
先回顾一下 Java 默认的双亲委派模型。当一个类加载器收到加载请求时,它不会自己先加载,而是先委派给父加载器,父加载器再委派给祖父,一直递到 Bootstrap ClassLoader。只有所有父加载器都找不到时,当前加载器才自己加载。
// 双亲委派的核心逻辑(简化版)
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 1. 先检查是否已加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 2. 委派给父加载器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器没找到,才自己加载
}
if (c == null) {
c = findClass(name);
}
}
return c;
}这个设计的优点是稳定:核心类库(java.lang.String、java.util.HashMap 等)始终由 Bootstrap ClassLoader 加载,保证所有应用使用同一份,不会出现类版本冲突。
但 Tomcat 说:我不这么干。
Tomcat 的类加载器架构
Tomcat 的类加载体系比 JDK 默认的复杂得多,包含 5 层:
Bootstrap ClassLoader (JVM 原生, 加载 rt.jar / java.*)
│
└─ Platform ClassLoader (JDK9+, 原名 Extension ClassLoader)
│
└─ Application ClassLoader (加载 CLASSPATH 上的类)
│
└─ Common ClassLoader ← 加载 Tomcat 自身 + 共享类
│ (${catalina.home}/lib/*.jar)
├─ Catalina ClassLoader ← 加载 Tomcat 服务器内部类
│ (对 Web 应用不可见)
└─ Shared ClassLoader ← 加载所有 Web 应用共享的类
│ (默认不存在,需配置)
└─ Webapp ClassLoader 1 ← 加载 WEB-INF/classes 和 WEB-INF/lib
└─ Webapp ClassLoader 2
└─ Webapp ClassLoader N关键区别: Webapp ClassLoader 打破了双亲委派——它先自己尝试加载,加载不到才委派给父加载器。
加载时序(以 AppA 加载 com.example.service.UserService 为例)
时间线:
1. WebappClassLoaderA.loadClass("com.example.service.UserService")
2. ├── 检查本地缓存 (已加载的类集合) → 未命中
3. ├── 检查系统类加载器缓存 → 未命中
4. ├── 检查类名是否 java.* 开头 → 否
5. ├── ★ 调用 findClass() 扫描 WEB-INF/classes 和 WEB-INF/lib/*.jar
6. │ └── 在 WEB-INF/lib/app-a.jar 中找到 → 加载并返回
7. └── 如果 findClass 没找到 → 才委派给 Shared/Parent对比双亲委派模式(假设 delegate=true)下的时序:
1. WebappClassLoaderA.loadClass("com.example.service.UserService")
2. ├── 检查本地缓存 → 未命中
3. ├── 委派给 Shared ClassLoader
4. │ ├── Shared 委派给 Common → Application → Platform → Bootstrap
5. │ └── 所有父加载器都找不到 → 返回 null
6. └── findClass() 自己加载两种模式的区别就在第 3 步和第 5 步的顺序对调了。这个顺序对调,决定了 Web 应用能不能用自己的 jar 覆盖 Tomcat 的共享库。
为什么必须打破
直接说答案:Web 应用隔离。
真实场景:Spring 版本冲突
一台 Tomcat 部署了两个 Web 应用:AppA 依赖 Spring 4.3.18,AppB 依赖 Spring 5.3.20。两个版本的 Spring 可以共存吗?
如果遵守双亲委派,AppA 先启动,WebappClassLoaderA 加载 Spring 4.3 的类。这些类进入 Shared ClassLoader 或更上层的缓存。AppB 启动时,WebappClassLoaderB 问父加载器有没有 org.springframework.context.ApplicationContext,父说"有,给你",于是 AppB 拿到了 Spring 4.3 的 ApplicationContext——但 AppB 的代码里引用了 Spring 5.3 才有的方法(比如 @RequestMapping 的 produces 属性在 5.3 里改成了 MediaType 数组),运行时直接 NoSuchMethodError。
生产事故案例:某互联网金融公司,同一个 Tomcat 实例部署了 A 和 B 两个应用,A 使用 Spring Boot 1.5(Spring 4.3),B 使用 Spring Boot 2.1(Spring 5.1)。开发环境各自独立运行正常,上线后 B 应用在启动时抛 NoSuchMethodError: org.springframework.core.annotation.AnnotationUtils.getAnnotationAttributes。排查了一天后发现:A 先启动,把 Spring 4.3 的 AnnotationUtils 加载进了 Common ClassLoader,B 启动时直接拿了这个旧的类,但 Spring 5.1 的 AnnotationUtils 接口已经变了,导致 B 启动失败。修复方案:把两个应用拆到独立的 Tomcat 实例,或者使用 <Context> 隔离配置。
另一个常见场景:Servlet API 版本冲突
AppA 使用 Servlet 3.1 API(Tomcat 8),AppB 使用 Servlet 5.0 API(Tomcat 10 的 jakarta.servlet 包)。实际中不会一台 Tomcat 同时跑 Servlet 3.1 和 5.0,但打平了说:不同 Web 应用对同一接口的不同版本依赖,是 Web 容器的固有矛盾。
打破双亲委派后,每个 Webapp ClassLoader 独立加载自己 WEB-INF/lib 下的 jar,AppA 加载 Spring 4.3,AppB 加载 Spring 5.3,互不干扰。
边界安全:java.* 的例外
需要特别注意:java.* 开头的类仍然由 Bootstrap ClassLoader 加载。Webapp ClassLoader 在自行加载前会先检查类名是否以 java. 开头,如果是则跳过自我加载,直接让父加载器处理。这是为了防止用户代码篡改 JDK 核心 API。比如你不能在 WEB-INF/classes 下放一个 java.lang.String.class 来替换 JDK 的 String——ClassLoader 根本不会让你有自己的 java.lang.String 生效。
// Tomcat 的 WebappClassLoaderBase.loadClass() 简化逻辑
// 注意:这个检查在 findClass() 之前
if (name.startsWith("java.")) {
// 核心类必须由 Bootstrap 加载,不允许覆盖
synchronized (JreCompat.isGraalAvailable() ? this : getClassLoadingLock(name)) {
Class<?> clazz = findLoadedClass(name);
if (clazz != null) return clazz;
// 直接委派给父加载器,不走 findClass
return (parent != null) ? parent.loadClass(name, false) : null;
}
}Common / Catalina / Shared 三层设计是为了什么?
Tomcat 的类加载器不止 WebappClassLoader 这一层,上面的 Common、Catalina、Shared 三层也有明确分工:
| 类加载器 | 加载范围 | 对 Web 应用可见 | 用途 |
|---|---|---|---|
| Bootstrap | rt.jar / java.* | 是 | JDK 核心类 |
| Platform | jre/lib/ext/* | 是 | JDK 扩展类 |
| Application | CLASSPATH | 是 | 启动脚本里的 jar |
| Common | $CATALINA_HOME/lib/*.jar | 是 | 所有 Web 应用共享的类(如 JDBC 驱动、日志框架) |
| Catalina | Tomcat 内部类 | 否 | 服务器自身实现(对 Web 应用不可见,防止意外依赖) |
| Shared | 需手动配置 | 是 | 可选的共享库层 |
| Webapp | WEB-INF/classes + WEB-INF/lib | 仅本应用 | 应用自身的类和依赖 |
这个设计解决了一个实际问题:JDBC 驱动放哪里? 放在 Common ClassLoader 下,所有 Web 应用都能用同一份 JDBC 驱动,节省内存。但如果某个 Web 应用需要自己的 JDBC 驱动版本,放在 WEB-INF/lib 下就行——因为 Webapp ClassLoader 打破了双亲委派,会优先加载自己的版本。
一个细节:Catalina 对 Web 应用不可见
Catalina ClassLoader 加载 Tomcat 的内部实现类(如 org.apache.catalina.startup.Catalina),这些类对 Web 应用不可见。这样做的好处是:Web 应用不会意外依赖 Tomcat 的内部 API。如果你在 WEB-INF/classes 下写了一个 org.apache.catalina.UserConfig,Tomcat 启动时 Catalina ClassLoader 负责加载这个包,但 Web 应用侧看不到,不会出现混淆。
JSP 的热更新原理
Tomcat 的 JSP 类加载器是 Webapp ClassLoader 的子加载器,每个 JSP 文件对应一个独立的类加载器:
// JSP 编译与加载流程
// 1. 用户访问 /index.jsp
// 2. JspServlet 检查 index.jsp 的修改时间
// 3. 如果修改时间 > 上次编译时间,重新编译为 index_jsp.java → index_jsp.class
// 4. 创建一个新的 JspClassLoader 加载 index_jsp.class
// 5. 旧的 JspClassLoader 失去引用,等待 GC踩坑实录:JSP 热部署导致 Metaspace OOM
某次生产故障:Tomcat 7 部署了 3 个 Web 应用,每天有运营人员频繁修改 JSP 文件(一天改 30+ 次)。运行两个月后,Tomcat 频繁 OOM。查看 jmap -clstats 发现 ClassLoader 数量达到了 2000+。
根因:有个 JSP 页面用了一个全局的 static Logger:
<%-- index.jsp --%>
<%!
private static final Logger log = LoggerFactory.getLogger("index.jsp");
%>LoggerFactory.getLogger() 会在 Log4j2 的 LoggerContext 中持有对 index_jsp.class 的引用。每次 JSP 重新编译,新的 JspClassLoader 加载新的 index_jsp.class,但旧的 JspClassLoader 因为被 LoggerContext 引用着,无法 GC。Metaspace 里积压了 1000+ 个 JspClassLoader,每个带着它加载的所有类,最终 OOM。
修复方案:
- JSP 中避免使用
static成员变量引用外部资源 - 配置
checkInterval参数,减少 JSP 检查频率(默认 4 秒一次) - 生产环境关闭 JSP 热加载(
development=false),改为重启后更新
# 排查 ClassLoader 泄漏的命令
jmap -clstats <pid> | grep "ClassLoader" | wc -l
# 正常: 3 个 WebApp × 部署次数(1) + Catalina 自身 ≈ 10 以内
# 异常: 远超 10,说明有泄漏
# 进一步看哪个 ClassLoader 持有什么类
jmap -clstats <pid> | grep -A 5 "JspClassLoader"
# 用 jcmd 查 ClassLoader 数量(JDK 8+)
jcmd <pid> VM.classloader_statsdelegate 属性:切换隔离模式
Tomcat 的 <Loader delegate="true"/> 属性可以切换回先委派模式:
<!-- context.xml -->
<Context>
<Loader delegate="true"/>
</Context>delegate | 加载顺序 | 隔离性 | 适用场景 |
|---|---|---|---|
false(默认) | 先自己找,再委派父 | 强 | 多应用独立部署,依赖版本不同 |
true | 先委派父,再自己找 | 弱 | 多应用共享同一套类库,节省内存 |
delegate=true 时,Webapp ClassLoader 先问父加载器,父找不到自己才加载。适用于多个 Web 应用大量共享同一套类库的场景,但会牺牲隔离性。生产环境通常保持 delegate=false(默认值),除非有明确的共享需求。
注意:delegate 在 Tomcat 7 和 Tomcat 8+ 的行为略有差异。Tomcat 8 之后,delegate=true 时已经加载的类不会重新卸载,即使后续改回 false,已加载的类仍然在父加载器中。所以不要在运行时切换这个属性。
另一个生产案例:delegate 误配置
某电商公司线上排查:Tomcat 部署了两个 Web 应用,A 应用发现 org.slf4j.impl.StaticLoggerBinder 加载了 B 应用 WEB-INF/lib 下的版本。排查发现,A 应用的 context.xml 被人误配置了 delegate=true,导致 A 的 WebappClassLoader 先问父加载器,父加载器从 B 的共享区域找到了 B 的日志实现。症状:A 应用的日志格式莫名其妙变成了 B 的格式,日志级别配置也失效了。修复:删掉 delegate=true 配置,重启即可。
跟 Spring Boot 的对比
Spring Boot 可执行 JAR 的类加载机制跟 Tomcat 不同。Spring Boot 使用 LaunchedURLClassLoader(继承 URLClassLoader),遵循双亲委派,不打破。它通过自定义的 JarFile 和 JarURLConnection 支持从嵌套 jar(BOOT-INF/lib/ 下的 jar)中加载类。
为什么 Spring Boot 不需要打破?因为 Spring Boot 通常一个 JAR 跑一个应用,不存在多个应用隔离的问题。它只需要解决如何从嵌套 JAR 中读取类这个问题,而不是类加载顺序问题。
// Spring Boot 3.x 的 LaunchedURLClassLoader(简化)
// 不打破双亲委派,但通过 URL 协议处理嵌套 jar 的路径
public class LaunchedURLClassLoader extends URLClassLoader {
// 注意:这里没有重写 loadClass,所以走默认的父先加载逻辑
// 只通过 addURL 注册了 BOOT-INF/lib/ 和 BOOT-INF/classes/ 的 URL
public LaunchedURLClassLoader(URL[] urls, ClassLoader parent) {
super(urls, parent);
}
}Spring Boot 嵌入式 Tomcat 的情况
当 Spring Boot 使用嵌入式 Tomcat 时,情况更微妙:
Spring Boot LaunchedURLClassLoader
└── 不打破双亲委派
└── 但当它通过 SPI 启动嵌入式 Tomcat 时
└── Tomcat 内部仍然会创建自己的 Webapp ClassLoader
└── 这个 Webapp ClassLoader 仍然是打破双亲委派的所以即使 Spring Boot 自身不打破双亲委派,它内部嵌入的 Tomcat 在运行时仍然会为每个 Web 应用(嵌入式场景下通常只有一个)创建打破双亲委派的 Webapp ClassLoader。只不过嵌入式场景下只有一个应用,打破不打破无所谓,但 Tomcat 的代码没变,内部机制还是原来的。
Tomcat 10 的 jakarta 迁移影响
Tomcat 10 从 javax.servlet 迁移到 jakarta.servlet 包名。这个变化影响类加载器:
- 旧应用(基于
javax.servlet)在 Tomcat 10 上直接跑会报ClassNotFoundException: javax.servlet.http.HttpServlet - 因为 Catalina ClassLoader 加载的是
jakarta.servlet版本的 API - 需要迁移工具(如
jakartaee-migration)重写字节码,或者使用 Tomcat 9 兼容
这个迁移对面试者来说是个很好的加分点:Tomcat 10 的类加载器架构没有根本变化,但包的迁移导致所有 Web 应用必须重新编译。
面试回答思路
如果面试官问"Tomcat 为什么打破双亲委派",不要只答"为了实现 Web 应用隔离"。按这个结构:
- 一句话定论:为了实现 ClassLoader 隔离,让不同 Web 应用能加载各自版本的类库
- 怎么打破的:WebappClassLoader 重写
loadClass,先findClass再getParent().loadClass - 边界在哪里:
java.*不走这个逻辑,仍然由 Bootstrap 加载 - 给个例子:AppA 用 Spring 4.3、AppB 用 Spring 5.3,不打破就冲突
- 对比:Spring Boot 为什么不打破(单应用不需要隔离)
- 加分项:提一下 JSP 热部署的 ClassLoader 泄漏坑,说明你踩过
- 再加分:提一下
delegate属性在生产环境被误配置导致日志错乱的事故
总结
| 要点 | 说明 |
|---|---|
| 打破原因 | 实现 Web 应用间类隔离,允许不同应用使用同一 jar 的不同版本 |
| 打破方式 | Webapp ClassLoader 先自己加载,找不到才委派给父加载器 |
| 三层设计目的 | Common 共享基础设施、Catalina 保护内部实现、Webapp 隔离应用 |
| 安全边界 | java.* 核心类仍由 Bootstrap ClassLoader 加载,不允许篡改 |
| 常见陷阱 | JSP 热部署导致 ClassLoader 泄漏,引发 Metaspace OOM;delegate 误配置导致日志版本错乱 |
| 隔离模式切换 | delegate=true 可切回先委派模式,牺牲隔离换取共享 |
| Spring Boot 对比 | 遵循双亲委派,通过自定义 JarFile 解决嵌套 jar 读取问题;嵌入式 Tomcat 内部仍打破 |
| Tomcat 10 变化 | javax.servlet → jakarta.servlet,类加载器架构不变但应用需要迁移 |