双亲委派机制详解:从原理到Tomcat打破实战
十次面试九次会问“双亲委派”这话听着夸张但在Java后端岗位里真不算虚构。我帮部门做过好几轮技术终面候选人简历上写着“熟悉JVM类加载机制”的不少可真要展开讲讲“双亲委派是什么、为什么需要它、什么时候必须打破它、Tomcat又是怎么打破的”能把这几层讲利索的十个里也就能出来两三个。每次遇到这种场面我都觉得挺可惜的——双亲委派这套东西知识点本身不难难的是把它和实际场景串起来。很多人只记住了“先让父类加载器加载不行再自己来”这半句话可一旦追问到JDBC、Tomcat、SPI机制就开始含糊了。这篇文章我就把这套模型从头到尾拆开直接站在面试官提问的角度把原理、源码、场景、Tomcat的实际做法全部串一遍。不需要你背概念看完你就能用自己的话把这条链路讲通。面试问到这儿基本等于给你送分了。1. 双亲委派到底是什么先把模型讲透1.1 类加载器的三层金字塔在聊双亲委派之前得先弄明白Java里到底有哪些类加载器。绝大多数情况下你接触到的就是下面这三层启动类加载器Bootstrap ClassLoader最顶层负责加载JDK核心类比如rt.jar里的java.*包。它比较特殊是用C写的在Java里拿不到引用打印出来是null。扩展类加载器Extension ClassLoader第二层从JDK 9开始改名叫Platform ClassLoader了负责加载JDK的扩展库或平台类主要是jre/lib/ext目录下的东西JDK 9之后是jmods。应用程序类加载器Application ClassLoader第三层负责加载classpath下的类也就是你写的大部分业务代码包括引用的第三方jar包。我们平时说的“系统类加载器”指的就是它。除了这三层你还可以自己写类加载器继承ClassLoader想怎么定义怎么定义。这就是一个非常简单的树形结构父加载器在上子加载器在下整体形成了个金字塔。1.2 loadClass源码里的“先问爸爸”逻辑双亲委派的“双亲”听着像俩父亲其实是一个父加载器更准确的说法是“父类加载器委派模型”。核心逻辑概括成一句话就是当一个类加载器收到类加载请求时它不自己先加载而是先把这个请求往上抛让父加载器先尝试父加载器又抛给它的父加载器……只有所有父加载器都加载不了子加载器才会亲手加载。这段逻辑在ClassLoader.loadClass()方法里写得明明白白。你可以直接翻一下源码核心就几个步骤protected Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 1. 先查自己有没有加载过这个类 Class? c findLoadedClass(name); if (c null) { try { if (parent ! null) { // 2. 有父加载器先让父加载器去加载 c parent.loadClass(name, false); } else { // 3. 没有父加载器说明当前是Bootstrap层直接拿引导类加载器 c findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 父加载器抛异常说明它没加载到忽略 } if (c null) { // 4. 父加载器搞不定自己来 c findClass(name); } } if (resolve) { resolveClass(c); } return c; } }这串代码看懂了双亲委派的机制就彻底明白了一半。注意第二步父加载器也可能有自己的父加载器它会接着往上抛所以类加载请求是从下往上层层传递的。最后如果走到Bootstrap都没人加载成功回到最初的那个加载器自己调用findClass()。findClass()默认实现是直接抛ClassNotFoundException的真正加载类的逻辑在defineClass()里所以自定义加载器一般只需要重写findClass()就够了。1.3 网上很多文章讲错的细节这里我插一句。很多文章讲双亲委派喜欢用“爸爸先去尝试加载不行的话儿子来”这种比喻。比喻没错但有个细节容易被忽略**父加载器不是“先来后到”的先后关系而是“委派关系”。**子加载器不是自己加载失败之后才想起父加载器而是从一开始就把请求抛给了父加载器自己压根儿没动手。还有一个点很容易被误解**同一个类到底由哪个加载器加载不是看类的包名而是看加载请求从哪个加载器发起的。**比如一个叫com.example.Demo的类你可以用应用类加载器加载也可以用一个自定义加载器加载。如果两个加载器都加载了一遍同样的字节码那JVM里就存在两个全限定名相同的Class对象它们之间互相不兼容。这正是双亲委派要避免的问题之一。2. 为什么必须有双亲委派这机制到底在防什么面试官问完“什么是双亲委派”百分之百会接一句“为什么需要它”。你要是只答一个“防止类重复加载”那还不够。至少要讲出三方面安全、不重复、体系一致。2.1 核心类不会被随意替换——沙箱安全最经典的理由是安全。想象一个场景你自己写了一个java.lang.String里面埋了恶意代码。如果类加载器不遵循任何规则应用类加载器直接把你自己写的String加载进来那么整个JVM里所有引用String的地方都会用你这套经过篡改的“假String”——你敢动String程序就敢崩给你看。有了双亲委派就不一样了。你写的String类的加载请求会一层层往上抛最后到达Bootstrap ClassLoader。Bootstrap一查发现java.lang.String正是自己管的核心类直接把自己手里的标准String加载了。你那个自定义的java.lang.String根本没有机会被加载。这就是很多人说的“沙箱安全机制”——核心API只认启动类加载器谁也别想替换。2.2 类只会被加载一次天然保证唯一性第二点是避免类的重复加载。还是上面那个例子如果每个加载器都不问别人直接自己加载同一个类在JVM里就会存在多份Class对象。最直接的影响就是instanceof判断失败明明是一个全限定名你new出来的对象跟别人做类型比较结果返回false整个程序行为乱套。双亲委派让所有加载请求都汇聚到同一个加载器上谁加载过就记录在JVM的已加载类表里。后面再有相同的加载请求直接在findLoadedClass()这一步就返回了天然保证了类的唯一性。这个特性在Spring这类大型框架里尤其重要——如果同一个Bean类被不同的ClassLoader加载两份整个容器的类型体系就崩了。2.3 让核心类库体系保持全局一致第三点从设计角度讲双亲委派保证了Java核心类库在全局的“视角一致性”。想想看JDK内部有一套类内部的调用关系比如集合框架、IO体系之间互相引用它们要求彼此是基于同一份类定义协作的。如果核心类被不同加载器反复加载出不同版本JVM的整个类型系统就会进入一种“精神分裂”状态任何跨类加载器的类型交互都会出问题。用生活里的例子类比一下双亲委派就像单位里的审批流程。基层员工要办一件事不自己拍板先层层向上报告领导能决定的领导就处理了领导决定不了的再退回给基层自己想办法。好处就是大事小情层级清晰、责任明确不会出现两个人各干一套、最后版本对不上的混乱局面。这个机制本身很优雅但它也带来一个现实问题**强约束必然带来不便。**有些场景恰恰需要子加载器绕过父加载器、自己说了算这就引出了“打破双亲委派”这件事。3. 什么时候必须打破双亲委派三种典型场景面试官最爱的第二个考点马上就来了“什么情况会打破双亲委派”你至少要答出SPI机制和热部署这两类要是能说出Tomcat的具体做法那基本就是加分项了。3.1 SPI机制与线程上下文类加载器双亲委派有个天然的盲区顶层的核心类库想要加载底层的第三方实现类按正常委派流程是做不到的。我拿JDBC举个例子。java.sql.DriverManager在rt.jar里由Bootstrap ClassLoader加载。但是DriverManager要加载具体的数据库驱动比如MySQL的com.mysql.cj.jdbc.Driver这个类是放在应用classpath里的理论上应该由应用类加载器来加载。按双亲委派的流程Bootstrap加载DriverManager没问题但DriverManager想加载Driver实现类这个类在Bootstrap的管辖范围之外往下传递它也够不着——因为类加载器只能往上委派不会往下找子加载器。为了解决这个问题JDK引入了“线程上下文类加载器”Thread Context ClassLoader。简单说就是每个线程可以保存一个对特定类加载器的引用默认是应用类加载器。JDBC的DriverManager在加载具体驱动时不再走双亲委派的标准流程而是通过Thread.currentThread().getContextClassLoader()把这个“顶层类加载底层实现”的请求直接交到应用类加载器手里。这个机制本质上就是破坏了双亲委派模型的方向顶层的Bootstrap/Extension类加载器通过线程上下文这个“旁路”反过来调用了底层的应用类加载器。类似的场景还有JNDI、JDBC、JAXB这些SPI框架套路都一样面试时候点到“SPI 线程上下文类加载器”这八个字面试官就知道你是懂行的。3.2 热部署加载器换人类就“变心”第二种典型的打破场景是热部署。典型的例子是OSGi、包括现在很多微服务框架里模块的动态加载卸载。热部署的核心诉求是同一个类在版本升级之后要用新版本替换旧版本。但JVM有个硬化规则——类一旦被加载就没法卸载掉至少绝大多数商用JVM是这样。那怎么实现替换呢答案就是换类加载器旧的类加载器废弃掉新版本类用一个全新的类加载器重新加载。反正JVM里允许同一个全限定名存在多个不同加载器定义的类版本应用层只要保证新旧切换时的引用一致就行。这种场景如果死守双亲委派子加载器发现父加载器已经加载过这个类了自己就没有重新加载的机会。所以模块热部署的类加载器必须打破常规**自己先尝试加载加载不到再交给父加载器。**这就是“子优先”策略跟标准双亲委派的“父优先”恰好相反。3.3 Web容器为什么注定要打破规则Web容器——Tomcat、Jetty、Undertow——是打破双亲委派最典型的舞台。一个Tomcat实例上可能同时跑着几十个Web应用每个应用依赖的框架版本可能完全不同。A应用要用Spring 4B应用要用Spring 6两者的Spring类如果在Tomcat的全局共享类加载器里只有一份那必然互相打架。Tomcat的解法就是每个Web应用一个独立类加载器应用自己的类和lib优先自己加载打破“先父后子”的默认路径。这也是为什么Tomcat被称为“打破双亲委派”的教科书案例。讲到这儿一个自然的追问就来了Tomcat具体是怎么组织的下面专门拆开讲。4. Tomcat打破双亲委派的完整机制拆解4.1 Tomcat的类加载器家族Tomcat启动后会创建一系列类加载器整体层级大概长这样Common类加载器顶层公共加载器加载Tomcat自身的lib目录以及所有Web应用共享的类库。Catalina类加载器加载Tomcat容器内部核心类也就是catalina.jar这些。Shared类加载器被所有Web应用共享。注意Tomcat默认情况下Shared和Catalina都指向同一个实例要单独配置才能分开。Webapp类加载器每个Web应用一个实例负责加载这个应用WEB-INF/classes和WEB-INF/lib下的类。JasperLoader类加载器Webapp的子加载器专门负责处理JSP文件。JSP被修改后通过换一个新的JasperLoader重新编译加载实现JSP的热更新。从中间开始越往下越“私有”Common是共享的Webapp是每个应用单独一份JasperLoader是每个JSP动态换新。4.2 下来看它怎么打破的Webapp类加载器的加载顺序Tomcat的WebappClassLoader和标准的ClassLoader.loadClass()逻辑明显不一样它改变的恰恰就是加载顺序。我简化一下它的核心行为public Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { // 1. 先查自己已经加载过的类 Class? clazz findLoadedClass(name); if (clazz null) { // 2. 对于部分JVM核心包还是先走父类加载器 if (isJvmPackage(name)) { clazz parent.loadClass(name, resolve); } // 3. 自己优先加载先看WEB-INF/classes和WEB-INF/lib if (clazz null) { try { clazz findClass(name); } catch (ClassNotFoundException e) { // 自己的库也没有 } } // 4. 最后才往父加载器丢 if (clazz null) { clazz parent.loadClass(name, resolve); } } ... }这里最关键的一段是第三步和第四步的颠倒标准的双亲委派是先parent再findClassTomcat是先findClass再parent当然它还留了个例外——JVM核心包比如java.和javax.这些还是强制交给父加载器防止你把核心类覆盖掉。这个“子优先”的调整就是官方认可的“打破双亲委派”。4.3 为什么选“子优先”而不是彻底换一套Tomcat之所以用这种方式而不是彻底发明一套全新的加载模型有几个很实际的理由。第一应用隔离是刚需。不同Web应用之间类库版本互不干扰A应用升级SpringB应用完全不受影响。第二节省内存。所有应用都通用的类比如Tomcat本身的容器类放在Common层共享不需要每个Web应用各存一份。第三热部署可行。redeploy的时候把旧Webapp类加载器扔掉用全新的加载器加载新版本类JVM内存里那些旧Class对象自然不可达等着被回收。一个很经典的例子是同一个应用的JSP被修改了Tomcat检测到变更后会创建一个新的JasperLoader去加载重新编译后的JSP类这就是“热替换”的底层逻辑。如果你用过IDEA里改了JSP自动刷新的功能背后就是这套机制在干活。4.4 你是不是也该想想如果让你写一个隔离容器照抄这套面试官问Tomcat打破双亲委派很多时候真正的潜台词是将来让你设计一个插件系统、一个模块化框架你会怎么处理类加载隔离Tomcat这套模型基本就是标准答案的框架核心类共享一层防止重复加载和类型不兼容每个业务模块一个独立类加载器实现模块之间的类隔离模块更新时直接换加载器实现热替换核心JDK类永远走父加载器确保安全边界。理解了Tomcat的这个设计脉络面试的时候你就不再是背结论而是能跟面试官聊设计取舍了。5. 面试现场问题怎么拆、答案怎么组织、坑怎么躲5.1 一个九十分的回答长什么样面试官问“讲讲双亲委派机制以及Tomcat是怎么打破的”你可以按这个框架去组织回答逻辑顺信息量足还不会跑偏第一步先用一句话讲机制**类加载请求先由父加载器尝试加载不到再由子加载器自己加载。**然后提一下ClassLoader.loadClass()源码里findLoadedClass、parent.loadClass、findClass三段逻辑。第二步说为什么要这样一是安全防止核心类被篡改二是避免重复加载保证类型唯一三是对核心类库统一管理。第三步说什么时候要打破一是JDK的SPI场景比如JDBC用线程上下文类加载器解决“Bootstrap加载者需要调用应用层实现”的矛盾二是热部署和Web容器隔离典型代表是Tomcat。第四步展开Tomcat每个Web应用一个Webapp类加载器加载顺序改成“自己先找WEB-INF/classes和lib找不到再让父加载器加载”但java.*核心包仍然强制父加载。这样既做到了应用隔离又实现了热部署还守住了核心类的安全底线。你把这四步走完时间大概两分钟信息密度足够还有源码、场景、设计理念这个回答在面试里绝对是头部水平。5.2 高频追问清单和参考回答面试官听完你的四步回答之后经常会顺着往下追这里是我整理的高频追问和现场应对追问一“那你自己写一个类加载器怎么打破双亲委派”回答重点重写loadClass方法不调用super.loadClass自己控制加载顺序。注意JDK 9之后强约束java.*开头的基本不能干涉重写时别踩这条线。另外一个礼貌的做法是默认继承ClassLoader时重写findClass就够了只有明确要打破才重写loadClass。追问二“为什么Class.forName和ClassLoader.loadClass不一样”回答重点Class.forName(className)默认会执行类的初始化执行静态代码块ClassLoader.loadClass默认不初始化类只是把类加载到JVM。JDBC里Class.forName(“com.mysql.jdbc.Driver”)就是利用“初始化时自动注册驱动”这个特性才能让DriverManager找到驱动。追问三“Spring是打破双亲委派实现的吗”回答重点不是完全打破。Spring的类加载机制主要是依赖线程上下文类加载器来穿透SPI场景同时它对ClassUtils使用默认加载器加载Bean类。Spring本身没有设计类似Tomcat那样子的多加载器隔离体系它遵循的还是应用类加载器的体系只是通过TCCL解决了一部分“由顶层加载器发现底层实现类”的需求。追问四“多个类加载器都加载了同一个类会怎样”可以这样回答类全限定名相同、加载器不同在JVM层面会视为不同的类。跨加载器做类型校验会失败典型的错误就是ClassCastException常见于Tomcat里应用和父加载器包含同一个类的时候。Tomcat为了解决这类问题对某些类比如Servlet API会强制走父加载器加载保持全局唯一。追问五“JDK 9之后模块化对双亲委派有影响吗”答JDK 9的模块化引入了模块路径和Platform ClassLoader扩展类加载器被替换为平台类加载器。双亲委派的基本模型还在但加载边界和模块之间的关系比之前复杂。面试时候点到“Platform ClassLoader”和“模块边界”就够了再深一般是研究生方向不用过度纠缠。5.3 面试中最常见的三个致命失误见的人多了我自己总结出三个特别容易踩的坑你可以对照一下第一个坑混淆“委托”和“继承”。有人把双亲委派说成“继承关系”甚至说“Bootstrap继承Extension”。完全不对这些加载器之间是组合关系不是继承关系它们各自独立只是通过parent字段串联起来的。第二个坑以为打破双亲委派就是完全丢掉双亲。严格来说哪怕是Tomcat也没有完全舍弃父加载器——它的核心机制是调整了顺序不是彻底移除。真正设计良好的打破场景一定会保留对JDK核心类的保护逻辑这也是面试官在考察你有没有工程判断力的地方。第三个坑只答概念不给场景。答“双亲委派是父加载器优先”没问题但如果只说这个而举不出SPI、Tomcat的例子面试官就很难判断你是真会还是背概念。反过来一旦你能举出实际场景这段话的含金量马上不一样。6. 实操心得自己动手验证一遍才是真掌握前面讲了这么多最后聊点实际的。我建议你无论如何都自己动手验证一遍光看文章不落地面试现场还是会虚。最省事的实验是写一个自定义类加载器破坏一下默认顺序。比如你可以在本地创建一个类然后用一个继承ClassLoader的加载器重写loadClass直接不调用super.loadClass而是自己findClass。你会发现同一个类用系统类加载器和你的自定义加载器各加载一遍然后用instanceof比较两个对象会得到最直观的false。这个例子我每次讲都让人自己试一次真的比背十句话都管用。如果你想进一步验证Tomcat的加载顺序更简单的方式是做一个有两套不同版本同名jar的Web应用分别部署两个应用一个用旧版一个用新版然后观察它们互不影响包括线程上下文类加载器指向的也各是各的WebappClassLoader。亲自看过一遍你才会真正理解“隔离”和“共享”在JVM层面长什么样。从面试角度来讲双亲委派这个知识点不是“背下来就完了”你要能顺着JVM、类加载器、SPI、Web容器一路讲到设计理由又能在代码里找到对应逻辑才行。我见过很多候选人背得滚瓜烂熟但一被问到“你项目里用过哪些类加载器的场景”就卡住——与其这样不如老老实实把源码和实验做一遍把每个概念都落到具体的代码行上。最后再送你一个小技巧面试如果遇到类加载器相关的问题回答完原理之后可以主动补一句“我们项目里之前做过模块隔离/热部署当时处理的思路是这样……”。能把自己的实际经验接上这个回答的完整度会高出一个段位。毕竟面试官想听的不只是你知道汤姆克鲁斯是谁而是你能不能在特定的舞台上演好自己的角色。