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


面试考察点

  1. JVM 类加载基础:面试官不仅仅是想知道 Tomcat 的类加载器长什么样,更是想确认你是否真正理解双亲委派模型——它的好处是什么、限制又是什么。基础不牢的话,这道题很难答到点子上。

  2. Tomcat 特殊需求理解:Tomcat 作为一个 Web 容器,要同时跑多个 Web 应用,它面临的问题(应用隔离、类库共享、热部署)普通 JVM 根本不用操心。你能不能把这些需求讲清楚,是这道题的加分关键。

  3. 设计取舍的判断力:Tomcat "破坏" 双亲委派不是瞎搞,而是有意为之。面试官想看你是否理解 "什么时候该遵守规则、什么时候该打破规则" 这种架构决策能力。

核心答案

先说结论:Tomcat 没有完全遵守 JVM 的双亲委派模型,而是设计了一套自己的类加载器体系,核心目的是实现 Web 应用之间的类隔离和类库共享。

JVM 默认的双亲委派模型要求类加载请求一路向上委托给父加载器,父加载器找不到才由自己加载。但 Tomcat 有几个刚需双亲委派满足不了:

  • 应用隔离:两个 Web 应用都依赖了 commons-lang3,但版本不同(一个是 3.12,一个是 3.14),必须互不干扰
  • 类库共享:Spring、日志框架这些公共类库,所有 Web 应用共享一份就行,没必要每个应用都加载一份
  • 热部署:重新部署一个 Web 应用时,需要把旧的类加载器扔掉,创建一个新的,而不影响其他应用

所以 Tomcat 的做法是:该共享的共享,该隔离的隔离,灵活运用但不乱来。

深度解析

一、Tomcat 类加载器体系全景图

上图展示了 Tomcat 完整的类加载器体系。从上到下可以分为三层:

  • JVM 标准层(Bootstrap → Extension → Application):这三层就是 JVM 默认的双亲委派,Tomcat 没动它。Bootstrap 加载 JDK 核心类库(rt.jar),Extension 加载扩展类库,Application 加载 Tomcat 自身的启动类(比如 catalina.sh 跑起来的入口类)。

  • Tomcat 公共层(Common ClassLoader):这是 Tomcat 自定义的第一个类加载器,负责加载 $CATALINA_HOME/lib 目录下的所有 jar 包。像 servlet-api.jarel-api.jar 以及你放到 lib 目录下的公共类库(比如 Spring、日志框架),都由它来加载。所有 Web 应用共享这一层。

  • Web 应用隔离层(WebApp ClassLoader):这是整个体系的核心。 每个 Web 应用都有一个独立的 WebAppClassLoader,负责加载 WEB-INF/classesWEB-INF/lib 下的类。应用 A 和应用 B 各有自己的 WebAppClassLoader,互相看不到对方的类,实现了隔离。

最下面还有一层 JspClassLoader,专门处理 JSP 文件的热编译。JSP 修改后不需要重启整个应用,只需要扔掉旧的 JspClassLoader,创建一个新的重新编译就行。

二、WebAppClassLoader 的加载顺序——打破双亲委派的关键

这一块是面试官最想听到的。JVM 的双亲委派是 "先问父亲,父亲找不到再自己来",但 WebAppClassLoader 的策略不一样:

上图展示了 WebAppClassLoader 的加载逻辑。核心区别就一句话:普通的双亲委派是 "先问爹",Tomcat 的 WebApp 是 "先自己找"。

为什么要反着来?举个例子你就明白了:

假设你的 Web 应用用了 Spring 5.3,Tomcat 的 lib 目录下有个旧版的 Spring 4.x。如果严格遵循双亲委派,Spring 4.x 会被 CommonClassLoader 先加载,你的应用就只能用旧版,运行直接报错。先自己找,就能保证应用用自己的 WEB-INF/lib 下的新版 Spring,互不干扰。

但是!Tomcat 并不是无脑 "先自己找",对 Java 核心类(java.*)和 JDK 标准扩展类(javax.*)依然严格遵守双亲委派,必须由父加载器加载。这是安全要求,不然你自己写个 java.lang.String 把 JDK 的给替换了,那还得了?

源码层面的实现,在 Tomcat 的 WebappClassLoaderBase 中,关键的 loadClass() 方法大致逻辑如下(简化版):

public Class<?> loadClass(String name, boolean resolve) {
    // 1. 检查是否已加载
    Class<?> clazz = findLoadedClass(name);

    if (clazz == null) {
        // 2. 核心类(java.* 等)必须委派给父加载器,不能自己搞
        if (isJavaInternalClass(name)) {
            clazz = parent.loadClass(name);
        } else {
            // 3. 先在自己地盘找(WEB-INF/classes、WEB-INF/lib)
            try {
                clazz = findClass(name);
            } catch (ClassNotFoundException e) {
                // 4. 自己找不到,再委托给父加载器
                clazz = parent.loadClass(name);
            }
        }
    }
    return clazz;
}

