Java类加载器与双亲委派模型:从原理到线上排错实战

发布时间:2026/9/10 18:24:23
Java类加载器与双亲委派模型:从原理到线上排错实战 Java 类加载器这个东西绝大多数人第一次接触它都是在面试八股文里。背完双亲委派、逐级向上、防止重复加载这几句话考试应付过去了回到项目中照样被 ClassNotFoundException 和 ClassCastException 折磨得头皮发麻。直到我在线上环境排查一个诡异的类型转换问题抓破了脑袋才发现问题根源根本不是代码逻辑而是类被不同的类加载器各加载了一遍。从那天起我彻底意识到类加载器不是面试官拿来为难人的冷门知识点它是定位很多线上疑难杂症的钥匙。这篇内容我打算把类加载器的角色、JVM 内置的加载器层级、双亲委派模型的工作机制、它保护的到底是什么以及实际开发中最容易踩坑的委派被破坏场景完整地梳理一遍。内容会偏向工程实践面试的角度我也会带上但更希望能帮你在真实项目中遇到类加载相关错误时有一套清晰的排查思路。1. 类加载器在 JVM 里到底扮演什么角色1.1 类加载器不只是读取字节码这么简单我们在 IDE 里写好的 .java 文件经过 javac 编译会生成 .class 字节码文件。JVM 启动之后不可能把磁盘上所有 class 文件一股脑全读进内存它必须按需加载。谁来做这件事就是类加载器ClassLoader。但类加载器的职责比读取文件要重得多。JVM 规范里对类加载阶段有明确定义通过一个类的全限定名来获取描述此类的二进制字节流然后把这个字节流所代表的静态存储结构转化为方法区的运行时数据结构最后在堆内存中生成一个代表这个类的 java.lang.Class 对象作为方法区这个类的各种数据的访问入口。你可以把 JVM 理解成一个大型工厂类加载器就是原料采购和质检部门。原料就是 .class 字节流而最终产出的 Class 对象就是车间堆内存里可以被随时调用的一条生产线。类是模板对象是实例而类加载器决定了模板本身从哪来、怎么来、能用几次。1.2 类是什么时候被加载的有人以为 JVM 一启动就会加载所有类这是误解。类的加载是懒加载JVM 规范并没有强制约束加载时机但只有遇到以下几种主动使用情况时才必然触发类加载使用 new 关键字创建类的实例访问类的静态字段final 常量除外调用类的静态方法使用反射机制Class.forName 等访问类初始化一个类的子类时会先触发父类的加载与初始化JVM 启动时包含 main 方法的那个类我在项目中见过不少因为类加载时机误解导致的诡异问题。举个例子一个类里有个静态方法做了很重的初始化但你的代码只是把另一个类作为参数传过去并没有触发它的加载结果某些环境下报 NoClassDefFoundError有些环境又正常。本质是类的加载时机不一样。所以排查类加载问题第一步永远要问这段代码在哪一行第一次触发了类的加载1.3 同一个类能被不同加载器加载成不同的类这是双亲委派模型存在的最重要前提之一也是最多人忽略的一点。判断两个类是否同一个类不仅仅看全限定名是否完全一致还必须要求它们是由同一个类加载器加载的。换句话说com.example.demo.User 这个类由 AppClassLoader 加载和由自定义的 MyClassLoader 加载在 JVM 内部它们是两个完全独立的 Class 对象彼此之间做 instanceof 判断会返回 false强转会抛 ClassCastException。我在第 7 部分会写一个实测案例这里先立住这个概念。双亲委派的核心价值之一就是确保同一个全限定名的类在整个 JVM 中尽可能只被加载一次而且默认由同一套加载链路上的加载器去加载避免出现明明类名一模一样却不是同一个类的尴尬局面。2. 从 JDK 8 到 JDK 17五个内置加载器的层级关系2.1 启动类加载器 BootstrapJVM 自带的地基JVM 内置了多个类加载器其中最底层、最特殊的是启动类加载器Bootstrap ClassLoader。它的特点有三个不是 Java 类而是由 C/C 实现的嵌套在 JVM 内部。在 Java 代码里获取它的引用时你得到的是 null而不是一个 ClassLoader 对象。负责加载 JVM 自身运行所需的核心类库。在 JDK 8 及之前Bootstrap 负责加载 rt.jar 里的类也就是说Java 标准库里的 java.lang.String、java.util.HashMap、java.lang.Thread 这些核心类都是它加载的。在 JDK 9 引入模块化之后rt.jar 被拆分为 java.base 等模块Bootstrap 负责加载 JDK 内部模块尤其是 java.base 模块。这里有个很容易踩的误区在代码里打印某个核心类的类加载器得到的是 null。很多新人以为这是 bug实际这是正常的表示该类由 Bootstrap 加载而 Bootstrap 无法用 Java 对象表达。2.2 扩展类加载器与平台类加载器的变迁在 JDK 8 里Bootstrap 的下面是扩展类加载器Extension ClassLoader负责加载 JRE 的 lib/ext 目录下的类或者系统变量 java.ext.dirs 指定路径下的类。它的实现类是 sun.misc.Launcher$ExtClassLoader。JDK 9 模块化之后扩展类加载器的名字改成了平台类加载器Platform ClassLoader职责也调整为加载一些非 java.base 模块比如 java.sql、java.xml 等。实现类是 jdk.internal.loader.ClassLoaders$PlatformClassLoader。虽然名字变了但它在委派层级里的位置没变依然是 Bootstrap 的子加载器、应用类加载器的父加载器。很多人面试时还在背扩展类加载器如果你答的是 JDK 8 之前的概念至少要补一句JDK 9 后改名平台类加载器。这一句话能体现出你对版本演进的敏感度而不是单纯背题库。2.3 应用类加载器与自定义加载器的默认父子关系应用类加载器Application ClassLoader也叫系统类加载器System ClassLoader是 JVM 内置加载器中层级最低、日常接触最多的那个。它负责加载 classpathJDK 9 之后是模块路径加 classpath下的所有类和 jar 包。你在项目里写的 controller、service、mapper绝大多数都是它加载的。应用类加载器的父加载器是平台类加载器。它的实现类是 sun.misc.Launcher$AppClassLoader。可以通过 ClassLoader.getSystemClassLoader() 获取到它。除了这三个内置加载器JVM 还允许我们通过继承 ClassLoader 来定义自定义类加载器。自定义类加载器的父加载器默认是应用类加载器除非你在构造时显式指定其他加载器。我们所说的类加载器层级就是指这些加载器按照父子关系组成的一条链。2.4 用一段代码把类加载器关系打印出来理论说再多不如自己跑一段代码。我建议你在本地新建一个最简单的 main 方法打印当前类的加载器以及它的父加载器public class ClassLoaderHierarchy { public static void main(String[] args) { // 当前类的类加载器 ClassLoader cl ClassLoaderHierarchy.class.getClassLoader(); System.out.println(当前类加载器: cl); // 递归打印父加载器 while (cl ! null) { cl cl.getParent(); System.out.println(父加载器: cl); } // 核心类由 Bootstrap 加载打印出来是 null System.out.println(String 的类加载器: String.class.getClassLoader()); System.out.println(系统类加载器: ClassLoader.getSystemClassLoader()); } }在 JDK 8 环境跑输出大概是这样的当前类加载器: sun.misc.Launcher$AppClassLoaderxxxx 父加载器: sun.misc.Launcher$ExtClassLoaderxxxx 父加载器: null String 的类加载器: null 系统类加载器: sun.misc.Launcher$AppClassLoaderxxxx在 JDK 11/17 环境跑输出则是当前类加载器: jdk.internal.loader.ClassLoaders$AppClassLoaderxxxx 父加载器: jdk.internal.loader.ClassLoaders$PlatformClassLoaderxxxx 父加载器: null String 的类加载器: null 系统类加载器: jdk.internal.loader.ClassLoaders$AppClassLoaderxxxx这个实验很有用它能直观展示扩展类加载器到平台类加载器的变化也能帮你确认你的项目运行在哪个 JDK 版本下加载链路的父加载器到底是谁。3. 双亲委派的工作流程与 loadClass 源码拆解3.1 loadClass 方法才是委派的核心入口双亲委派模型并不是 JVM 层面的硬性机制而是 Java 类加载器设计上的一种约定。它的核心实现逻辑全部集中在了 java.lang.ClassLoader#loadClass 这个方法里。我们先看简化版的源码思路再把重点归类protected Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 1. 先检查自己是否已经加载过这个类 Class? c findLoadedClass(name); if (c null) { try { // 2. 如果自己有父加载器就先委派给父加载器 if (parent ! null) { c parent.loadClass(name, false); } else { // 3. 如果没有父加载器说明自己是 Bootstrap 的下层 // 直接委派给 Bootstrap通过 null 代表 c findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 父加载器无法完成加载忽略这个异常继续往下走 } if (c null) { // 4. 父加载器加载不了才自己通过 findClass 去加载 c findClass(name); } } if (resolve) { resolveClass(c); } return c; } }这一小段代码是整个双亲委派模型的灵魂。看懂它你就理解了为什么叫双亲委派。3.2 一次完整的委派请求路径以 com.example.demo.Hello 为例假设你的 classpath 下有一个 com.example.demo.Hello 类第一次被使用时会触发加载请求是发给应用类加载器的。应用类加载器的 loadClass 执行链路如下AppClassLoader 调用 findLoadedClass(com.example.demo.Hello)检查自己缓存里有没有加载过这个类没有。AppClassLoader 的 parent 是 PlatformClassLoader于是调用 PlatformClassLoader.loadClass。PlatformClassLoader 也先查自己缓存没有它的 parent 是 Bootstrap在 Java 代码里 parent 为 null于是委托给 Bootstrap。Bootstrap 检查自己的命名空间发现 com.example.demo.Hello 不在核心类库中加载失败。异常被 PlatformClassLoader 捕获PlatformClassLoader 自己通过 findClass 去加载classpath 里根本没有这个类的入口所以也失败。异常被 AppClassLoader 捕获AppClassLoader 自己通过 findClass 去加载。它在自己的搜索路径classpath里找到了 com/example/demo/Hello.class加载成功。注意如果这个类是 java.lang.String情况就完全不同了。请求同样委派到 Bootstrap 后Bootstrap 直接返回已经加载好的 String 类整个链路到此结束AppClassLoader 根本不会有机会去加载自定义路径下的 String 类。这就是双亲委派能防止核心类被篡改的根本原因。3.3 被误读的双亲是父加载器不是父类很多初学者第一次看到双亲这个词以为是两个父亲其实这是翻译上造成的误解。英文原文是 parent delegation model这里的 parent 意思是父加载器不是父类也不是两个父亲。每个类加载器只有一个父加载器Bootstrap 除外它没有父加载器所以整个结构是一条单链条不是二叉树。Bootstrap - Platform/Extension - Application - 自定义加载器这条链上每个节点只有一个父亲。双亲其实是父辈们的拟人化说法强调的是委派关系是自底向上层层传递的而不是某个类加载器真的有两位父加载器。面试时能把这一点讲清楚会加分不少。4. 双亲委派为什么能立得住三个核心价值4.1 价值一阻止核心类被篡改JVM 的安全性很大程度上建立在核心类必须可靠这个前提之上。试想你可以在 classpath 里放一个自己写的 java.lang.String里面塞一段恶意代码如果 JVM 不管不顾地加载了它整个应用就彻底失控了。双亲委派机制天然解决了这个问题。当你试图加载 java.lang.String 时应用类加载器会把请求一层层向上委派最终由 Bootstrap 加载真正的 JDK 核心类。你自己写的那个 String 类永远不会被加载。这里还要补充一个 JVM 的兜底保护机制在 HotSpot 虚拟机中即使你通过自定义类加载器重写 loadClass 强制去加载 java.lang.String若类名以 java. 开头且类加载器不是 BootstrapJVM 会抛出 SecurityException。所以哪怕你铁了心要破坏双亲委派核心包名这条路也是封死的。4.2 价值二保证类的全局唯一性前面提到同一个全限定名的类如果被不同类加载器加载JVM 会认为它们是两个类。如果不用双亲委派而是每个类加载器各加载各的那么同一个类可能会出现多份副本互相之间无法做类型判断和强转。双亲委派模型规定加载请求优先发给父加载器。这意味着只要父加载器能够加载这个类那么这个类在整个 JVM 中只会存在一份由父加载器持有。子加载器的任务只是捡漏负责加载父加载器覆盖不到的那些类。这套机制保证了在默认情况下同一个类在全 JVM 范围内是全局唯一的。你的项目里到处传递 User 对象、到处做 instanceOf 判断都能正常工作就是因为它背后有一个稳定的类加载链路。4.3 价值三层次化加载的灵活扩展双亲委派并没有把子加载器锁死。子加载器可以通过重写 findClass 方法来自定义加载字节流的来源比如从数据库读、从加密文件读、从网络远程拉取等等。只要父加载器加载不了子加载器就有机会发挥。这种先问爸爸爸爸不行自己上的策略既保证了核心类稳定又给了业务层足够的灵活性。比如很多框架会自己实现类加载器来加载插件类、实现热部署同时又不影响 JDK 核心类和其他公共依赖。层次化结构让隔离和共享可以并存这是单层类加载器无法做到的。5. 双亲委派是怎么被打破的三个经典场景5.1 JDBC 驱动加载用的线程上下文类加载器如果说双亲委派是自底向上委派那 Java 的 SPI 机制Service Provider Interface就是典型的自顶向下逆向加载。JDBC 是最经典的案例。JDBC 的核心接口 java.sql.Driver、DriverManager 定义在 JDK 中由 Bootstrap 加载。而具体的驱动实现比如 com.mysql.cj.jdbc.Driver在 MySQL 的 jar 包里位于 classpath由应用类加载器加载。问题来了DriverManager 是被 Bootstrap 加载的按照双亲委派模型它要加载 com.mysql.cj.jdbc.Driver会先委派给父加载器父加载器最终会找到 Bootstrap但 Bootstrap 根本不知道 classpath 里有什么加载失败。也就是说Bootstrap 加载的核心类反而加载不了它在下面的类。为了解决这个问题JDK 引入了线程上下文类加载器Thread Context ClassLoader。这个加载器可以通过 Thread.currentThread().getContextClassLoader() 获取默认是应用类加载器。DriverManager 在启动时会调用 ServiceLoader 加载驱动实现而 ServiceLoader 会使用线程上下文类加载器去加载实现类。这样一来父加载器借用了子加载器的能力完成了对子级路径类的加载。一句话总结双亲委派是子加载器请求父加载器而线程上下文类加载器是父加载器主动请求子加载器。这是最典型的破坏方式也常被叫做 SPI 加载机制。5.2 Tomcat 的 WebAppClassLoader 与反向委派如果你写过 Web 应用你其实每天都在和破坏了双亲委派的类加载器打交道只是你可能没意识到。传统的单体应用里所有依赖都堆在 classpath 上由 AppClassLoader 统一加载类冲突问题不明显。但一个 Tomcat 容器里通常会部署多个 Web 应用两个应用可能使用了同一个第三方库的不同版本。如果还坚持严格的双亲委派先加载的那个版本会被父加载器缓存下来后部署的应用即使带了新版本也用不上甚至因为 API 不兼容直接报错。Tomcat 的解决办法是给每个 Web 应用创建独立的 WebAppClassLoader。它的加载逻辑是反向委派先检查本地缓存尝试从当前 Web 应用的 WEB-INF/classes 和 WEB-INF/lib 里加载加载不到才委派给父加载器也就是说Web 应用自己的类优先加载这保证了一个应用内部的类完全隔离互不干扰。不过 Tomcat 依然保留了一个底线对于 java. 开头的核心类WebAppClassLoader 不会自己尝试而是直接委派给父加载器。毕竟任何 Web 应用都不应该有能力去加载自己的 java.lang.String。这种子加载器优先父加载器兜底的加载策略本质上就是对双亲委派模型的破坏只是破坏得很有节制。5.3 热部署新建类加载器替代旧的热部署是另一个必须打破双亲委派的场景。在 JVM 里一个类一旦被加载到内存想修改它通常是不可能的。哪怕是 IDE 重新编译类加载器缓存里的 Class 对象也不会自动替换。常见的热部署策略是每次版本更新时创建一个全新的自定义类加载器让它去加载新的 class 文件。旧的类加载器和它加载的类会随着没有引用后逐渐被回收。新的类加载器和旧的类加载器之间没有父子关系里面的同名类互不干扰。很多 RPC 框架、规则引擎、脚本引擎比如 Groovy 的 GroovyClassLoader都采用这种思路来实现动态加载和版本切换。这也是为什么说双亲委派虽然好但在高动态场景下必须灵活变通。5.4 打破双亲委派的技术细节与底线从代码层面讲打破双亲委派最直接的方式就是重写 loadClass 方法而不是重写 findClass。默认的 loadClass 实现里已经写死了先父后子的逻辑findClass 只是最后兜底的一个钩子。如果你只重写 findClass其实并没有破坏双亲委派你只是给父加载器加载不了的类提供了一个补充来源。这种写法非常推荐也是定制类加载器的正确姿势。如果你想真正破坏双亲委派就需要重写 loadClass把先父后子改成先子后父甚至完全不管父加载器自己直接加载。Tomcat 的 WebAppClassLoader 就是这种思路的典型代表。但永远记住底线java. 开头的核心包绝对不能通过自定义类加载器加载。JVM 的安全管理器会拦截这种操作。你可以在业务代码里破坏委派但不能让业务代码伪装成核心类。6. 自定义类加载器实战该重写 findClass 还是 loadClass6.1 标准写法重写 findClass绝大多数自定义类加载器的需求都是从某个特定目录或加密包中读取 .class 字节流。这时候正确的做法是继承 ClassLoader只需要重写 findClass 方法。下面是一个从指定路径加载 class 文件的最简实现public class FileClassLoader extends ClassLoader { private final String baseDir; public FileClassLoader(String baseDir) { // 默认父加载器是 AppClassLoader this.baseDir baseDir; } Override protected Class? findClass(String name) throws ClassNotFoundException { String path baseDir / name.replace(., /) .class; try { byte[] bytes readBytes(path); // defineClass 是 ClassLoader 的关键方法将字节数组转为 Class 对象 return defineClass(name, bytes, 0, bytes.length); } catch (Exception e) { throw new ClassNotFoundException(Cannot load class: name, e); } } private byte[] readBytes(String path) throws IOException { try (InputStream in new FileInputStream(path)) { return in.readAllBytes(); } } }注意我没有重写 loadClass所以双亲委派模型依然生效。父加载器能加载的类比如 JDK 核心类、classpath 下的依赖依然会先由父加载器加载。只有父加载器加载不了的类才会走到这里通过自定义路径读取。这种写法的好处是安全、可控、不影响现有类加载机制。Spring、MyBatis 等框架内部定制类加载器时绝大多数都是扩展 findClass而不是重写整个 loadClass。6.2 破坏写法重写 loadClass如果你确实需要实现热部署或者自己控制类加载顺序可以重写 loadClass。以一个优先加载指定目录下的类找不到再委派给父加载器的加载器为例public class ReverseClassLoader extends ClassLoader { private final String baseDir; public ReverseClassLoader(String baseDir) { this.baseDir baseDir; } Override protected Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 1. 先查自己是否已经加载过 Class? c findLoadedClass(name); if (c null) { try { // 2. 先尝试从自定义路径加载 c findClass(name); } catch (ClassNotFoundException e) { // 自定义路径加载不到再走双亲委派 if (getParent() ! null) { c getParent().loadClass(name, false); } else { c findBootstrapClassOrNull(name); } } } if (c null) { throw new ClassNotFoundException(name); } if (resolve) { resolveClass(c); } return c; } } Override protected Class? findClass(String name) throws ClassNotFoundException { // 和上面的 FileClassLoader 的 findClass 类似略 // 注意这里要确保 java. 开头的类不在此路径加载 if (name.startsWith(java.)) { throw new ClassNotFoundException(Forbidden: name); } // ... return defineClass(name, bytes, 0, bytes.length); } }这种写法就是先子后父和双亲委派正好相反。但要我提醒一句不到万不得已不要这么干。因为它会引入类隔离复杂度和不可预期的类型判断问题。如果你只是想要热部署优先考虑成熟的框架而不是自己造轮子。6.3 一个验证同一个类在不同加载器中不是同一个类的实验最后分享一个可以自己在本地做的验证实验。它能让类唯一性这个概念从抽象变得具体。准备一个简单的类public class Demo { }然后写两个 FileClassLoader分别指向同一个目录各自加载 Demo 类FileClassLoader loader1 new FileClassLoader(/tmp/classes); FileClassLoader loader2 new FileClassLoader(/tmp/classes); Class? class1 loader1.loadClass(Demo); Class? class2 loader2.loadClass(Demo); System.out.println(class1 class2); // false System.out.println(class1.getClassLoader()); // FileClassLoader... System.out.println(class2.getClassLoader()); // FileClassLoader... Object obj1 class1.getDeclaredConstructor().newInstance(); Object obj2 class2.getDeclaredConstructor().newInstance(); // 强转会抛 ClassCastException Demo d (Demo) obj1; // 这个可以成功因为 obj1 的类就是 loader1 加载的 Demo e (Demo) obj2; // 这个会抛 ClassCastException最后一行的强转失败原因就在于 obj2 的 Demo 类和当前代码里引用的 Demo 类虽然全限定名完全一样但加载它们的类加载器不是同一个所以 JVM 认为它们是两个类。这个实验在很多诡异的线上问题里都能找到缩影也是我为什么反复强调类的唯一性一定要理解到位。7. 类加载问题排查链路从 ClassCastException 到 ClassLoader7.1 现象类名完全一致却互相转换失败我遇到过的这类问题表现几乎一样全局搜索代码类名和包名完全匹配错误日志里却出现 ClassCastException而且经常伴随着一大串长长的类加载器名字或者提示某个类是无法转换的对象。一般在以下场景中出现的概率比较高使用了热部署/热加载机制旧类和新类同时存在多个框架各自实现了类加载器导致同一个类在框架 A 的加载器和框架 B 的加载器中各有一份容器型应用Tomcat、Jetty部署多个模块模块间依赖同一个库的不同版本7.2 排查工具与命令排查类加载问题有几个工具和 JVM 参数非常实用-XX:TraceClassLoading打印每个类的加载信息包括加载它的类加载器。这个参数在定位类到底被谁加载了时特别好用。-verbose:class效果和上面类似。jcmd、jconsole、jvisualvm可以查看类加载器和已加载类的统计信息。代码打印在关键的类里打印 this.getClass().getClassLoader() 以及它的链路上所有父加载器。我建议你在本地复现问题时优先加 -XX:TraceClassLoading 参数。输出里每一行都是一个类的加载记录能看到类名、来源 jar 包和类加载器类型这比靠猜要快得多。排错时还可以写一个工具方法打印对象所属类的加载器public static void printClassLoader(Object obj) { Class? clazz obj.getClass(); ClassLoader loader clazz.getClassLoader(); System.out.println(Class: clazz.getName()); System.out.println(Loader: loader); while (loader ! null) { loader loader.getParent(); System.out.println(Parent Loader: loader); } }7.3 一个参考的排错流程结合我自己的排查经验遇到类加载相关异常可以按以下顺序依次推进先确认异常类型。ClassNotFoundException 通常表示 JVM 在整个加载链路上都没有找到对应的类可能是缺少依赖、依赖版本不匹配、或者父加载器加载的是旧版本NoClassDefFoundError 则表示类在编译期存在但运行时某个依赖类加载失败ClassCastException 则优先怀疑类被不同加载器加载。打印关键对象的类加载器确认是不是同一加载器。检查是否存在多个自定义类加载器或者容器隔离机制如 Tomcat 的多个 WebAppClassLoader。用 -XX:TraceClassLoading 观察加载顺序确定第一个加载该类的是谁。确认类冲突的来源优先统一依赖版本或者把公共类放到父加载器能加载的路径上。这套流程在多数情况下能定位问题。我自己处理过的一个典型 case是两个框架各自通过自定义加载器加载了同一个第三方库的类导致运行时类型转换失败。最终把该库的依赖统一提升到父加载器可见的公共 classpath 后问题就消失了。类加载器这块知识写代码时看不见摸不着但一旦线上出了诡异问题它往往是最后的真相。我自己的体会是不要只把它当面试八股背而是在项目里主动打印一次类加载器链路跑一次自定义加载器实验遇到一次真实的 ClassCastException。这三件事做完你才算真正把双亲委派模型内化了。以后再看到 JVM 报出的类加载相关错误你起码知道往哪个方向查而不是一头雾水。