
生产环境半夜报警NoClassDefFoundError刷屏你第一反应是不是去检查 jar 包有没有打全老实说我踩过这个坑之后才发现有一半的概率问题不在 jar 包而在 JVM 那套默认的类加载机制上。类加载器在 JVM 里不是冷门概念它是每个类身份的一部分同一个类被不同的加载器加载在运行时就是两个完全不同的类型。搞懂 JVM 自带的这几个加载器你排查类冲突、jar 重复、SPI 服务加载问题的时候思路会清晰很多。这篇文章不打算只贴概念我会带上源码、实测命令、打印输出和几个真实场景的问题复盘把 JDK 里默认预设的类加载器逐个拆开看一遍。JDK 版本不同这套体系是有变化的我会把 JDK 8 和 JDK 9 的差异一起讲清楚毕竟现在很多线上项目还在 JDK 8但新项目已经在用更高版本了。1. JVM 预设的核心加载器体系1.1 三个加载器分别是谁打开 JDK 源码找到java.lang.ClassLoader顺着它的子类往下扒能看到一套固定的层级结构。JDK 9 之后这套结构在内部类jdk.internal.loader.ClassLoaders里定义对外暴露的形态就是三个加载器。第一个是启动类加载器也就是 Bootstrap ClassLoader。它负责加载 JVM 最基础的运行环境比如java.lang.String、java.util.ArrayList这些根正苗红的 JDK 类。在 HotSpot 虚拟机里这个加载器是用 C 写的不对 Java 层暴露实际对象所以在 Java 代码里调用String.class.getClassLoader()得到的是null这个 null 不是“没有加载器”而是“加载器是 Bootstrap你用 Java 拿不到它的引用”。第二个是平台类加载器Platform ClassLoader。JDK 8 时代它叫扩展类加载器对应ExtClassLoader负责加载jre/lib/ext目录下的扩展包。JDK 9 模块化之后它被重命名为 Platform ClassLoader内部实现变成了ClassLoaders$PlatformClassLoader职责也从“加载 ext 目录”变成了“加载 JDK 平台模块里的类”比如java.sql、java.xml这些非核心但又是 JDK 自带的模块。第三个是应用类加载器App ClassLoader内部类是ClassLoaders$AppClassLoader。它负责加载我们自己在classpath和模块路径上声明的类也就是你打出来的业务 jar、依赖的第三方 jar。ClassLoader.getSystemClassLoader()返回的就是它所以它也经常被叫作系统类加载器。实际项目里我们自己写的类绝大多数都是被它加载的。1.2 父子关系是怎么建立的这三个加载器之间不是继承关系而是一种组合关系每个ClassLoader实例里都有一个parent字段指向自己的父亲。默认情况下App 的 parent 指向 PlatformPlatform 的 parent 逻辑上是 Bootstrap但因为在 Java 层拿不到 Bootstrap 对象所以Platform.getClassLoader().getParent()返回的是 null。注意这里的“父子”是逻辑层级不是 Java 类继承。用getParent()拿到的是上一个加载器实例不是父类。JDK 9 里它们的层级关系可以用下面这张文本示意图来表示配合代码里实际打印出的名字就非常直观。Bootstrap ClassLoader (C实现Java层显示为null) ↑ Platform ClassLoader (jdk.internal.loader.ClassLoaders$PlatformClassLoader) ↑ App ClassLoader (jdk.internal.loader.ClassLoaders$AppClassLoader) ↑ 用户写的自定义类加载器可选在 JDK 8 里这个结构的中间层是ExtClassLoader包名是sun.misc.Launcher$ExtClassLoaderApp 加载器叫sun.misc.Launcher$AppClassLoader。很多老博客还在用这套名字如果你看的是新版本源码找不到ExtClassLoader不要奇怪它不是“没了”是换了个马甲叫 Platform ClassLoader并且加载范围也变了。1.3 三个加载器加载范围对照每个加载器都有一个自己专属的“势力范围”理解这个才能明白为什么某个类会被某个加载器捞走。JDK 9 的模块化路径相对复杂一点我整理了一张表方便对照。加载器实现语言加载范围Java 层对象Bootstrap ClassLoaderCJDK 基础模块java.base等JDK 8 对应rt.jarnullPlatform ClassLoaderJavaJDK 非核心平台模块JDK 8 对应jre/lib/extClassLoaders$PlatformClassLoaderApp ClassLoaderJavaclasspath上的类和模块路径上的用户模块ClassLoaders$AppClassLoader这里有个容易搞混的点JDK 8 的rt.jar里的类、JDK 9 的java.base模块里的类加载者都是 Bootstrap但 JDK 9 里 Bootstrap 的加载逻辑其实已经移到 Java 层实现了jdk.internal.loader.BootLoader只是对外依然表现为 null。这个细节不影响日常使用但看源码的时候别被绕晕。2. 双亲委派模型是怎么工作的2.1 loadClass 源码逐行拆解双亲委派是这套加载器体系的核心规则。它规定一个类加载器收到加载请求时不会自己先找而是先把这个请求丢给父加载器父加载器再丢给爷爷一直到 Bootstrap然后从上往下逐个尝试加载。看 JDK 里ClassLoader.loadClass(String name, boolean resolve)的简化版源码逻辑非常清晰。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. 如果没有父加载器说明当前是 Platform/Ext逻辑父加载器是 Bootstrap c findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 父加载器找不到不处理继续往下 } if (c null) { // 4. 父加载器找不到才轮到自己调用 findClass 去找 c findClass(name); } } if (resolve) { resolveClass(c); } return c; } }这段代码里有几个关键点。首先findLoadedClass(name)查的是“当前加载器自己已经加载过的类”JVM 层维护了每个加载器自己的类索引表同一个类被多个加载器加载时它们各自记各自的互不干扰。其次findBootstrapClassOrNull(name)是去 Bootstrap 的加载范围里确认有没有这个类。整个流程下来一个类的加载顺序永远是从上往下尝试子加载器只是兜底。2.2 为什么非要这么设计双亲委派模型不是 JVM 规范强制的而是 JDK 给ClassLoader的默认实现但为什么默认是它核心原因是安全。想象一下如果没有双亲委派我自己写一个完全限定名是java.lang.String的类放到 classpath 里App 加载器直接加载它那String类就不只一份了JVM 里到处都在用的String可能被一个恶意实现劫持基础类型的行为变得不可控。有了双亲委派任何加载java.lang.String的请求都会先被 Bootstrap 拿到它发现java.base模块里已经有这个类了直接返回你写的那个冒牌货根本没有被加载的机会。第二个原因是保证类身份的一致性。JVM 判断两个 Class 对象是不是同一个类型要同时看“类的全限定名 定义类加载器”。双亲委派保证了越基础的类越往上走同一个类在任意一个加载器看来都是同一个 Class 对象不会出现两个加载器各加载一份导致类型互不兼容的问题。第三点模块化之后这个模型依然成立。JDK 9 只是在loadClass流程里加入了模块判断它会在父加载器加载前检查该类的包名是否属于某个模块、模块是否可读但整个委派方向没变。2.3 双亲委派有没有被打破的场景有而且很常见。最典型的就是 JDBC 这种 SPIService Provider Interface机制。java.sql.DriverManager是java.base模块里的类由 Bootstrap 加载。但它要用的 MySQL、PostgreSQL 驱动实现在业务 jar 里Bootstrap 根本看不到。如果严格按照双亲委派驱动永远加载不了。JDK 的解法是引入线程上下文类加载器Thread Context ClassLoader。DriverManager在初始化时会读取Thread.currentThread().getContextClassLoader()拿到当前线程的类加载器通常就是 App ClassLoader然后让这个加载器去加载驱动实现类。父加载器反过来借用子加载器的能力这就是对双亲委派的一种有意识打破。后面排查 SPI 相关问题时这个点会反复遇到。3. 实测亲眼看看每个加载器在干什么3.1 打印加载器层级关系纸上谈兵没意思直接跑一段代码看结果。下面的代码非常简单就是打印当前类和 JDK 核心类的加载器。public class ClassLoaderDemo { public static void main(String[] args) { // 当前类由谁加载 ClassLoader current ClassLoaderDemo.class.getClassLoader(); System.out.println(当前类加载器: current); // 一路向上找父加载器 while (current ! null) { System.out.println(父加载器: current); current current.getParent(); } // JDK 核心类 System.out.println(String 加载器: String.class.getClassLoader()); System.out.println(ArrayList 加载器: ArrayList.class.getClassLoader()); // 系统类加载器就是 App ClassLoader System.out.println(系统类加载器: ClassLoader.getSystemClassLoader()); // 当前线程上下文类加载器 System.out.println(线程上下文类加载器: Thread.currentThread().getContextClassLoader()); } }在 JDK 17 环境下输出大致是这样当前类加载器: jdk.internal.loader.ClassLoaders$AppClassLoader512ddf17 父加载器: jdk.internal.loader.ClassLoaders$PlatformClassLoader2c7b84de 父加载器: null String 加载器: null ArrayList 加载器: null 系统类加载器: jdk.internal.loader.ClassLoaders$AppClassLoader512ddf17 线程上下文类加载器: jdk.internal.loader.ClassLoaders$AppClassLoader512ddf17注意看两个细节String和ArrayList的加载器是 null这就是 Bootstrap当前类和系统类加载器是同一个对象说明在普通 Java 程序里默认上下文加载器就是 App 加载器。这个输出可以作为你排查问题的基准如果你的项目里Thread.currentThread().getContextClassLoader()打印出来的不是 AppClassLoader那大概率是跑在 Web 容器或是某个框架自定义了上下文加载器。3.2 用 -verbose:class 看加载日志代码里写打印只是静态观察真正遇到问题时要看 JVM 启动和运行阶段每个类是从哪里被加载的。JVM 提供了一个开关-verbose:class。java -verbose:class -cp . ClassLoaderDemo跑起来之后控制台会输出类似这样的信息[0.023s][info][class,load] java.lang.Object source: jrt:/java.base [0.023s][info][class,load] java.io.Serializable source: jrt:/java.base [0.024s][info][class,load] java.lang.String source: jrt:/java.base [1.102s][info][class,load] ClassLoaderDemo source: file:/Users/dev/classes/每一行都包含类名和来源。jrt:/java.base表示这个类来自 JDK 模块由 Bootstrap 负责file:/...这类路径表示来自 classpath由 App 加载器负责。这个参数在生产排查里价值很高不用改代码重启时临时加一下就能看到每个类的真实来源。JDK 8 下输出格式略不同来源会是rt.jar、ext路径或file:/...。无论哪个版本关注点都一样你怀疑“类加载冲突”的时候先看同一个类是不是出现了两次加载记录来源是否相同。3.3 一个类的身份到底由什么决定很多人不理解“同一个类被不同加载器加载会怎样”我用一个直白的类比解释。你在公司有一个同名同事如果你们在不同部门工牌上的“部门”字段就不同即使名字完全一样HR 系统里也是两个记录。JVM 里的类身份就是这个逻辑类的唯一性由“全限定名 定义类加载器”组合决定。所以当你看到ClassCastException报错信息写着某个类无法转换成另一个类但明明它们是同一个类的样子最典型的原因就是同一个类被两个不同的加载器各加载了一次两个 Class 对象互相不认。这种情况在应用里出现通常不是 JVM 自带三个加载器之间发生而是你手动用了URLClassLoader或者 Web 容器自带的类加载器重复加载了同一个 jar。4. 由类加载器引发的常见问题与排查4.1 ClassNotFoundException 和 NoClassDefFoundError 的区别这两个异常长得像实际含义完全不同。ClassNotFoundException是运行时主动调用Class.forName()、loadClass()时找不到类抛出的属于受检异常你代码里能捕获到NoClassDefFoundError是 JVM 在字节码层面引用了一个类但在链接阶段找不到属于Error程序根本没法正常捕获。前者是“我主动找没找到”后者是“字节码里写着要用结果类没了”。遇到过最典型的场景一个类在编译期存在但运行时的 classpath 里没有对应 jar或者 jar 版本不对它引用的某个类在更小版本的包里没有。排查第一步永远是确认 classpath 里的 jar 是否完整。加了-verbose:class之后看崩溃前最后加载失败的类名基本一抓一个准。4.2 同一个类的两份实例我在一个模拟项目里遇到过很奇怪的问题同一个 jar 包被同时放进了 classpath 和某个自定义扩展目录代码中用instanceof判断结果始终是 false赋值时抛ClassCastException。查了半天才发现这个 jar 被 App 加载器和自定义的URLClassLoader各加载了一次两边拿到的Class对象完全不是同一个。遇到这种情况第一件事是检查类加载器层级用前面那段打印代码看看项目中各个类分别被谁加载。如果确实存在多个加载器再看有没有办法让它们共用同一个加载器或者移除重复的 jar。在 Spring Boot 这种用 fat jar 的项目里如果搭了一些动态加载插件的机制特别容易出现这种问题。4.3 SPI 加载类失败的深层原因用 JDBC 驱动的时候很多人遇到过No suitable driver found。常规检查是看Class.forName(com.mysql.cj.jdbc.Driver)有没有执行但如果你用的是DriverManager自动发现机制问题可能出在线程上下文类加载器上。SPI 机制的加载链是这样的DriverManager由 Bootstrap 加载但它要通过线程上下文类加载器去加载驱动实现类。如果某些框架启动时把线程上下文类加载器换成了别的加载器而那个加载器看不到驱动 jar驱动自然就发现不了。排查时可以打印Thread.currentThread().getContextClassLoader()再手动contextClassLoader.loadClass(com.mysql.cj.jdbc.Driver)看能不能找到类。这个验证方法在很多 SPI 问题上都通用。4.4 排查工具和速查表除了前文说到的-verbose:class排查类加载问题还有几个顺手工具。JDK 自带的jcmd可以看类直方图和类加载统计比如jcmd pid VM.class_hierarchy能输出类和加载器的关系。如果项目里接入了 Arthas那更直接classloader命令能把所有加载器的层级、加载的 jar 列得清清楚楚还能直接dump某个加载器加载的类。平时遇到类相关异常我习惯按下面这张表的思路来定位。异常现象常见触发原因优先排查方向ClassNotFoundExceptionClass.forName路径不存在或依赖缺失-verbose:class看崩溃前最后加载的类NoClassDefFoundError编译期存在、运行期缺失典型是 jar 版本不一致对比编译 classpath 与运行 classpathClassCastException且看起来是同一个类同一个类被多个加载器加载检查是否有自定义加载器或容器类加载器重复加载SPI 驱动找不到线程上下文类加载器不正确打印上下文加载器手动loadClass验证LinkageError同一个包名类在不同加载器间交叉依赖排查依赖 jar 和加载器隔离配置4.5 处理类加载冲突的几板斧真到实操层面解决冲突的手段就那么几个。最直接的是把多余的重复 jar 从 classpath 里去掉这是根治。如果是依赖传递导致的冲突用构建工具的依赖排除功能把旧版本排除掉。如果冲突是因为自定义类加载器比如在插件系统里优先让插件之间的公共依赖由父加载器统一加载插件自己的实现再走子加载器。还有一个容易忽略的点不要在代码里写死绝对路径去 newURLClassLoader加载外部 jar除非你能保证这个 jar 不会和当前项目的类发生交叉引用。以前接手过一个用自定义类加载器动态加载模板类的项目模板 jar 里的类引用了项目内部的公共类结果模板每次重新加载都导致那批公共类被重复加载接口适配器全部失效最后改成了把公共依赖统一提升到父加载器才消停。5. 实操心得这个内容看到最后最想提醒你的是类加载器不是 Java 学习的边角料它是 JVM 运行时的地基。我后来养成了一个习惯只要遇到类相关异常先不急着翻 jar 包先跑一遍-verbose:class看两行日志再动手。省下来的排查时间往往是按天算的。另外提一下 JDK 版本敏感性问题。现在很多博客还在用 JDK 8 的ExtClassLoader术语如果你在 JDK 9 的源码里找不到它别怀疑自己看错它被 Platform ClassLoader 取代了。技术方案升级的时候把这种底层的命名变化同步更新到团队的知识库里能避免不少新人排查问题时的困惑。这套自带的类加载器体系理解透了再去看容器、中间件的类加载器定制会轻松很多。