Skip to content

02.4 - Tomcat 与 Spring 类加载分析

定位: 双亲委派模型在 Web 容器和微服务框架中的实际应用与改造 面试高频度: ⭐⭐⭐⭐ 考查方式: Tomcat 为什么打破双亲委派、Spring Boot fat jar 如何加载嵌套 jar 中的类

一、为什么需要特殊的类加载机制?

Tomcat 的核心矛盾

Tomcat 是一个 Web 容器——它同时运行多个 Web 应用,这些应用需要:

Tomcat 容器
┌─────────────────────────────────────────────────────────────────────┐
│                                                                     │
│  ┌─ Web 应用 A ──────────────────┐  ┌─ Web 应用 B ───────────────┐ │
│  │  com.example.web.controller   │  │  com.example.web.controller  │ │
│  │  spring-core-4.x.jar          │  │  spring-core-5.x.jar         │ │
│  │  logback-classic-1.2.jar      │  │  log4j-2.x.jar              │ │
│  │  servlet-api.jar (共享)        │  │  servlet-api.jar (共享)      │ │
│  └────────────────────────────────┘  └─────────────────────────────┘ │
│                                                                     │
│  ┌─ Tomcat 自身 ──────────────────────────────────────────────────┐│
│  │  catalina.jar, tomcat-api.jar, 各种容器内部类                   ││
│  └────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────┘

矛盾点:

  1. 隔离需求: 应用 A 用 Spring 4,应用 B 用 Spring 5——同名类(如 org.springframework.context.ApplicationContext)不能冲突。标准双亲委派下,先到先得,后加载的应用会得到同一个 Class——无法支持版本隔离
  2. 共享需求: Servlet API(javax.servlet.* / jakarta.servlet.*)所有应用共享——不需要每个应用自己带一份。Tomcat 自己的类库(Catalina)也需要与共享类隔离
  3. 覆盖需求: 应用应该能用自己的版本覆盖容器提供的库(比如用自己的日志框架)

标准双亲委派模型无法满足这些需求,因为它的委派方向是固定的(子→父),没有"先检查自己"的选项。

二、原理拆解

2.1 Tomcat 的多级 ClassLoader 层次

┌─ Bootstrap ClassLoader ──────────────────────────────────────────┐
│  JRE 核心类: rt.jar (java.lang.*, java.util.* 等)                │
└────────────────────────┬─────────────────────────────────────────┘

┌─ System ClassLoader ───┴─────────────────────────────────────────┐
│  Tomcat 启动类: $CATALINA_HOME/bin/bootstrap.jar, tomcat-juli.jar│
│  注意: 这不是 AppClassLoader,是 Tomcat 自己创建的一个 URLCL │
└────────────────────────┬─────────────────────────────────────────┘

