Tomcat 的类加载机制是怎样的?

#Java#面试八股

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 还在涨」这些现象。

笔记加载中…