生产环境半夜报警,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 指向 Platform,Platform 的 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$ExtClassLoader,App 加载器叫sun.misc.Launcher$AppClassLoader。很多老博客还在用这套名字,如果你看的是新版本源码找不到ExtClassLoader,不要奇怪,它不是“没了”,是换了个马甲叫 Platform ClassLoader,并且加载范围也变了。
1.3 三个加载器加载范围对照
每个加载器都有一个自己专属的“势力范围”,理解这个才能明白为什么某个类会被某个加载器捞走。JDK 9+ 的模块化路径相对复杂一点,我整理了一张表方便对照。
| 加载器 | 实现语言 | 加载范围 | Java 层对象 |
|---|---|---|---|
| Bootstrap ClassLoader | C++ | JDK 基础模块(java.base等),JDK 8 对应rt.jar | null |
| Platform ClassLoader | Java | JDK 非核心平台模块,JDK 8 对应jre/lib/ext | ClassLoaders$PlatformClassLoader |
| App ClassLoader | Java | classpath上的类和模块路径上的用户模块 | 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 这种 SPI(Service 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$AppClassLoader@512ddf17 父加载器: jdk.internal.loader.ClassLoaders$PlatformClassLoader@2c7b84de 父加载器: null String 加载器: null ArrayList 加载器: null 系统类加载器: jdk.internal.loader.ClassLoaders$AppClassLoader@512ddf17 线程上下文类加载器: jdk.internal.loader.ClassLoaders$AppClassLoader@512ddf17注意看两个细节: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某个加载器加载的类。
平时遇到类相关异常,我习惯按下面这张表的思路来定位。
| 异常现象 | 常见触发原因 | 优先排查方向 |
|---|---|---|
ClassNotFoundException | Class.forName路径不存在或依赖缺失 | -verbose:class看崩溃前最后加载的类 |
NoClassDefFoundError | 编译期存在、运行期缺失,典型是 jar 版本不一致 | 对比编译 classpath 与运行 classpath |
ClassCastException且看起来是同一个类 | 同一个类被多个加载器加载 | 检查是否有自定义加载器或容器类加载器重复加载 |
| 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 取代了。技术方案升级的时候,把这种底层的命名变化同步更新到团队的知识库里,能避免不少新人排查问题时的困惑。这套自带的类加载器体系理解透了,再去看容器、中间件的类加载器定制,会轻松很多。