┌─ Common ClassLoader ───┴─────────────────────────────────────────┐
│  所有 Tomcat 实例和所有 Web 应用共享                             │
│  $CATALINA_HOME/lib/*.jar (Servlet API, Tomcat 自身 API 等)      │
└────────┬──────────────────────────────────────┬──────────────────┘
         │                                      │
         ▼                                      ▼
┌─ Catalina ClassLoader ─┐           ┌─ Shared ClassLoader ─────┐
│  Tomcat 内部类          │           │  所有 Web 应用共享        │
│  加载器                 │           │  通常指向 Common 同一范围 │
└─────────────────────────┘           └────────┬─────────────────┘

                                  ┌────────────┴──────────────┐
                                  ▼                           ▼
                        ┌─ Webapp1 CL ──┐           ┌─ Webapp2 CL ──┐
                        │  隔离: A 的类  │           │  隔离: B 的类  │
                        │  WEB-INF/     │           │  WEB-INF/     │
                        │  classes/     │           │  classes/     │
                        │  WEB-INF/lib/*│           │  WEB-INF/lib/*│
                        └───────────────┘           └───────────────┘

2.2 WebappClassLoader 为什么打破双亲委派?

标准模型:先委派父,自己最后。 Tomcat WebappClassLoader:先尝试自己加载,找不到才委派给父

标准委派顺序:                           Tomcat WebappClassLoader 顺序:
                                       (WebappClassLoader 重写了 loadClass)
自己 → 父(Shared) → 祖父(Common) ...

父(Bootstrap) → Ext → App ...
                                      ① 检查已加载

                                      ② 自己加载 (WEB-INF/classes, WEB-INF/lib/*)

                                      ③ 委派父 (Shared → Common → System → Bootstrap)

                                      ④ 仍找不到 → ClassNotFoundException

为什么这样设计?

考虑一个场景:应用 A 在自己的 WEB-INF/lib/ 中放了一个 spring-core-4.3.jar。如果按双亲委派,这个请求会先被委派到 Shared ClassLoader,Shared 可能已经在 Common 或系统 classpath 上找到了一个 spring-core-5.x.jar——结果应用 A 被迫使用 Spring 5,即使它的代码是为 Spring 4 编写的。

WebappClassLoader 先看自己的 WEB-INF/classesWEB-INF/lib/*优先使用应用自带的版本。这是合理的:Web 应用的开发者应该能决定自己的依赖版本。

2.3 Spring Boot fat jar 的类加载机制

Spring Boot 将应用打包成一个可执行的 fat jar:

my-app.jar
├── META-INF/
│   └── MANIFEST.MF   (指定 Main-Class: org.springframework.boot.loader.JarLauncher)
├── org/springframework/boot/loader/
│   ├── JarLauncher.class
│   └── LaunchedURLClassLoader.class  ← 自定义 ClassLoader
└── BOOT-INF/
    ├── classes/                      ← 应用自己的类
    └── lib/                          ← 所有依赖 jar
        ├── spring-core-5.x.jar
        ├── spring-web-5.x.jar
        └── ...

普通 java -jar 为什么不行?

java -jar 的默认行为:JAR 中的 Main-Class 被启动,但 BOOT-INF/lib/ 下的嵌套 jar 不在 classpath 上——AppClassLoader 找不到它们。如果 JarLauncher 尝试加载 org.springframework.boot 之外的类,就会抛 ClassNotFoundException

LaunchedURLClassLoader 的巧妙设计

java -jar my-app.jar


JarLauncher.main() 启动

    ├─ ① 解析 my-app.jar 的目录结构
    │   发现 BOOT-INF/lib/ 下有 N 个 jar

    ├─ ② 为每个嵌套 jar 创建 JarURLConnection
    │   不展开 jar,直接在 jar 内部读取另一个 jar 的条目
    │   协议: jar:file:my-app.jar!/BOOT-INF/lib/spring-core.jar!/

    ├─ ③ 创建 LaunchedURLClassLoader
    │   参数: 所有嵌套 jar 的 URL 数组
    │   parent: 系统 AppClassLoader

    └─ ④ LaunchedURLClassLoader.loadClass("com.example.MyApp")


       在 BOOT-INF/classes/ 找到 → 加载


       需要 org.springframework.context.ApplicationContext


       在 BOOT-INF/lib/spring-core.jar! 中找到 → 加载

关键设计点

  • 不需要解压: Spring Boot 扩展了 URLStreamHandler,支持 jar:jar:...!/ 这种两层嵌套的 jar 协议,可以直接在一个 JAR 内部读取另一个嵌入的 JAR 包
  • ClassLoader 隔离: LaunchedURLClassLoader 是一个普通的 URLClassLoader 变体,但它只搜索自己注册的嵌套 jar URL,不破坏双亲委派(parent 仍是 AppClassLoader)
  • 启动入口分离: JarLauncher 不是业务类,它只负责创建合适的 ClassLoader,然后调用真正的 main 类(通过 MANIFEST.MF 中的 Start-Class 属性指定)

三、图解全景

┌─────────────────────────────────────────────────────────────────────────┐
│               Tomcat 类加载器全貌 + 委派方向                              │
│                                                                         │
│                        ┌──────────────────────────────┐                  │
│                        │   Bootstrap ClassLoader       │                  │
│                        │   (JRE 核心: rt.jar)          │                  │
│                        └──────────┬───────────────────┘                  │
│                                   │ ↑ 委派                               │
│                        ┌──────────┴───────────────────┐                  │
│                        │   System ClassLoader          │                  │
│                        │   (Tomcat 启动类)             │                  │
│                        └──────────┬───────────────────┘                  │
│                                   │ ↑ 委派                               │
│                        ┌──────────┴───────────────────┐                  │
│                        │   Common ClassLoader          │                  │
│                        │   $CATALINA_HOME/lib/*.jar    │                  │
│                        └──────┬─────────────┬────────┘                  │
│                               │              │                           │
│                ┌──────────────┴──┐    ┌──────┴───────────────┐           │
│                │ Catalina CL     │    │  Shared ClassLoader   │           │
│                │ (Tomcat 内部)    │    │  (所有 app 共享)      │           │
│                └─────────────────┘    └──────┬───────────────┘           │
│                                              │                           │
│                                 ┌────────────┴────────────┐              │
│                                 ▼                         ▼              │
│                     ┌───────────────────┐   ┌─────────────────────┐      │
│                     │ Webapp1 CL        │   │ Webapp2 CL          │      │
│                     │ 打破双亲委派:      │   │ 打破双亲委派:        │      │
│                     │                   │   │                    │      │
│                     │ ① 自己(BOOT-INF)  │   │ ① 自己(BOOT-INF)   │      │
│                     │ ② 父(Shared)     │   │ ② 父(Shared)       │      │
│                     │ ③ ClassNotFound  │   │ ③ ClassNotFound   │      │
│                     └───────────────────┘   └─────────────────────┘      │
│                                                                         │
│  ───→ 委派方向 (标准)     ╌╌╌→ 委派方向 (Webapp 打破)                    │
└─────────────────────────────────────────────────────────────────────────┘

四、实战验证

4.1 两个 Web 应用同名类不冲突

假设:

  • 应用 A: WEB-INF/classes/com/example/UserService.class 版本 1.0
  • 应用 B: WEB-INF/classes/com/example/UserService.class 版本 2.0
java
// 在 Tomcat 中模拟(伪代码)
// Webapp1 的 ClassLoader
ClassLoader w1CL = new WebappClassLoader(context1);
// Webapp2 的 ClassLoader
ClassLoader w2CL = new WebappClassLoader(context2);

// 两个应用各自加载自己的 UserService
Class<?> userSvc1 = w1CL.loadClass("com.example.UserService");
Class<?> userSvc2 = w2CL.loadClass("com.example.UserService");

// 即使在不同的线程中同时调用,也不冲突
System.out.println(userSvc1 == userSvc2); // → false
// 方法: 因为 WebappClassLoader 先在自己的 WEB-INF 中找到了各自的版本

4.2 Spring Boot 嵌套 jar 的启动原理

java
// 模拟 LaunchedURLClassLoader 的核心逻辑
// LaunchedURLClassLoader 内部会:
// 1. 扫描 BOOT-INF/lib/ 下所有 jar
// 2. 为每个 jar 创建"jar 内 jar"的 URL
// 3. 在 findClass 中按 URL 顺序查找

public class SimulatedLaunchedURLClassLoader extends URLClassLoader {

    public SimulatedLaunchedURLClassLoader(URL[] urls, ClassLoader parent) {
        super(urls, parent);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 标准 URLClassLoader.findClass 已经实现了
        // 遍历 urls[] 数组,用每个 URL 尝试加载
        // Spring Boot 的 URL 是: jar:file:my-app.jar!/BOOT-INF/lib/xxx.jar!/
        // URLStreamHandler 解析这种两层嵌套的 jar 协议
        return super.findClass(name);
    }
}

五、面试视角

追问答案要点
Tomcat 为什么需要自己的类加载机制?同时运行多个 Web 应用需要隔离(同名类不冲突)和共享(Servlet API 共用)。标准双亲委派只能单向委派,不能满足"先自己加载再委派"的需求
WebappClassLoader 如何打破双亲委派?重写 loadClass 方法,改变委派顺序:先检查已加载 → 自己尝试加载 → 委派父加载器。与标准模型的"先委派父→自己最后"正好反向
Tomcat 有哪几级 ClassLoader?Bootstrap → System → Common → (Catalina / Shared) → Webapp。Webapp 是最底层,每个 Web 应用一个独立的加载器
Spring Boot fat jar 中的类如何加载?LaunchedURLClassLoader 处理 jar 内嵌套 jar 的 URL(两层 jar 协议)。不需要解压,直接通过扩展的 URLStreamHandler 读取嵌套 jar 条目
java -jar my-app.jar 时谁先被执行?MANIFEST.MF 中的 Main-Class 指定 JarLauncher。JarLauncher 创建 LaunchedURLClassLoader 后,再通过反射调用 Start-Class 指定的应用 main 方法
为什么不把嵌套 jar 解压到临时目录再加载?解压会增加启动时间和磁盘占用。fat jar 的设计初衷就是一个 jar 包即整个应用,解压破坏了"单文件部署"的体验
两个 Web 应用类名完全一样会冲突吗?不会。每个 Web 应用有独立的 WebappClassLoader,加载的类由 (ClassLoader, 全限定名) 唯一标识。不同 WebappClassLoader 加载的同名类被视为不同类型

📚 相关链接

  • **双亲委派模型与打破** — 双亲委派的基础知识和打破动机
  • **自定义类加载器实战** — WebappClassLoader 是自定义 ClassLoader 的工程实践
  • **OSGI与SPI模块化** — OSGI 的模块化加载与 Tomcat 异同
  • ← 返回 **类加载索引**

Knowledge4J — Java 知识库