Tomcat 类加载机制详解
概述
类加载机制是 Tomcat 多应用隔离的基石。Java 默认的双亲委派模型要求类加载自顶向下委派,而 Tomcat 的 WebappClassLoader 却"打破"了这一规则——在隔离性与复用性之间找到了自己的平衡。本文从 JVM 双亲委派讲起,拆解 Tomcat 类加载器的完整层次与加载流程,最后落到类冲突和内存泄漏这两个生产高频问题上。
一、JVM 双亲委派模型回顾
Java 默认类加载器层次:
Bootstrap ClassLoader ← 加载 JDK 核心类(java.base 等)
└── Platform ClassLoader ← 加载 JDK 扩展类(java.* 之外的 JDK 模块)
└── Application ClassLoader ← 加载 classpath 上的应用类双亲委派规则:先委派给父加载器,父加载不了自己才加载。
// java.lang.ClassLoader.loadClass() 的核心逻辑
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 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) {
// 3. 父加载器加载失败,自己加载
c = findClass(name);
}
}
return c;
}
}双亲委派的价值:保证核心类唯一(同一个 String 类不会被重复加载),避免核心 API 被篡改。
二、Tomcat 类加载器层次
Tomcat 在 JVM 三个内置加载器之上,又叠加了四层:
Bootstrap ClassLoader
│
┌────────────┼─────────────┐
│ ▼ │
│ System ClassLoader │ ← catalina.sh 设置的 classpath
│ │ │
│ ▼ │
│ Common ClassLoader │ ← conf/catalina.properties 的 common.loader
│ │ │ │
│ ▼ ▼ │
│ Catalina CL Shared CL │ ← catalina.loader / shared.loader(默认空)
│ │ │
│ ▼ │
│ Webapp CL ① │ ← 应用 A 的类
│ Webapp CL ② │ ← 应用 B 的类
│ Webapp CL ③ │ ← 应用 C 的类
└──────────────────────────┘各层加载器加载的路径由 conf/catalina.properties 控制:
common.loader="${catalina.base}/lib","${catalina.base}/lib/*.jar","${catalina.home}/lib","${catalina.home}/lib/*.jar"
catalina.loader="${catalina.home}/server","${catalina.home}/server/*.jar"
shared.loader="${catalina.base}/shared","${catalina.base}/shared/*.jar"| 加载器 | 加载内容 | 可见范围 |
|---|---|---|
| Common | lib/*.jar、bin/bootstrap.jar | 所有 Web 应用 + 容器 |
| Catalina | 容器自身代码(默认从 Common 加载) | 仅容器 |
| Shared | 所有应用共享的库(默认空) | 所有 Web 应用 |
| Webapp | WEB-INF/classes + WEB-INF/lib/*.jar | 单个应用 |
关键点:每个 Web 应用拥有独立的 Webapp 类加载器实例。应用 A 与应用 B 即使部署了同名同版本的类,也不会互相影响。
三、打破双亲委派:WebappClassLoader
3.1 为什么必须打破
如果完全遵守双亲委派,应用 A 的 WEB-INF/lib/spring.jar 会先被委派给父加载器。但父加载器的路径里没有 spring.jar,最终还是会由 Webapp 加载器加载——表面上没区别。
真正的矛盾在反向场景:父加载器加载了某个类,但应用想用自己的版本。更关键的是同名的接口/类分裂问题:容器(父)用 Tomcat 10 的 jakarta.servlet-api,应用(子)若自带 javax.servlet-api,两套 API 并存会让类型无法互通。
Tomcat 的解法是:优先加载应用自己的类,找不到再委托父加载器。
3.2 加载流程
// org.apache.catalina.loader.WebappClassLoaderBase.loadClass() 的查找顺序
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 阶段一:先查 JVM 已加载缓存(本地 + 父类)
Class<?> clazz = findLoadedClass0(name); // 本加载器缓存
if (clazz == null) clazz = findLoadedClass(name); // 全局 JVM 缓存
if (clazz == null) {
// 阶段二:安全包列表直接交给父加载器
//(javax.servlet 的包会被引导,防止重复加载)
...
}
// 阶段三:本地优先加载(打破双亲委派的点)
try {
clazz = findClass(name); // 先加载 WEB-INF/classes 与 WEB-INF/lib
} catch (ClassNotFoundException e) {
// 阶段四:本地找不到,才委派父加载器
clazz = super.loadClass(name, resolve);
}
return clazz;
}三个阶段可以概括为:缓存 → 本地 → 父类。
3.3 例外:安全包列表
完全本地优先会引入新的问题——如果应用自带 javax.servlet-api,会与容器加载的同名 API 产生两份类。因此 Tomcat 通过 catalina.properties 的 package.definition / package.access 列表,把这些关键包强制交给父加载器:
package.definition=\
javax.,java.,org.apache.catalina.,org.apache.coyote.,\
org.apache.tomcat.,org.apache.jasper.,org.apache.naming.规则:默认父优先,但 WEB-INF/classes 与 WEB-INF/lib 内的类例外(Webapp 优先);javax.*、java.* 等系统包强制父加载。
四、WebappClassLoader 与热部署、热重载
4.1 热部署(deploy)与热重载(reload)的区别
| 概念 | 触发 | 机制 |
|---|---|---|
| 热部署 | WAR 放入 appBase / 删除 | 创建新 Context + 新 WebappClassLoader |
| 热重载 | reloadable="true" 时 classes 变化 | 销毁 Context,重建 WebappClassLoader |
两者共同的实现基础是:卸载旧 Context 时,丢弃其 WebappClassLoader 引用,使旧类可被 GC。
4.2 卸载失败的经典场景
热重载后内存暴涨,往往不是 Tomcat 的问题,而是旧 ClassLoader 被引用"钉死":
旧 WebappClassLoader ──被──▶ 静态字段持有旧类实例
▲ (如 ThreadLocal、static List)
└── 无法回收,类元数据 + 常量池持续驻留触发原因多为:
- 线程池中线程的
ThreadLocal持有应用对象 - 静态集合保存了应用创建的连接/监听器
- 第三方库(如某些日志框架)在 JVM 级缓存了类引用
排查手段:jmap -dump 后分析 dump,搜索 org.apache.catalina.loader.WebappClassLoader 的存活实例。
五、类冲突:NoClassDefFoundError 与 NoSuchMethodError
5.1 常见冲突场景
| 场景 | 现象 | 原因 |
|---|---|---|
| 应用 lib 与容器 lib 同名 | NoSuchMethodError / ClassCastException | 两份不同版本 Jar 被不同加载器加载 |
| 应用内多个 Jar 版本混杂 | NoClassDefFoundError | 依赖树中存在版本冲突,类缺失 |
| 老 API 迁移 | ClassNotFoundException: javax.servlet.* | Tomcat 10 起换为 jakarta 包名 |
5.2 定位步骤
- 确认类被哪个加载器加载:启动参数加
-verbose:class或在catalina.bat中开启类加载日志。 - 检查 Jar 归属:
# 找出包含目标类的所有 Jar(在 lib 与应用 lib 中)
for f in lib/*.jar WEB-INF/lib/*.jar; do
unzip -l "$f" 2>/dev/null | grep -q "com/example/Foo.class" && echo "$f"
done- 明确加载优先级:Webapp 优先 → 父加载器共享 → 冲突时决定是删应用 lib 还是换版本。
- 验证最终类路径:用
jmap -clstats <pid>查看各 ClassLoader 的加载统计。
5.3 预防
- 应用
WEB-INF/lib精简,只放应用独有依赖 - 通用库统一放入
$CATALINA_HOME/lib(Shared 层),保持版本一致 - 使用 Maven 依赖树检查冲突:
mvn dependency:tree - Tomcat 10 迁移时统一
javax→jakarta命名空间,勿混用
六、类加载顺序对 JDBC/SPI 的影响
JDBC 驱动等通过 ServiceLoader(SPI)加载,SPI 默认使用 Thread.currentThread().getContextClassLoader()。Tomcat 在请求线程中会设置上下文类加载器为当前 WebappClassLoader,因此:
- 数据源若由容器(JNDI)创建,驱动类需对容器可见(放
$CATALINA_HOME/lib或配置common.loader) - 应用内创建连接时,驱动放
WEB-INF/lib即可
经典坑:驱动放错位置导致 ClassNotFoundException: com.mysql.cj.jdbc.Driver——数据源定义在 META-INF/context.xml 但驱动只存在于某个应用的 WEB-INF/lib,而该数据源又被其他应用引用。
七、JVM 类加载与 Tomcat 的联动配置
| 场景 | 配置 |
|---|---|
| 查看类加载日志 | -verbose:class |
| 修改默认共享库位置 | 编辑 catalina.properties 的 shared.loader |
| 防内存泄漏监听 | 默认启用 JreMemoryLeakPreventionListener |
| 线程泄漏预防 | 默认启用 ThreadLocalLeakPreventionListener |
ThreadLocalLeakPreventionListener 会在 Context 停止时向工作线程池注册"停止后清理",扫描仍持有旧 ClassLoader 引用的情况,是生产环境缓解热重载泄漏的第一道防线。
八、对比:与其他容器/框架的类加载
| 方案 | 策略 |
|---|---|
| Tomcat | Webapp 优先,系统包父加载 |
| 传统双亲委派 | 严格父优先 |
| Spring Boot Fat Jar | 扁平 classpath,无层级隔离 |
| OSGi | 每个 Bundle 独立类空间,声明式依赖 |
Spring Boot 内嵌 Tomcat 时使用 Fat Jar 的扁平 classpath(LaunchedURLClassLoader),不再有 Webapp 隔离——因此内嵌模式天然避免了类加载器层次带来的复杂度,代价是失去多应用隔离能力。
参考链接: