【JVM原理详解】08-类加载器实战-Tomcat类加载架构

【JVM原理详解】08-类加载器实战-Tomcat类加载架构 类加载器实战Tomcat类加载架构前两篇我们学习了双亲委派模型及其被打破的场景SPI/TCCL这些更多是框架层面的机制。本篇我们将目光投向一个工业级的产品——Apache Tomcat。Tomcat是最流行的Java Web容器之一它的类加载架构是打破双亲委派模型的经典案例同时也是面试中的高频考点。理解Tomcat的类加载设计不仅能帮你应对面试更能在实际开发中排查Web应用类冲突问题。Tomcat为什么需要自定义类加载架构假设Tomcat使用标准的双亲委派模型即所有Web应用共享同一个Application ClassLoader会出现什么问题需求一Web应用隔离Tomcat作为Web容器可以同时部署多个Web应用。这些应用可能依赖同一个第三方库的不同版本应用A: 依赖Spring Framework 5.3.x 应用B: 依赖Spring Framework 6.0.x如果共享同一个类加载器这两个版本的Spring类会冲突——类加载器只会加载先出现的那一个版本。应用A可能用了6.0的Spring类导致运行异常应用B反之亦然。因此每个Web应用必须有独立的类加载器加载各自依赖的类库实现相互隔离。需求二热部署在开发过程中修改了JSP文件后希望浏览器刷新就能看到效果不需要重启整个Tomcat。这意味着修改后的类需要被重新加载。但根据上一篇讲到的defineClass不可逆性同一个类加载器无法重新加载已加载的类。因此热部署必须创建新的类加载器实例用新加载器重新加载修改后的类。需求三JVM核心类库的共享与安全虽然Web应用之间需要隔离但所有应用共享JVM核心类库java.lang.*等。这些类必须由Bootstrap ClassLoader统一加载防止Web应用篡改。同时Tomcat自身的类库也需要与Web应用的类库隔离——Tomcat的Servlet API实现不应被Web应用覆盖。这三个需求决定了Tomcat不能简单地使用双亲委派模型必须设计一套多层级的自定义类加载架构。Tomcat类加载器层次结构Tomcat的类加载器层次结构如下以Tomcat 9/10为例┌─────────────────────────────┐ │ Bootstrap ClassLoader │ │ (加载JVM核心类库) │ └──────────────┬──────────────┘ │ ┌──────────────▼──────────────┐ │ System ClassLoader │ │ ( Application CL) │ │ (加载catalina.properties │ │ 中server.loader配置) │ └──────────────┬──────────────┘ │ ┌──────────────▼──────────────┐ │ Common ClassLoader │ │ (加载 $CATALINA_HOME/lib) │ │ Tomcat和Web应用共享的类 │ └──────┬───────────────┬───────┘ │ │ ┌────────────▼──┐ ┌──▼────────────┐ │ Catalina CL │ │ Shared CL │ │ (Tomcat内部 │ │ (所有WebApp │ │ 可见的类) │ │ 共享的类) │ └───────────────┘ └──┬─────────────┘ │ ┌────────────▼────────────┐ │ WebApp ClassLoader │ │ (每个Web应用独立) │ │ WEB-INF/classes │ │ WEB-INF/lib │ └────────────┬────────────┘ │ ┌────────────▼────────────┐ │ JasperLoader │ │ (每个JSP文件独立) │ │ 热部署JSP编译后的类 │ └─────────────────────────┘Common ClassLoaderCommon ClassLoader加载$CATALINA_HOME/lib目录下的类库。这些类库对Tomcat内部和所有Web应用都可见。典型包含Servlet API的实现如tomcat-coyote.jar、servlet-api.jar等。Catalina ClassLoaderCatalina ClassLoader加载Tomcat服务器自身需要的类库对Web应用不可见。通过catalina.properties中的server.loader配置指定路径。如果不配置server.loader则Catalina ClassLoader等同于Common ClassLoader。这样设计的目的是Web应用看不到Tomcat内部的实现类防止Web应用代码意外引用Tomcat内部类提高隔离性。Shared ClassLoaderShared ClassLoader加载所有Web应用共享的类库对Tomcat内部不可见。通过catalina.properties中的shared.loader配置指定路径。如果不配置shared.loader则Shared ClassLoader等同于Common ClassLoader。如果多个Web应用需要共享某个库如统一的日志框架可以将其放到shared.loader指定的路径下。WebApp ClassLoaderWebApp ClassLoader是每个Web应用独立的类加载器加载WEB-INF/classes和WEB-INF/lib下的类。这是Tomcat类加载架构中最重要、也是打破双亲委派最彻底的类加载器。JasperLoaderJasperLoader负责加载JSP文件编译后的Servlet类。每个JSP文件对应一个JasperLoader实例当JSP文件被修改时Tomcat会丢弃旧的JasperLoader创建新的实例来加载重新编译后的类——这就是热部署的实现原理。WebAppClassLoader如何打破双亲委派WebAppClassLoader是Tomcat打破双亲委派的核心。标准双亲委派模型是先委托父加载器父加载器加载不了再自己加载而WebAppClassLoader的加载策略是**“先自己加载自己加载不了再委托父加载器”**。WebAppClassLoader的加载顺序Tomcat的WebappClassLoaderBaseWebAppClassLoader的基类的加载顺序如下简化版1. 检查本地缓存该类是否已被本加载器加载过 2. 检查JVM缓存findLoadedClass该类是否已被JVM加载过 3. 用System ClassLoaderApp CL尝试加载 —— 这一步是为了防止Web应用覆盖J2SE核心类 —— 比如Web应用里有java.lang.String不会加载它先用System CL加载核心类 4. 使用本WebAppClassLoader自己尝试加载findClass —— 从WEB-INF/classes和WEB-INF/lib中查找 —— 这就是优先自己加载打破了双亲委派 5. 如果自己加载不了再委托给Common ClassLoader父加载器加载为什么要先自己加载先自己加载的原因是Web应用的类应该优先于容器提供的类。例如Web应用可能依赖特定版本的Spring而这个版本与Tomcat自带的库不同应该用Web应用自己WEB-INF/lib下的版本。为什么还要先用System ClassLoader加载J2SE类第3步看似多余——既然要优先自己加载为什么还要先让System ClassLoader加载这是为了安全性防止Web应用通过自定义java.lang.String等核心类来绕过安全限制。先让System ClassLoader加载J2SE类就保证了核心类来自JVM而非Web应用。关键源码解析// Tomcat WebappClassLoaderBase.loadClass 源码简化版保留核心逻辑OverridepublicClass?loadClass(Stringname,booleanresolve)throwsClassNotFoundException{Class?clazznull;// (1) 检查本地缓存clazzfindLoadedClass0(name);if(clazz!null)returnclazz;// (2) 检查JVM缓存clazzfindLoadedClass(name);if(clazz!null)returnclazz;// (3) 用System ClassLoader加载J2SE类防止覆盖核心类StringresourceNamebinaryNameToPath(name,false);ClassLoaderjavaseLoadergetJavaseClassLoader();try{clazzjavaseLoader.loadClass(name);if(clazz!null){if(resolve)resolveClass(clazz);returnclazz;}}catch(ClassNotFoundExceptione){// 忽略}// (4) 检查是否需要委托给父加载器默认delegatefalse不委托if(delegate){clazzparent.loadClass(name);if(clazz!null)returnclazz;}// (5) 自己尝试加载WEB-INF/classes 和 WEB-INF/libtry{clazzfindClass(name);if(clazz!null)returnclazz;}catch(ClassNotFoundExceptione){// 忽略}// (6) 如果没委托过最后再委托给父加载器if(!delegate){clazzparent.loadClass(name);if(clazz!null)returnclazz;}thrownewClassNotFoundException(name);}通过配置控制委派策略Tomcat允许通过WEB-INF/context.xml中的Loader delegatetrue/false/来控制委派策略delegatefalse默认先自己加载再委托父加载器。推荐生产环境使用。delegatetrue先委托父加载器再自己加载。即标准双亲委派模式。适用于某些需要共享父加载器类库的场景。!-- WEB-INF/context.xml --ContextLoaderdelegatefalse//Context热部署的实现原理热部署Hot Deploy是Tomcat类加载架构的重要应用场景主要体现在JSP的热更新上。JSP到Servlet的编译过程当浏览器第一次请求一个JSP页面时Tomcat的Jasper引擎会将JSP文件编译成一个Java Servlet类然后编译成.class文件最后用JasperLoader加载并实例化这个Servlet。index.jsp ──(Jasper编译)──▶ index_jsp.java ──(javac)──▶ index_jsp.class │ ▼ JasperLoader加载 │ ▼ 实例化并执行JSP热更新的检测机制Tomcat默认开启JSP热更新检测通过development参数控制!-- WEB-INF/web.xml 中的JSP配置 --jsp-configjsp-property-groupurl-pattern*.jsp/url-patternel-ignoredfalse/el-ignoredscripting-invalidfalse/scripting-invalid/jsp-property-group/jsp-config在developmenttrue模式下默认Jasper会定期检查默认间隔3秒由modificationTestInterval控制JSP文件的最后修改时间。如果发现文件被修改就触发重新编译和加载。热更新的类加载器替换当JSP被修改后Tomcat的热更新流程如下1. 检测到 index.jsp 文件被修改 2. 丢弃旧的 JasperLoader 实例 ── 旧的 index_jsp.class 随之不可达等待GC 3. 重新编译 index.jsp → 新的 index_jsp.java → 新的 index_jsp.class 4. 创建新的 JasperLoader 实例 5. 用新的 JasperLoader 加载新的 index_jsp.class 6. 实例化新的 Servlet后续请求由新Servlet处理// JasperLoader热更新的核心逻辑简化版publicclassJspRuntimeContext{privateMapString,JspServletWrapperjspsnewConcurrentHashMap();// 检查JSP是否需要重新编译publicvoidcheckCompile(){for(JspServletWrapperjsw:jsps.values()){if(jsw.isOutDated()){// JSP文件被修改了// 关键创建新的JasperLoaderJasperLoadernewLoadernewJasperLoader(newURL[]{workDirUrl},// JSP编译输出目录jsw.getParentClassLoader());// 旧的JasperLoader被丢弃旧类等待GCjsw.setClassLoader(newLoader);// 重新编译JSPjsw.compile();// 后续请求将使用新的JasperLoader加载新的Servlet类}}}}热部署的内存泄漏风险热部署有一个潜在问题旧的JasperLoader和它加载的类不一定能被及时GC。如果旧Servlet的实例被其他地方引用如Session中缓存了对象、静态变量持有引用等那么旧JasperLoader就无法被回收导致Metaspace/PermGen内存泄漏。这就是经典的Tomcat热部署导致OutOfMemoryError: MetaspaceJDK 8前为PermGen space问题的根源。每次部署都会创建新的WebAppClassLoader/JasperLoader如果旧的加载器无法回收Metaspace持续增长直至溢出。完整的类加载流程示例假设在Tomcat中部署了一个Web应用它依赖MySQL驱动并有一个JSP页面查询数据库请求: http://localhost:8080/myapp/query.jsp 1. Tomcat启动时: ├─ Common CL 加载 $CATALINA_HOME/lib/*.jar (包含servlet-api.jar) ├─ WebApp CL(myapp) 加载 WEB-INF/classes WEB-INF/lib/*.jar (包含mysql.jar) └─ JasperLoader 加载编译后的 query_jsp.class 2. query.jsp执行时: ├─ 需要HttpServletResponse类 │ └─ WebApp CL先检查 → 没找到 → 委托Common CL → 找到servlet-api.jar中的类 ├─ 需要com.mysql.cj.jdbc.Driver类 │ └─ WebApp CL先自己查找 → 在WEB-INF/lib/mysql.jar中找到 → 加载成功 └─ 需要java.sql.DriverManager类 └─ WebApp CL先检查J2SE类 → System CL加载 → 找到rt.jar/java.base中的类 3. 如果修改了query.jsp: ├─ Jasper检测到文件修改 ├─ 丢弃旧JasperLoader ├─ 重新编译query.jsp → 新的query_jsp.class ├─ 创建新JasperLoader加载新的Servlet类 └─ 下次请求使用新Servlet实践要点避免在Web应用中打包Servlet API有时开发者不小心将servlet-api.jar打包到WEB-INF/lib中虽然WebAppClassLoader会先加载WEB-INF/lib下的版本但由于Tomcat先让System/Common CL加载J2EE类Servlet API在Common CL中所以容器版本优先。但这可能导致版本不一致的诡异问题。建议通过scopeprovided/scope排除dependencygroupIdjavax.servlet/groupIdartifactIdjavax.servlet-api/artifactIdversion4.0.1/versionscopeprovided/scope!-- 由容器提供不打包进war --/dependency共享库的正确放置位置只给Tomcat内部用放server.loader路径所有Web应用共享放shared.loader路径单个Web应用用放WEB-INF/libTomcat和所有Web应用共享放$CATALINA_HOME/lib热部署频率控制频繁的热部署会导致大量JasperLoader创建和废弃。如果Metaspace空间不足会触发频繁Full GC甚至OOM。生产环境通常关闭JSP热部署developmentfalse开发环境适当设置modificationTestInterval减少检测频率。WebAppClassLoader与TCCL的配合Tomcat在处理Web请求时会将当前线程的上下文类加载器TCCL设置为对应Web应用的WebAppClassLoader。这确保了Web应用中通过SPI加载的服务如JDBC驱动能找到WEB-INF/lib下的实现类。Tomcat 10的命名空间变化Tomcat 10支持Jakarta EEjavax.servlet.*变为jakarta.servlet.*。这不是类加载器的变化但迁移时需要注意类名空间冲突——同一个应用不能同时引用javax和jakarta的Servlet API。小结Tomcat自定义类加载架构的三大动因Web应用隔离各应用独立类空间、热部署重新加载修改后的类、核心类库安全共享。Tomcat类加载器层次为Common → Catalina/Shared → WebApp → JasperLoader每一层负责不同范围的类加载。WebAppClassLoader打破了双亲委派采用先自己加载、再委托父加载器的策略但会先用System CL加载J2SE核心类以保证安全。热部署通过创建新的JasperLoader实例重新加载JSP编译后的类实现但需注意旧的类加载器回收问题避免Metaspace内存泄漏。下一篇我们将把目光转向类加载问题的排查——当类冲突、类隔离引发NoClassDefFoundError、LinkageError时如何快速定位和解决。