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, 各种容器内部类 ││
│ └────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────┘矛盾点:
- 隔离需求: 应用 A 用 Spring 4,应用 B 用 Spring 5——同名类(如
org.springframework.context.ApplicationContext)不能冲突。标准双亲委派下,先到先得,后加载的应用会得到同一个 Class——无法支持版本隔离 - 共享需求: Servlet API(
javax.servlet.*/jakarta.servlet.*)所有应用共享——不需要每个应用自己带一份。Tomcat 自己的类库(Catalina)也需要与共享类隔离 - 覆盖需求: 应用应该能用自己的版本覆盖容器提供的库(比如用自己的日志框架)
标准双亲委派模型无法满足这些需求,因为它的委派方向是固定的(子→父),没有"先检查自己"的选项。
二、原理拆解
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/classes 和 WEB-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
// 在 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 的启动原理
// 模拟 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 异同
- ← 返回 **类加载索引**