Skip to content

Tomcat 类加载器为什么打破双亲委派

问题

Tomcat 的类加载器是如何设计的?为什么它要打破双亲委派模型?面试官问你这个问题,不是考察你背不背得出类加载器层级图,而是想看你是否理解"隔离性"和"兼容性"在 Java 中间件层面的真实矛盾。

从双亲委派说起

先回顾一下 Java 默认的双亲委派模型。当一个类加载器收到加载请求时,它不会自己先加载,而是先委派给父加载器,父加载器再委派给祖父,一直递到 Bootstrap ClassLoader。只有所有父加载器都找不到时,当前加载器才自己加载。

java
// 双亲委派的核心逻辑(简化版)
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.Stringjava.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 才有的方法(比如 @RequestMappingproduces 属性在 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 生效。

java
// 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 应用可见用途
Bootstraprt.jar / java.*JDK 核心类
Platformjre/lib/ext/*JDK 扩展类
ApplicationCLASSPATH启动脚本里的 jar
Common$CATALINA_HOME/lib/*.jar所有 Web 应用共享的类(如 JDBC 驱动、日志框架)
CatalinaTomcat 内部类服务器自身实现(对 Web 应用不可见,防止意外依赖)
Shared需手动配置可选的共享库层
WebappWEB-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 文件对应一个独立的类加载器:

java
// 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

java
<%-- 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。

修复方案

  1. JSP 中避免使用 static 成员变量引用外部资源
  2. 配置 checkInterval 参数,减少 JSP 检查频率(默认 4 秒一次)
  3. 生产环境关闭 JSP 热加载(development=false),改为重启后更新
bash
# 排查 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_stats

delegate 属性:切换隔离模式

Tomcat 的 <Loader delegate="true"/> 属性可以切换回先委派模式:

xml
<!-- 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),遵循双亲委派,不打破。它通过自定义的 JarFileJarURLConnection 支持从嵌套 jar(BOOT-INF/lib/ 下的 jar)中加载类。

为什么 Spring Boot 不需要打破?因为 Spring Boot 通常一个 JAR 跑一个应用,不存在多个应用隔离的问题。它只需要解决如何从嵌套 JAR 中读取类这个问题,而不是类加载顺序问题。

java
// 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 应用隔离"。按这个结构:

  1. 一句话定论:为了实现 ClassLoader 隔离,让不同 Web 应用能加载各自版本的类库
  2. 怎么打破的:WebappClassLoader 重写 loadClass,先 findClassgetParent().loadClass
  3. 边界在哪里java.* 不走这个逻辑,仍然由 Bootstrap 加载
  4. 给个例子:AppA 用 Spring 4.3、AppB 用 Spring 5.3,不打破就冲突
  5. 对比:Spring Boot 为什么不打破(单应用不需要隔离)
  6. 加分项:提一下 JSP 热部署的 ClassLoader 泄漏坑,说明你踩过
  7. 再加分:提一下 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.servletjakarta.servlet,类加载器架构不变但应用需要迁移

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。