搞懂双亲委派模型,我花了一个下午画图 📅 2026/7/5 8:42:35 上一篇把四层类加载器梳理清楚了但光知道有几层、分别是谁还不够。下一个问题是它们之间到底怎么配合为什么要有向上问的规则为什么说这个规则保证了核心类的安全这篇是我把双亲委派的原理和作用从头到尾理清楚之后记下来的。双亲委派到底是什么意思先说名字。双亲这个词容易让人误解以为是父亲和母亲两个爹。实际上它指的不是两个具体的父加载器而是一个机制子加载器收到加载请求时先交给父加载器一层层往上直到顶层。顶层加载不了再一层层往下让子加载器自己尝试。用一句话概括就是先向上问问不动了自己干。我在上一篇画过四层加载器的层级图现在加上委派的箭头看起来是这样的Bootstrap ClassLoader顶层 ↑ 委派 Platform / Extension ClassLoader ↑ 委派 Application ClassLoader ↑ 委派 Custom ClassLoader注意箭头的方向始终是从下往上。不是子加载器往下找而是把请求往上推。用代码把流程走一遍理论说再多不如跑一个例子。假设你写了这么一个类packagecom.example;publicclassHelloService{publicvoidsayHello(){System.out.println(Hello from custom class);}}然后你在main方法里用到了它。JVM 需要加载com.example.HelloService这个任务被交给了应用程序类加载器AppClassLoader。接下来发生的事情是这样的第 1 步AppClassLoader 收到加载请求。它没有直接去 ClassPath 里找而是先把请求交给了它的父加载器。第 2 步在 Java 17 上父加载器是 PlatformClassLoader。PlatformClassLoader 也没有自己先找而是把请求继续往上抛。第 3 步PlatformClassLoader 的父加载器逻辑上的是 Bootstrap ClassLoader。请求到了这里。第 4 步Bootstrap ClassLoader 看看这个类名com.example.HelloService。它负责的是 JDK 核心模块里的类比如java.lang.*、java.util.*不在它的管辖范围内。它返回我加载不了。第 5 步PlatformClassLoader 收到父加载器的加载不了信号。它自己试了一下它负责的是平台模块的类比如java.sql.*、java.xml.*也不管com.example这个包。它也返回我加载不了。第 6 步AppClassLoader 终于拿到了你的父加载器们都加载不了的结果。它自己在 ClassPath 上找到了com/example/HelloService.class加载成功。现在换一个例子加载java.lang.String。第 1 步请求同样从 AppClassLoader 出发向上抛给 PlatformClassLoader。第 2 步PlatformClassLoader 向上抛给 Bootstrap ClassLoader。第 3 步Bootstrap ClassLoader 一看java.lang.String是我的。它在核心模块里找到了这个类加载并返回。结果传到下层PlatformClassLoader 和 AppClassLoader 看到父加载器已经加载了就不再尝试。关键区别就在这一步对于核心类请求在顶层就被处理了不会往下传。对于你的业务类请求到顶层发现没人管再一层层传回来让 AppClassLoader 处理。我第一次完整走完这两条路径的时候才真正理解向上问的意义。它的精髓不是加载规则而是优先权规则核心类永远有最高优先级。反过来想没有双亲委派会怎样理解一个设计最好的方式是想象没有它会怎样。假设没有双亲委派每个类加载器收到请求都自己先尝试加载。你可以写一个java.lang.String类放在 ClassPath 里。AppClassLoader 加载的时候不会去问 Bootstrap ClassLoader直接在 ClassPath 上找到了你写的String类加载进来。然后你的String类和 JDK 的String类在 JVM 里并存了。JVM 怎么区分分不了。有些代码用的是你的String有些代码用的是 JDK 的String类型对不上运行时直接崩了。更危险的是安全问题。一个恶意用户写了一个javax.crypto.Cipher把加密逻辑替换成空实现。如果这个假Cipher被加载了所有依赖加密的代码就形同虚设。双亲委派杜绝了这种可能不管谁要加载javax.crypto.Cipher请求都会先到 Bootstrap ClassLoader它只加载真正的 JDK 核心类。双亲委派模型的几个作用把上面这些总结一下双亲委派模型主要做了这几件事1. 保证核心类的唯一性这是最重要的。同一份java.lang.String在整个 JVM 里只有一个版本。不管你写多少个自定义类加载器String类的加载权始终在 Bootstrap ClassLoader 手里。这也就保证了一个类在全 JVM 范围内只被加载一次。2. 防止核心类被篡改恶意代码没办法用一个自定义的假String或假System类覆盖 JDK 的核心实现。因为加载请求一上来就传到顶层了顶层只认 JDK 自己的版本。3. 核心类共享、节省内存核心类被 Bootstrap ClassLoader 加载一次之后所有子加载器都能通过委派拿到这个类。不需要每个加载器各自加载一份。4. 层次划分清晰每一层加载器各管各的范围。Bootstrap 管核心模块Platform 管平台模块AppClassLoader 管用户代码。职责边界清楚出了问题好排查。类加载器和类之间的双向绑定类加载器和它加载的类之间有一种双向绑定的关系。具体来说每个 Class 对象里会记录它是被哪个类加载器加载的要卸载一个类必须先卸载加载它的类加载器反过来想如果你只把某个类的实例设为 null但它的类加载器还活着这个类就不会被卸载。这在什么场景下会出问题一个典型的场景是 Tomcat 热部署。每次重新部署 Web 应用时Tomcat 会创建一个新的类加载器用新加载器加载更新后的类。旧的类加载器和它加载的所有类在确认没有任何引用之后才能被 GC 回收。如果旧类加载器一直被某个地方引用着就产生了类加载器泄漏类加载器活着的它加载的所有类也都活着内存就一点点涨上去。我读到这的时候想起了之前遇到过的一个内存泄漏问题当时只排查了对象引用没想过是类加载器的锅。后来翻了日志才发现是热部署多次之后旧的类加载器一直没释放。双亲委派搞不定的事不过双亲委派也不是万能的。上一篇提到过两个打破它的场景这里再展开说一下。SPI 的困境Java SPIService Provider Interface的机制是定义一个接口在核心库里实现类在第三方 jar 里。比如java.sql.Driver定义在 JDK 的java.sql模块中但 MySQL 的驱动实现类在 mysql-connector-java 这个 jar 里。问题来了java.sql.Driver是由 Bootstrap ClassLoader 加载的。但当它尝试加载 MySQL 的驱动实现类时它根本找不到因为 Bootstrap ClassLoader 不负责 ClassPath。Java 的解决办法是Thread.currentThread().getContextClassLoader()。这个线程上下文类加载器通常是 AppClassLoader。Bootstrap ClassLoader 用它来加载自己管不到的类。相当于爸爸让儿子帮忙跑个腿打破了向上问的规则。Tomcat 的做法Tomcat 需要部署多个 Web 应用每个应用有自己的类和 jar 包。两个应用里可能有同名但不同版本的类不能互相覆盖。Tomcat 的解决方案是给每个 Web 应用创建一个独立的类加载器WebAppClassLoader并且它的加载顺序跟双亲委派是相反的先自己尝试加载加载不到才交给父加载器。这个做法能保证每个 Web 应用的类互相隔离。但也带来了新的问题两个应用里如果都用了同一个第三方 jar各自的类加载器会各自加载一份内存上会有一些浪费。这是 Tomcat 在隔离和内存之间做的取舍。理完之后我的几点感受1. 双亲委派本质上是一个优先级规则。它解决的问题不是怎么加载而是谁先加载。核心类永远优先用户类靠后。先保证安全再考虑灵活。2. 打破规则本身也是规则的一部分。SPI、Tomcat、热部署工具它们打破双亲委派不是因为设计有问题而是因为向上问解决不了反向加载的问题。框架在自己可控的范围内做调整不影响整体的安全模型。3. 类加载器泄漏比对象泄漏更难排查。对象泄漏你还能 dump 堆看一下。类加载器泄漏意味着它加载的所有类都活着占用的是方法区的空间dump 堆不一定能看到。碰到这类问题要先确认是不是有类加载器一直没释放。4. 学双亲委派最好的方法是动手写一个打破它的例子。写一个自定义 ClassLoader覆写loadClass方法让它的加载顺序跟双亲委派反过来先自己尝试再交给父加载器。然后分别用正常委派和反向委派加载同一个类观察它们的ClassLoader对象是不是同一个。做一遍这个实验比读十篇文章都管用。