资讯动态

深入拆解JVM双亲委派:从加载器职责到类冲突排查实战

发布时间:2026/10/24 4:54:47 来源:尧图企业网站定制
把 JVM 比作一栋大楼的话类加载器就是往楼里各层运货的配送系统你得先把 class 文件安全地送进大楼楼里的其他代码才能开始干活。而“双亲委派”就是这套配送系统的核心调度规则。很多人被问过“什么是双亲委派”也能脱口而出“先让父加载器加载父加载不了子加载器再上场”可真到了排查 NoClassDefFoundError、类冲突或者想动手写一个插件加载器的时候就会发现自己对这句话的理解还停留在字面上。这篇文章不打算背概念我会从类加载器的职责讲起把委派链路一层层拆开再带上可运行的验证代码和几个真实排错案例希望你看完能真正做到“看得见、排得动”。1. 双亲委派到底在解决什么问题1.1 先把“类加载器”这个角色看清楚Java 里所有类都是被某个类加载器加载进 JVM 的哪怕是 JDK 自带的 String、ArrayList 也不例外。一个类加载器要做的事其实很纯粹拿到一个二进制字节流通常是 .class 文件解析出类的结构放到方法区里最后在代码里见到new、getClass()这类操作时能有对应的 Class 对象可以返回。类加载器在 JVM 中不是单一的默认情况下就存在多个加载器它们之间还形成了层级关系。这带来一个现实问题同一个全限定名比如com.example.User如果两个加载器各自都能加载它那 JVM 里就会出现两个类的版本互相之间还不能直接赋值转换。为了避免这种混乱JVM 定下了一条人人必须遵守的规矩——双亲委派。1.2 一条规则说清楚委派过程双亲委派的规则用一句话就能讲明白当一个类加载器收到加载请求时它不会自己先动手而是先把请求逐级向上抛给父加载器只有父加载器明确说自己加载不了子加载器才尝试自己加载。注意这里的“父加载器”并不是面向对象里的父类继承而是一种持有关系。你可以把它理解成“上级”——加载器之间像公司里的汇报链出了事先找领导领导解决不了再轮到你自己想办法。只要遵从这个规则同一个类无论从哪个入口发起加载请求最终都会汇合到最顶层去处理类就不会在多个加载器里各来一份。提示双亲委派里的“父”是提前通过代码组合建立起来的引用比如ClassLoader(ClassLoader parent)构造方法传进来的那个对象不是extends出来的父类。2. 这套链路里到底站着谁2.1 三个内置加载器与默认层级从 JDK 8 开始JVM 内置了三个类加载器从上到下依次是启动类加载器Bootstrap、平台类加载器Platform早期叫扩展类加载器 Extension和应用类加载器Application也就是系统类加载器。三层之间的关系可以用一张对照表快速记住加载器负责加载什么父加载器启动类加载器 BootstrapJVM 核心类库如java.*、javax.*、sun.*等基础 API没有本质上是 C/C 实现不是 Java 类平台类加载器 PlatformJDK 里的一些扩展性 API比如java.sql.*、java.nio.*等启动类加载器应用类加载器 Applicationclasspath 下的业务类、第三方依赖库平台类加载器Bootstrap 加载器有点特殊它在 JVM 层面上是原生代码实现的所以你在 Java 代码里调用ClassLoader.getParent()返回的会是null。这不是说它没有父加载器而是用一个空值表示“我就是最顶层”请求到了这里就是终点。在实际运行时你写的业务类通常由 Application 加载器加载而 Application 先问 PlatformPlatform 先问 Bootstrap。比如加载java.lang.String请求会一路传到 Bootstrap由它出手而加载你自己的com.example.DemoBootstrap 和 Platform 都没见过这个包路径请求原路退回给 Application最后才落到它头上。2.2 loadClass 方法委派逻辑的真实执行代码光听规则不够还得看看 JVM 里这条规则是怎么落地的。平时我们强调要重写findClass而不是loadClass原因就在loadClass的默认实现里。这段默认逻辑的简化版本是这样的protected Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 第一步查缓存看这个类是不是已经加载过了 Class? c findLoadedClass(name); if (c null) { if (parent ! null) { // 第二步先让父加载器去加载 c parent.loadClass(name, false); } else { // 没有父加载器就找启动加载器 c findBootstrapClassOrNull(name); } if (c null) { // 父加载器都说加载不了才轮到自己 c findClass(name); } } if (resolve) { resolveClass(c); } return c; } }从这段代码可以看到几个关键细节。第一加载过程是带锁的getClassLoadingLock保证同一个类的加载并发安全第二加载前会先查缓存findLoadedClass已经加载过的类不会重复执行加载流程第三整个委派的核心就是“先 parent 后 findClass”的顺序这是双亲委派最直接的代码体现。所以如果你要自定义一个加载器默认只重写findClass那你的加载行为会自动遵循双亲委派——因为loadClass的逻辑没动父加载器永远优先。可如果你为了热部署之类的原因去重写loadClass强制让自己先加载那你就亲手把这条委派链打破了后面会展开讲。2.3 为什么非要设计成“往上抛”面试里常问“双亲委派解决了什么问题”答案其实集中在安全和唯一性两个方面。安全层面最典型的一个例子如果没有委派机制你可以在自己的代码里定义一个java.lang.String让 JVM 加载到你自己实现的那个恶意版本整个运行时环境就变得不可信了。有了双亲委派顶层 Bootstrap 发现有同名的java.lang.String直接把 JDK 自带的那个给你你定义的那个根本没机会被加载。唯一性层面更好理解。同一个类全限定名在同一个类实例上反复加载JVM 会直接返回缓存里的结果不会出现两份。而跨加载器场景下委派保证所有加载路径最后都汇聚到同一个顶层加载器从根源上避免同一个类被多个加载器各加载一份。注意类在 JVM 里的身份识别是“类定义 类加载器”共同决定的。两个不同的加载器加载同一个.class文件在 JVM 眼里就是两个完全不同的类互相强转会直接抛 ClassCastException。3. 亲手验证让双亲委派看得见摸得着3.1 写个小类打印自己的加载器链我习惯用一个最简单的小例子让新人直观看到委派的结果。新建一个类打印自己的加载器、加载器的父级以及 JDK 自带类的加载器public class LoaderProbe { public static void main(String[] args) { LoaderProbe probe new LoaderProbe(); ClassLoader cl probe.getClass().getClassLoader(); System.out.println(业务类加载器: cl); System.out.println(业务类父加载器: cl.getParent()); System.out.println(再往上: cl.getParent().getParent()); ClassLoader stringCl String.class.getClassLoader(); System.out.println(String 的加载器: stringCl); } }编译后在标准 classpath 下运行输出大致是这样业务类加载器: jdk.internal.loader.ClassLoaders$AppClassLoader 业务类父加载器: jdk.internal.loader.ClassLoaders$PlatformClassLoader 再往上: null String 的加载器: null这个输出已经能说明很多事了。你的类由 AppClassLoader 加载App 的父级是 PlatformPlatform 再往上是 null——对应 Bootstrap。而java.lang.String的加载器直接显示 null说明它由原生层的 Bootstrap 加载在 Java 代码中表现为空。如果你在 JDK 8 上跑看到的加载器类名会不太一样但层级结构完全一致。3.2 写一个遵守委派的自定义加载器接着来做一个自定义加载器验证委派不是一句空话。我们定义一个父加载器能加载的类再看自定义加载器能不能“抢”到手。先写一个简单类public class Hello { static { System.out.println(Hello 被加载加载器: Hello.class.getClassLoader()); } }把它放到 classpath 下编译出 Hello.class。然后写自定义加载器只重写findClassimport java.io.*; public class MyLoader extends ClassLoader { private String path; public MyLoader(String path, ClassLoader parent) { super(parent); this.path path; } Override protected Class? findClass(String name) throws ClassNotFoundException { byte[] bytes loadClassBytes(name); if (bytes null) { throw new ClassNotFoundException(name); } return defineClass(name, bytes, 0, bytes.length); } private byte[] loadClassBytes(String name) { String fileName path File.separator name.replace(., File.separatorChar) .class; try (InputStream in new FileInputStream(fileName)) { ByteArrayOutputStream bos new ByteArrayOutputStream(); byte[] buffer new byte[4096]; int len; while ((len in.read(buffer)) ! -1) { bos.write(buffer, 0, len); } return bos.toByteArray(); } catch (IOException e) { return null; } } public static void main(String[] args) throws Exception { MyLoader loader new MyLoader(., null); Class? clazz loader.loadClass(Hello); clazz.newInstance(); // 打印到底是谁加载了 Hello System.out.println(实际加载器: Hello.class.getClassLoader()); } }这里我把自定义加载器的父级传成了null它的委派终点就是 Bootstrap。但即便如此Hello这种业务类 Bootstrap 并不认识请求会一路原路返回最终由MyLoader自己通过findClass读字节流加载。运行时你会看到Hello 被加载的静态块执行并且“实际加载器”打印出来就是MyLoader。如果把父级改成 AppClassLoader 再试一次结果就完全不同了Hello.class本来就在 classpath 上App 加载器能加载它于是你自己的MyLoader从Hello.class.getClassLoader()里查缓存都会直接命中——这个类根本不会经过你的findClass。这就是双亲委派生效的过程眼见为实。4. 现实中的反例委派为什么会被打破4.1 SPI 场景下的上下文加载器我一直觉得理解双亲委派最好的方式是去看它被打破的地方。最典型的就是 JDK 的 SPI服务提供者接口机制比如 JDBC。JDBC 的规范定义在 JDK 里比如java.sql.Driver按双亲委派应该由 Bootstrap 加载。但驱动的实现类比如某个具体的数据库驱动com.xxx.DriverImpl这个类被打包成 jar 放在业务依赖里Bootstrap 根本不知道它的存在。如果严格按照“先父后子”的流程连接数据库时去DriverManager里找驱动实现会发现压根找不到。于是 JDK 提供了一个“后门”Thread.currentThread().getContextClassLoader()。当 Bootstrap 加载的代码需要往下查找业务方的类时就通过线程上下文加载器绕回到应用类加载器让“底层”能反向看见“上层”提供的类。// DriverManager 内部实际模式的简化示意 ClassLoader contextCL Thread.currentThread().getContextClassLoader(); ServiceLoaderDriver drivers ServiceLoader.load(Driver.class, contextCL);这算是对双亲委派的一次有意识偏离。委派方向本来是“上到顶”SPI 却允许“往下找”目的只有一个让 JDK 内的规范代码可以加载 JDK 外部的实现类。这种机制成功后后来大量 SPI 框架都在借鉴。4.2 容器热部署为什么必须反着来另一个打破委派的经典场景是 Web 容器。我遇到过不少同学以为容器也是严格按双亲委派来的实际情况正相反。正常情况下部署在同一个容器里的多个应用如果依赖了同一个第三方的不同版本这些 jar 又都在 classpath 上按照双亲委派应用类加载器会优先加载最早出现的那份另一个应用再想用别的版本轻则行为异常重则直接冲突。所以主流容器并不让所有应用共享同一个类加载器而是给每个应用单独建一个 WebAppClassLoader负责加载对应应用目录下的类并且它把loadClass的顺序改成了“先自己、再父级”——自己和父级找不到才会继续向上委派。这就是“逆委派”或“破环委派”本质上是拿标准规则换扩展性。类是否会重复加载这个问题在容器场景里变成了可控状态每个应用自己管自己互不干扰。热部署时直接扔旧加载器、起新加载器就能把整个应用的类全部“替换掉”。注意不是所有场景都适合打破委派。如果你只是做一个普通的 SDK强行打破会很危险因为你会同时失去安全保护和类的唯一性保障。先想清楚问题到底是什么再决定要不要动loadClass。5. 类加载问题排查实录5.1 别再混淆两个 NoClass 异常做后端的人几乎都遇到过这两个异常但它们含义完全不同排查方向也完全相反。ClassNotFoundException 是加载器在查找类的过程中findClass找不到这个类的定义属于“这个类不存在于我能看到的范围内”。常见原因就是缺少 jar 包、打包时没包含进去、或者用了错误的前缀去Class.forName动态加载。NoClassDefFoundError 则意味着类定义之前存在过、也加载成功过但后续阶段出问题了最常见的就是类初始化时抛了异常或者引用它的类被加载而它依赖的类又突然缺失。我排查过好几次最后都发现是某个静态块里抛了运行时异常或者编译期存在但运行时被打包工具优化掉了。异常类型含义常见触发原因优先排查方向ClassNotFoundException找不到类定义缺 jar、依赖范围错误检查 classpath、构建产物NoClassDefFoundError类加载或初始化失败静态代码抛错、依赖缺失看完整堆栈、初始化异常5.2 同包同类不同加载器导致的诡异问题比找不到类更隐蔽的是类明明存在、报错却异常奇怪。有一次排查一个项目里偶发的 ClassCastException报错信息是com.example.Account cannot be cast to com.example.Account。看起来像同一个类为什么不能转换问题根源正是类加载器体系。Account.class.getClassLoader()在上下两处输出不同一个由 AppClassLoader 加载一个由某个插件自定义加载器加载。二者类名完全一致但 JVM 判定它们是两个类自然强转失败。这种问题光看报错信息很难定位最快的方法是干脆把两边的加载器打印出来。if (obj instanceof Account) { // ... } // 更准确地判定时需要用加载器相同的 Class 对象 if (obj.getClass() Account.class) { // ... }经验是一看到“同一个类名无法 cast”的诡异现象第一反应就去看两侧加载器链路尤其检查是不是有自定义加载器打破了委派或者 SPI 上下文加载器把类拉到了另一个加载器底下。5.3 快速确定某个类到底由谁加载排查时我最常用的手段就三个。第一-verbose:class启动参数能打印每个类的加载来源第二Class.forName(name, true, classLoader)显式指定加载器确认在特定加载器视角下能不能找到类第三直接写一行探针代码打印clazz.getClassLoader()结合clazz.getProtectionDomain().getCodeSource()还可以看到类的来源 jar 路径。java -verbose:class -cp . Hello输出里形如[Loaded com.example.Hello from file:/.../Hello.class]的每一行都是历史的加载记录。真到线上环境不方便加参数就去 dump 线程看当前线程的上下文类加载器是什么很多“找不到类”的问题其实都是上下文加载器不对导致的。最后说点自己的体会倒腾类加载这些年我的感受是双亲委派不是一个需要死记硬背的面试知识点而是一把理解 JVM 运行时的钥匙。你越能清楚地讲出“谁先尝试、谁兜底、类又被谁看到”就越容易解释那些看起来毫无规律的类冲突、NoClassDefFoundError 和诡异的 cast 问题。如果真想彻底掌控它建议按文章里的探针代码自己跑一遍再试着写一个不破坏委派的自定义加载器和一个故意破坏的版本做对比。纸上得来终觉浅你亲手把它跑出来、再亲手排掉几个错这个机制才算真正长在你手里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