当然实际源码比这复杂得多,还有各种安全校验、缓存机制,但核心逻辑就是这个 "先自己后父亲" 的策略。

三、为什么 Tomcat 要 "破坏" 双亲委派?

严格来说,Tomcat 并不是 "破坏" 双亲委派,而是 在保证安全的前提下做了合理的调整。这背后有三个硬需求:

需求一:Web 应用隔离

一台 Tomcat 部署了 3 个应用,A 用 Spring 5.3,B 用 Spring 4.3,C 用 Spring 6.0。如果共用一个类加载器,Spring 的 BeanFactory 类只能加载一个版本,其他两个应用就废了。每个应用一个独立的 WebAppClassLoader,各自加载各自的,互不干扰。

需求二:公共类库共享

反过来说,3 个应用都用 servlet-api.jar,这个 jar 又跟具体业务无关。如果每个应用都加载一份,纯粹浪费内存。所以这类公共类库放到 $CATALINA_HOME/lib 下,由 CommonClassLoader 统一加载,大家共享。

需求三:热部署/热替换

开发阶段改了个 Java 类,希望不用重启 Tomcat 就能生效。Tomcat 的做法是:停掉旧的 WebAppClassLoader(它加载的所有类都会被 GC),然后创建一个新的 WebAppClassLoader 重新加载应用。旧加载器加载的类在新加载器里完全不可见,实现了 "热替换"。双亲委派模型下类一旦加载就不会卸载,根本做不到这一点。

四、Common、Catalina、Shared 三个加载器的关系

这块容易搞混,简单理一下:

类加载器加载路径作用可见性
CommonClassLoader$CATALINA_HOME/lib公共类库,Tomcat 和所有 Web 应用共享Tomcat 内部 + 所有 WebApp
CatalinaClassLoader通过 server.xml 配置Tomcat 自身内部使用的类,Web 应用看不到仅 Tomcat 内部
SharedClassLoader通过 server.xml 配置所有 Web 应用共享但 Tomcat 内部不用的类所有 WebApp

默认情况下,CatalinaClassLoaderSharedClassLoader 的加载路径跟 CommonClassLoader 是一样的(指向同一个目录),所以看起来好像只有 CommonClassLoader 在干活。你可以通过修改 catalina.properties 中的 server.loadershared.loader 来把它们区分开。

实际生产中,大部分团队就用默认配置,很少去改这两个。知道有这么回事就行。

面试高频追问

  1. 追问一:Tomcat 是如何实现热部署的?

    核心就是换类加载器。Tomcat 监控 WEB-INF/classesWEB-INF/lib 目录的文件变化(通过文件最后修改时间判断),发现变化后,先把旧的 WebAppClassLoader 停掉(它加载的所有类就不可达了,等待 GC 回收),然后创建一个新的 WebAppClassLoader 重新加载整个应用的类。对 JVM 来说,新旧两个同名类是由不同的类加载器加载的,属于不同的类型,互不影响。

  2. 追问二:如果两个 Web 应用都用了 Spring,Spring 的 Bean 会不会冲突?

    不会。每个 Web 应用有独立的 WebAppClassLoader,各自加载各自的 Spring 框架类。应用 A 的 ApplicationContext 和应用 B 的 ApplicationContext 在 JVM 看来是两个完全不同的类实例,互不干扰。代价就是内存占用会大一些,Spring 的类被加载了多份。

  3. 追问三:可以把 Spring 的 jar 包放到 Tomcat 的 lib 目录下共享吗?

    技术上可以,但不推荐。放到 lib 下就由 CommonClassLoader 加载了,所有应用共享一份。问题在于:如果某个应用需要不同版本的 Spring,就搞不定了;而且所有应用共享同一个 Spring 上下文,容易产生冲突。除非你非常确定所有应用都用同一个版本,否则还是让每个应用自带比较好。

常见面试变体

  • "Tomcat 为什么打破了双亲委派模型?"
  • "Tomcat 是怎么实现 Web 应用之间的类隔离的?"
  • "JVM 的双亲委派模型在 Tomcat 中适用吗?"

记忆口诀

Common 管公共,WebApp 管隔离,JSP 管热加载;核心类走爹,业务类自己来。

三个层次记住就行:JVM 标准层照旧 → Common 层共享 → WebApp 层隔离(优先自己加载)。

总结

Tomcat 设计了一套自己的类加载器体系:CommonClassLoader 负责加载公共类库实现共享,每个 Web 应用有独立的 WebAppClassLoader 实现隔离。WebAppClassLoader 的加载策略是 "先自己后父亲"(优先加载 WEB-INF 下的类),但对 Java 核心类依然严格遵守双亲委派。这不是 "破坏",而是 Tomcat 在保证安全的前提下,为了满足应用隔离、类库共享和热部署的需求做的合理设计。面试时把这个设计动机讲清楚,比单纯背类加载器的名字值钱得多。