Tomcat 的类加载机制是怎样的?
结论先行:Tomcat 在标准双亲委派之外,为 Web 应用类加载器做了定制:WebappClassLoader 加载应用自己的类时先自己找、再问父加载器(即「破坏双亲委派」),目的是让每个 webapp 的 WEB-INF/classes、WEB-INF/lib 相互隔离、优先于容器共享库。但 java.* 等核心类始终先交给 Bootstrap,避免被覆盖。
1. 默认的委派顺序
WebappClassLoader.loadClass(name)
① 已加载过?→ 直接返回(本地缓存)
② 是否 JVM 核心类(java./javax. 等)?→ 交给 Bootstrap/Ext,绝不自己加载
③ 本地仓库找:WEB-INF/classes → WEB-INF/lib/*.jar
找到 → 自己 defineClass
④ 本地找不到 → 委派父加载器(Shared → Common → System → Bootstrap)
⑤ 都没有 → 抛 ClassNotFoundException
对比标准双亲委派(先父后子),Tomcat 的顺序是 「先己后人」——这就是「破坏双亲委派」的含义。
2. 为什么要破坏
| 诉求 | 双亲委派的问题 | Tomcat 的做法 |
|---|---|---|
| 应用隔离 | 同名类会被父加载器抢先加载,两个应用互相污染 | 每个 webapp 独立 WebappClassLoader,先加载自己的 |
| 应用优先 | 应用想用新版本 jar,容器里是旧版 | 本地仓库优先,覆盖共享库版本 |
| 热部署 | 类无法卸载 | 丢弃整个 WebappClassLoader,类随之卸载 |
| 容器稳定 | 应用类污染容器 | 容器类由 Common/Catalina 加载,应用类不向上泄漏 |
典型的翻车场景:两个应用分别依赖 guava 20 和 guava 30,双亲委派下只能有一个版本生效;Tomcat 的隔离让它们各用各的。
3. 层级结构
Bootstrap(JVM 核心,rt.jar / java.base)
└─ System / AppClassLoader(CLASSPATH,含 bootstrap.jar、tomcat-juli.jar)
└─ Common ClassLoader(conf/catalina.properties 的 common.loader)
├─ Catalina ClassLoader(Tomcat 自身组件,server.loader)
└─ Shared ClassLoader(所有 webapp 共享,shared.loader)
└─ Webapp ClassLoader × N(每个应用一个)
└─ JasperLoader(每个 JSP 一个,支持 JSP 热编译)
默认配置下 server.loader 与 shared.loader 为空,Catalina 与 Shared 会退化为 Common,所以实际常见的层级是 Common → Webapp。
4. 配置示例
conf/catalina.properties:
# 容器与所有应用共享的 jar
common.loader="${catalina.base}/lib","${catalina.base}/lib/*.jar","${catalina.home}/lib","${catalina.home}/lib/*.jar"
# 只给 Tomcat 自身用(不暴露给应用)
server.loader=
# 所有应用共享,但 Tomcat 自身不加载
shared.loader=${catalina.base}/shared/lib/*.jar
conf/context.xml(对所有应用生效):
<Context delegate="false">
<!-- delegate=true:恢复标准双亲委派,先问父加载器 -->
</Context>
单个应用可以在 META-INF/context.xml 里覆盖:
<Context reloadable="true" delegate="true">
<Loader delegate="true" />
</Context>
reloadable="true" 只适合开发环境——它靠后台线程监控类文件变化,生产上会带来额外开销。
5. 热部署与类泄漏
Tomcat 重新部署应用时,会丢弃旧的 WebappClassLoader,让它的所有类变成不可达,由 GC 回收。这也是「Tomcat 内存泄漏」的常见来源:若有线程、ThreadLocal、JDBC 驱动注册表还持有旧加载器的引用,类就卸载不掉,反复部署后 Metaspace 持续增长。
排查手段:jmap -clstats <pid> 看类加载器数量,或 -XX:+TraceClassUnloading 观察卸载情况。
6. 常见追问
追问一:Tomcat 完全放弃双亲委派了吗?
没有。java.*、javax.*(部分)、sun.* 等核心包强制委派给 Bootstrap,防止应用伪造 java.lang.String。破坏只针对应用自己的类和第三方库。
追问二:WEB-INF/lib 和 lib/ 有什么区别?
WEB-INF/lib 由应用自己的 WebappClassLoader 加载,应用间隔离;$CATALINA_HOME/lib 由 Common 加载,所有应用共享。共享库适合 JDBC 驱动、公共日志实现。
追问三:delegate="true" 有什么副作用?
恢复「先父后子」,应用无法覆盖共享库版本,但能避免「同一个类被两个加载器各加载一份」导致的 ClassCastException(典型如 javax.servlet.* 冲突)。
追问四:Spring Boot 内嵌 Tomcat 也有这套机制吗?
没有。内嵌模式下应用是一个普通 Java 进程,由 AppClassLoader 加载,只有一层类路径,不存在 webapp 隔离。这也是为什么内嵌 Tomcat 部署多个应用要拆成多个进程。
追问五:为什么 JSP 需要单独的 JasperLoader? JSP 会被编译成 Servlet 类,修改 JSP 后需要重新编译并加载新类,旧类要能卸载。每个 JSP 一个加载器,丢弃它就能卸载对应类,实现「改 JSP 不重启应用」。
小结
Tomcat 类加载的关键是两级策略:核心类严格双亲委派(安全),应用类先己后人(隔离与覆盖)。理解了这一点,就能解释「为什么两个应用能用不同版本的同一个 jar」「为什么重新部署后 Metaspace 还在涨」这些现象。