Tomcat 的类加载机制是什么?为什么要打破双亲委派?
简化版
Tomcat 为每个 Web 应用创建一个独立的 WebappClassLoader,让不同应用能使用不同版本的依赖、互不干扰,并支持应用卸载时回收类(热部署基础)。它打破了标准双亲委派:加载应用自己的类时,优先从本应用的 WEB-INF/classes 和 WEB-INF/lib 加载(而不是先委托父加载器),从而实现应用隔离。但它仍保护 JDK 核心类和 Servlet API——这些不允许应用覆盖,所以是「有选择地」打破,不是完全推翻。
详细版
标准双亲委派 vs Tomcat 的破坏:
| 标准双亲委派 | Tomcat WebappClassLoader | |
|---|---|---|
| 加载顺序 | 先委托父加载器,父加载不了才自己加载 | 先自己(本应用)加载,特定类才委托父 |
| 目的 | 保证核心类唯一、安全 | 应用隔离 + 热部署 |
Tomcat 的类加载器层级(自上而下):
Bootstrap ClassLoader (JVM 核心类 rt.jar / JDK 模块)
↑
System/Platform ClassLoader (JDK 扩展、CLASSPATH)
↑
Common ClassLoader (Tomcat 与所有 Web 应用共享的类,如 lib/)
↑
WebappClassLoader(每个应用一个) ← 加载本应用 WEB-INF/classes 和 WEB-INF/lib
WebappClassLoader 的加载逻辑(打破双亲委派处):
- 先查已加载缓存。
- 再让 Bootstrap 加载 JDK 核心类(这部分不破坏,保证安全)。
- 然后优先从本应用
WEB-INF/classes、WEB-INF/lib加载(这里破坏了双亲委派——应用类优先自己加载)。 - 本应用找不到,才委托 Common 等父加载器。
完整版教学
一、先回顾:双亲委派原本解决什么
标准的双亲委派模型:一个类加载器收到加载请求,先委托给父加载器去加载,父加载器再往上委托,直到 Bootstrap;只有父加载器加载不了,子加载器才自己加载。
它的目的:
- 保证核心类的唯一性和安全:像
java.lang.String这种核心类,永远由 Bootstrap 加载,不可能被应用自定义的同名类替换(防止恶意代码用假的 String 替换真的)。 - 避免重复加载:同一个类只被加载一次,由最顶层能加载它的加载器负责。
这在普通 Java 应用里很好。但 Web 容器有特殊需求,标准模型满足不了。
二、Web 容器为什么需要打破它:应用隔离
一个 Tomcat 可以同时部署多个 Web 应用(多个 war)。这带来一个核心矛盾:
A 应用依赖 Jackson 2.13,B 应用依赖 Jackson 2.17。如果按双亲委派,两个应用的 Jackson 类都由公共父加载器加载,那就只能有一个版本——两个应用没法各用各的版本,冲突。
要让每个应用拥有自己独立的依赖版本、互不污染,就必须让每个应用有自己的类加载器,且加载应用类时优先加载本应用的(而不是先去公共父加载器找)。这就必然要打破双亲委派——「应用类优先自己加载」正是对「先委托父加载器」的破坏。
此外,热部署也需要它:每个应用一个独立 ClassLoader,卸载应用时把这个 ClassLoader 连同它加载的类一起丢弃回收,就能实现「不重启 Tomcat 单独重新部署一个应用」。
三、Tomcat 的类加载器层级
Tomcat 设计了多层类加载器(不同版本细节略有差异,核心思想一致):
- Bootstrap:加载 JDK 核心类。
- System/Platform:加载 JDK 扩展、系统 CLASSPATH。
- Common ClassLoader:加载 Tomcat 自身和所有 Web 应用共享的类(Tomcat 的
lib/目录下的 jar)。放这里的类是全局共享的。 - WebappClassLoader:每个 Web 应用一个,加载该应用的
WEB-INF/classes(类文件)和WEB-INF/lib(jar)。这是实现应用隔离的关键——每个应用一个,互相看不见对方的类。
| 类加载器 | 典型加载内容 | 是否应用独享 | 面试要点 |
|---|---|---|---|
| Bootstrap | JDK 核心类 | 否 | 保护核心类唯一性 |
| System/Platform | JDK 平台类、系统类路径 | 否 | 普通 Java 应用常见父加载器 |
| Common | Tomcat lib/ 和共享类 | 否,所有 Web 应用共享 | 放错业务依赖会污染全部应用 |
| WebappClassLoader | WEB-INF/classes、WEB-INF/lib | 是,每个应用一个 | 应用隔离和热部署的关键 |
Tomcat
Common: servlet-api.jar, tomcat 自身类
AppA WebappClassLoader: WEB-INF/lib/jackson-2.13.jar
AppB WebappClassLoader: WEB-INF/lib/jackson-2.17.jar
上面这个例子里,A 和 B 的 Jackson 类名可能相同,但由不同 WebappClassLoader 加载,因此互不覆盖。若把某个 Jackson 放到 Tomcat lib/,它会变成 Common 层共享依赖,隔离就被破坏。
记忆钩子:Tomcat 不是为了“叛逆”才破坏双亲委派,而是为了让每个 Web 应用拥有自己的依赖小世界。
四、到底「打破」的是哪一段(关键细节)
面试爱追问「Tomcat 是不是完全不用双亲委派了」——不是。它打破的只是「应用自己的类」这一段:
WebappClassLoader 加载类的顺序:
- 先查缓存(已加载过的直接返回)。
- 让 Bootstrap 尝试加载 JDK 核心类(
java.*等)——这一步不破坏双亲委派,核心类仍由 Bootstrap 保证唯一和安全,应用无法覆盖java.lang.String之类。 - 然后优先在本应用的
WEB-INF/classes、WEB-INF/lib里找——这里破坏了双亲委派(应用类优先自己加载,而非先委托 Common)。 - 本应用找不到,才向上委托 Common 等父加载器。
所以准确说法是:JDK 核心类、Jakarta Servlet API 等容器关键类仍受保护(走双亲委派),只有应用自己的业务类是「本应用优先」(打破双亲委派)。 这是「有选择的打破」,兼顾了安全和隔离。
五、隔离带来的好处与热部署
这套机制的收益:
- 依赖隔离:A、B 应用各用各的依赖版本,一个应用升级库不影响另一个,互不污染。
- 热部署基础:每个应用独立 ClassLoader。卸载应用时,理论上它的 WebappClassLoader 和加载的所有类都能被 GC 回收,从而可以单独重新部署一个应用而不重启整个 Tomcat。
六、常见坑:内存泄漏与类冲突
① 依赖放错位置污染全局:把某个应用的依赖 jar 放到 Tomcat 的 lib/(Common 加载),会让它变成全局共享,可能污染其他应用(其他应用被迫用这个版本)。应用依赖应放 WEB-INF/lib。
② WebappClassLoader 内存泄漏(高频):热部署/卸载应用时,如果应用里有东西长期持有 WebappClassLoader 的引用,这个 ClassLoader 和它加载的所有类就无法被 GC,反复重新部署会导致 Metaspace/PermGen OOM。常见「泄漏源」:
- ThreadLocal 没清理:线程池的线程是 Tomcat 共享的,如果应用往 ThreadLocal 塞了自己的类实例又没 remove,线程持有它 → 间接持有 WebappClassLoader。
- 应用启动的线程/定时器没关闭:这些线程的上下文类加载器指向 WebappClassLoader。
- 注册了 JDBC Driver 没注销:
DriverManager是全局的,持有应用的 Driver 类。
这些是 Tomcat 热部署内存泄漏的经典原因,理解类加载隔离才能定位。
③ 类冲突排查:遇到 ClassCastException、NoSuchMethodError、LinkageError,先确认类到底从哪个 jar / 哪个 ClassLoader 加载。关键认知:两个「同名同全限定名」的类,若由不同 ClassLoader 加载,在 JVM 看来是完全不同的两个类型——把一个赋给另一个就会 ClassCastException。这解释了很多诡异的类型转换错误。
七、常见误区与追问
- 误区:Tomcat 完全抛弃了双亲委派。 它是选择性打破,JDK 核心类和容器关键类仍优先由父加载器保护,应用类才本应用优先。
- 误区:所有依赖放到 Tomcat
lib/更省事。 这样会让依赖全局共享,容易让多个应用发生版本污染,业务依赖应放在各自WEB-INF/lib。 - 追问:为什么每个 Web 应用要独立 ClassLoader? 为了依赖版本隔离和热部署,卸载应用时可以回收该应用加载的类。
- 追问:同名类为什么还会
ClassCastException? JVM 判断类型时看“类全限定名 + 类加载器”,不同加载器加载的同名类不是同一个类型。 - 误区:热部署内存泄漏只和堆对象有关。 WebappClassLoader 被线程、ThreadLocal、JDBC Driver 等持有时,会连同类元数据一起留在 Metaspace。
- 追问:ThreadLocal 为什么会导致类加载器泄漏? Tomcat Worker 线程可能比应用活得更久,ThreadLocal 值若引用应用类对象,就会间接持有 WebappClassLoader。
八、加强记忆
Tomcat 为每个 Web 应用一个 WebappClassLoader,实现依赖隔离(不同应用用不同版本,互不污染)和热部署(卸载时回收 ClassLoader)。加载器层级:Bootstrap → System → Common(Tomcat 与所有应用共享,lib/)→ WebappClassLoader(每应用一个,加载 WEB-INF/classes、WEB-INF/lib)。打破双亲委派体现在:加载应用自己的类时「本应用优先」(先查 WEB-INF,找不到才委托父),但 JDK 核心类和 Servlet API 仍走双亲委派受保护,不允许覆盖——是「有选择的打破」。坑:应用依赖放 lib/ 会污染全局;ThreadLocal/线程/JDBC Driver 未清理会持有 WebappClassLoader 导致卸载后 Metaspace 泄漏;不同 ClassLoader 加载的同名类是不同类型,会 ClassCastException。