大家好,我是晚安code。
假设一个场景排查一个 NoClassDefFoundError:日志里打出来的类名一模一样,代码里这个类也确确实实存在,编译能过、打包能过,一跑就炸。折腾半天才反应过来——同一个 class 文件被两个类加载器各加载了一次,在 JVM 眼里这俩根本不是一个类。
要解释清楚这件事,就得回到 JVM 类加载机制最核心的那套设计:双亲委派模型。今天把它从头拆一遍:生命周期七个阶段、四种类加载器、双亲委派的源码与优势、类初始化的触发时机,以及它到底是怎么被打破的。源码与 JVMS 条目基于 OpenJDK 25(当前 LTS),2026 年 10 月核对。
内容有点长,建议先点个收藏。
一、类加载生命周期:七个阶段里,真正容易踩坑的是「准备」和「解析」
类加载机制(Class Loading):JVM 把 .class 文件里的二进制字节流读进内存、校验合法性、转换成方法区中的运行时数据结构,并在堆里生成一个 java.lang.Class 对象的过程。你可以理解成「把一本书从仓库搬进图书馆,同时给它建一张唯一的索引卡」——索引卡就是那个 Class 对象,之后你对这本书的所有操作,都从这张卡开始。
类加载的完整生命周期有七个阶段,但真正会让人写出错误代码的只有两个:准备阶段和解析阶段——前者决定了静态变量什么时候有值,后者决定了你的程序什么时候会突然抛 NoClassDefFoundError。其余的看一眼知道怎么回事就够了。

先说加载(Loading)。这一步要做三件事:通过类的全限定名获取二进制字节流、把字节流代表的静态存储结构转成方法区的运行时数据结构、在堆里生成 Class 对象作为访问入口。
这里有个常被忽略的点:字节流的来源完全没有限制。不一定非得是文件——从网络、数据库、加密文件,甚至运行时现场生成都行。这也是自定义类加载器唯一的切入点(后面第五节会用上)。
然后是验证(Verification),确认字节码没有恶意、格式合法。分为文件格式验证(魔数是不是 0xCAFEBABE、版本号对不对)、元数据验证(有没有父类、有没有错误地继承 final 类)、字节码验证(最复杂的一步,做数据流和控制流分析)。
好,重点来了。
准备(Preparation)阶段,JVM 会为静态变量分配内存并赋零值。注意是零值,不是你在代码里写的那个值。
public class Prep {
public static int count = 123; // 准备阶段后 count = 0
public static Prep instance = new Prep(); // 准备阶段后 instance = null
public static final int LIMIT = 123; // 准备阶段后 LIMIT = 123
public static final int RANDOM = new Random().nextInt(); // 准备阶段后 RANDOM = 0
}
count 在准备阶段拿到的是 0,123 是等到 <clinit> 执行时才赋上去的。instance 同理,准备阶段它是 null。
唯一的例外是 LIMIT 这种:static final 修饰的基本类型或 String 字面量,编译期就在字节码里生成了 ConstantValue 属性,准备阶段会直接把真值写进去。但注意 RANDOM 这种编译期算不出来的常量不享受这个待遇,它还是得等 <clinit>。
这个区别在写「静态常量初始化顺序」相关的代码时非常致命,我见过不止一次因为在静态块里读了还没赋值的静态变量而拿到 0 的。
接着是解析(Resolution):把常量池里的符号引用替换成直接引用。符号引用是一组字面量,比如 com/example/Foo.bar,跟内存布局无关;直接引用则是能直接定位到目标的指针、偏移量或句柄。
这里有个反直觉的地方:JVMS 明确允许解析延迟到初始化之后才发生(规范原文用的是 "may be performed lazily")。这不是偷懒,是为了支持运行时动态绑定——多态、反射、动态代理都靠它。代价就是,NoSuchMethodError、NoClassDefFoundError 这类错误只会在运行到那一行的时候才爆出来,编译期拦不住。
最后是初始化(Initialization),执行编译器自动生成的 <clinit>() 方法——静态变量赋值和静态代码块会被按源码顺序收进去。JVM 保证这一步是加锁的,多线程下只会有一个线程执行,其他线程阻塞等待。执行完就进入使用,直到满足特定条件才会被卸载。
关于卸载有个很实用的结论:想让一个类被卸载,得同时满足「该类的所有实例都被回收」「加载它的 ClassLoader 被回收」「对应的 Class 对象没被引用」三个条件。而 Bootstrap 加载的类几乎永远卸载不了——因为它的加载器根本不会被回收。所以热部署必须自己写类加载器,这是第五节要讲的东西。
二、四种类加载器:父子关系到底是怎么串起来的
先给个判断:网上讲「四种类加载器」,默认说的都是 JDK 8 的版本;JDK 9 引入模块化之后扩展类加载器已经改名叫平台类加载器了,照老版本背会答错。
类加载器(ClassLoader):负责根据类的全限定名去外部介质读取二进制字节流,并把它转换成 JVM 内部 Class 对象的组件。它决定了「这个类从哪里来」,也决定了「这个类和哪个类算同一个类」——后半句比前半句重要得多。
| 加载器 | JDK 8 名称 | JDK 9+ 名称 | 负责加载什么 |
|---|---|---|---|
| 启动类加载器 | BootstrapClassLoader | 同名 | JAVA_HOME/lib 下的核心类库 |
| 扩展类加载器 | ExtClassLoader | PlatformClassLoader | JDK 8 扫 JAVA_HOME/lib/ext;JDK 9+ 改为加载平台模块 |
| 应用程序类加载器 | AppClassLoader | 同名 | classpath 上的类,也就是你自己写的代码 |
| 自定义类加载器 | —— | —— | 网络、加密文件、热部署等自定义来源 |

有一个特别容易记错的细节:「双亲」这个叫法其实有误导性,它是单亲。AppClassLoader 的 getParent() 返回的是 ExtClassLoader(JDK 9+ 是 PlatformClassLoader),但 ExtClassLoader.getParent() 返回的是 null——因为启动类加载器不是 Java 类,它是 HotSpot 里用 C++ 实现的,压根没有对应的 Java 对象。
所以 String.class.getClassLoader() 返回 null 不代表没有加载器,恰恰相反,它说明 String 是由启动类加载器加载的。这是个很经典的面试点。
至于拿到这些加载器实例:ClassLoader.getSystemClassLoader() 返回应用类加载器,JDK 9+ 可以用 ClassLoader.getPlatformClassLoader() 拿到平台类加载器。启动类加载器拿不到对象,只能拿到 null。
可能有人会问:那我自己写一个
java.lang.String扔进 classpath,能顶掉 JDK 自带的那份吗?不能,而且有两道坎。第一道是编译期,
javac会直接拒绝编译java.lang包下的类;第二道就算你用字节码工具硬造一个出来也没用——加载时走双亲委派,AppClassLoader会把请求一路委派给ExtClassLoader再到BootstrapClassLoader,启动类加载器从JAVA_HOME/lib里先加载到真正的String,你那份永远轮不上。顺带说个历史背景:JDK 1.2 之前是没有双亲委派的,当时就有人写恶意类加载器加载自定义的
java.lang.String来植入后门。双亲委派很大程度上就是为了堵这个洞才引入的。
三、双亲委派模型:十几行源码,撑起 JVM 的安全底座
双亲委派模型的全部秘密,就藏在 ClassLoader.loadClass(String, boolean) 这十几行里——读懂它,你对类加载的理解就完成一大半了。
双亲委派模型(Parent Delegation Model):类加载器收到加载请求时,先把请求委派给父加载器,父加载器再委派给它的父加载器,一路传到启动类加载器;只有当父加载器反馈「我加载不了」,子加载器才会自己去加载。你可以理解成找人办事时先层层上报,上级都办不了才自己动手。
看源码(这里是精简后的版本,去掉了埋点和部分异常处理):
protected Class<?> loadClass(String name, boolean resolve) {
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name); // ① 加载过就直接返回
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false); // ② 有父加载器,委派给它
} else {
c = findBootstrapClassOrNull(name); // ③ 自己是 Bootstrap,去启动路径找
}
} catch (ClassNotFoundException ignored) {
} // 父加载器找不到不算失败
if (c == null) {
c = findClass(name); // ④ 父都不行,才自己动手
}
}
if (resolve) resolveClass(c);
return c;
}
}
四步读下来就一句话:先查缓存,再问爹,爹不行自己上。这里唯一的扩展点是 findClass()——它是空的,等着子类实现。所以自定义类加载器只应该重写 findClass,而不是 loadClass。重写 loadClass 意味着「我要打破委派规则」,那是有明确目的的主动选择,不是随手改改。

那这套设计到底好在哪?常见的说法有三条,我逐条说一下,顺带纠正一个被讲烂的错误。
第一,安全。 核心类库统一由启动类加载器加载,应用程序无法用同名类冒充。上面那个 java.lang.String 的例子就是最直接的体现,这也是双亲委派诞生的最初动机。
第二,避免重复加载。 这一条我要唱个反调——这个说法其实不太准确。真正拦住重复加载的是 findLoadedClass 的缓存查询加上「父加载器优先」这两件事的组合。它保证的不是「全局不重复」,而是在同一个委派链构成的命名空间内,一个类名只对应一个 Class 对象。换个加载器链,同一个类名照样能被加载出第二份——而且那恰恰是自定义类加载器做热部署的原理。把这两个说法混为一谈,排查 NoClassDefFoundError 的时候会绕很大一圈。
第三,保证类唯一性。 这条其实是上一条的另一面,也是一个伏笔:双亲委派维持的是一个「树形」的加载结构,每个类只在自己那条链上有唯一标识。一旦打破委派,树就散了,类的唯一性也就保不住了——第五节我们会看到代价。
四、类初始化时机:6 种主动引用,4 种被动引用
这一节是面试重灾区,也是最能看出有没有真读过 JVMS 的地方。
主动引用(Active Use):JVMS 第 5.5 节规定的、必须立即触发类初始化动作的引用方式。规范里只列了 6 种,不在这个列表里的一切引用都是被动引用,都不会触发初始化。
先说这 6 种:
- 遇到
new、getstatic、putstatic、invokestatic这四条字节码指令时。翻译成人话就是:new一个对象、读写一个静态字段(编译期常量除外)、调用一个静态方法。 - 使用
java.lang.reflect包的方法对类进行反射调用。比如Class.forName("com.example.Foo")。 - 初始化一个类时,如果发现它的父类还没初始化,那就先初始化父类。
- 虚拟机启动时,用户指定的主类(就是含
main方法的那个)会被最先初始化。 - 使用
java.lang.invoke.MethodHandle时,如果解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial这四种方法句柄,也会触发初始化。 - 一个接口定义了
default方法(JDK 8 开始支持),如果这个接口的实现类被初始化了,那接口本身也要先被初始化。这是 JDK 8 给规范打的补丁。
接下来是被动引用,也就是不触发初始化的几种典型情况,重点看这张表:
| 代码写法 | 会初始化吗 | 原因 |
|---|---|---|
Sub.parentStaticField |
只会初始化 Parent,不会初始化 Sub |
静态字段只有直接定义它的那个类才会被初始化 |
new Sub[10] |
只会初始化 [LSub 这个数组类,不会初始化 Sub |
数组类由 JVM 直接生成,不经过类加载器 |
Foo.COMPILE_CONST(static final 基本类型或 String) |
不会初始化 Foo |
编译期已内联进调用方的常量池,压根没引用 Foo |
loader.loadClass("Foo") |
不会初始化 Foo |
只完成加载,不触发初始化 |
表格第一行值得单独拎出来说:通过子类去引用父类的静态字段,只会初始化父类,不会初始化子类。这个规则是 JVMS 明文写死的,很多人第一次看到会觉得反直觉——毕竟代码里写的是子类。但静态字段在字节码层面根本不属于子类,getstatic 指令解析到的就是父类,所以触发初始化的自然也是父类。
第三行则是各种「常量编译期优化」面试题的标准答案:static final 修饰的基本类型和 String 字面量,在编译阶段就已经被直接写进调用方的常量池了,运行时压根不会去访问定义常量的那个类,自然也就不会触发它的初始化。你要是把 static final 换成非 final 的静态字段,行为立刻就不一样了。
可能有人会问:
Class.forName和ClassLoader.loadClass到底差在哪?就差一个参数。
Class.forName(String)等价于Class.forName(name, true, 当前类加载器),那个true就是initialize,加载完立刻走链接和初始化,静态代码块会执行;而loadClass(String)只做加载(resolve默认是false),你拿到Class对象了,但静态代码块一行都不会跑。顺带一提,热部署框架替换类的时候用的就是
loadClass——它要的是新的Class定义,不是立刻把老代码的初始化逻辑再跑一遍。
五、打破双亲委派:三种真实场景,以及打破之后要付的代价
最后这一节,我们回到开头那个 NoClassDefFoundError。
先说结论:双亲委派从来不是 JVM 强制执行的规则,它只是 ClassLoader.loadClass 的一个默认实现。只要重写这个方法,你随时可以打破它。真正的问题不是「能不能破」,而是「什么时候非破不可」。
下面三种场景,都是「不破不行」。
1)SPI 与线程上下文类加载器:当爹需要儿子的东西
JDBC 是最好懂的例子。
DriverManager 这个类在 java.sql 包里,JDK 9 之前位于 rt.jar,由启动类加载器加载。而 MySQL 的驱动 com.mysql.cj.jdbc.Driver 在应用的 classpath 上,启动类加载器根本看不见它——它只扫 JAVA_HOME/lib。
问题就来了:父加载器加载的 DriverManager,需要调用子加载器才能看见的驱动实现类。委派方向是「子问父」,现在父需要反过来找子,双亲委派天然不支持。
线程上下文类加载器(Thread Context ClassLoader,TCCL)就是给这种情况开的后门:每个线程持有一个类加载器引用,默认继承父线程,主线程默认就是应用类加载器。父加载器拿不到的东西,通过它「反向」传下去。
// 用自定义加载器替换当前线程的上下文加载器,再走 SPI 加载实现类
Thread.currentThread().setContextClassLoader(myClassLoader);
ServiceLoader<MyService> services = ServiceLoader.load(MyService.class);
for (MyService s : services) {
s.run(); // myClassLoader 能看见、但父加载器看不见的实现类
}
ServiceLoader.load(Class) 内部用的就是当前线程的 TCCL。所以「打破双亲委派」的第一种方式,严格来说不是重写了谁,而是在委派链之外新开了一条路。
顺带解答一个日常疑问:JDK 6 之后你写 JDBC 代码已经不需要 Class.forName("com.mysql.cj.jdbc.Driver") 了,因为驱动注册改成了 SPI 自动发现,DriverManager 会去读 META-INF/services/java.sql.Driver。而这件事能成,靠的就是上面这套 TCCL 机制。
2)Tomcat 的 WebappClassLoader:自己优先,但保留两条红线
Tomcat 要解决的问题很具体:一个 Tomcat 里同时跑好几个 webapp,如果它们共用一个应用类加载器,那两个应用分别引用了同一个库的不同版本(比如一个用 Spring 4,一个用 Spring 5),就只能有一个生效——另一个必然出错。
Tomcat 的做法是给每个 webapp 一个独立的 WebappClassLoader,并且重写 loadClass,把顺序整个反过来:先在自己的 webapp 目录里找,找不到才委派给父加载器。这跟双亲委派正好相反。
但它保留了红线,有两类包仍然强制委派给父加载器:
java.*开头的类,也就是 JRE 核心类库javax.servlet.*这类容器相关的 API
原因很实在:如果应用能自己塞一份 Servlet API 进去,就可能版本不匹配把容器整个搞崩。所以「打破」从来不是全部推翻,而是精确地只放开需要放开的那部分。
顺便说下 Tomcat 的加载器层级:默认是 Bootstrap(JVM 的)→ System(应用的)→ Common → Webapp。如果配置了 server.loader 和 shared.loader,Common 还会再分出 Catalina 和 Shared 两层;不配的话 Common 一个人把这几个角色全兼了。
3)OSGi 的网状委派:结构上就已经不是树了
OSGi 把每个 Bundle 当成独立模块,每个 Bundle 一个类加载器。它的委派顺序是:
java.*开头的类 → 委派给父加载器- 类所在的包在自己的
Import-Package里声明过 → 委派给导出该包的那个 Bundle 的加载器 - 都不满足 → 在自己的
Bundle-ClassPath里找 - 还找不到 → 才委派给父加载器
注意第 2 条:它找的不是「父加载器」,而是平级的另一个 Bundle。这就让整个类加载结构从一棵树变成了一个多对多的图(也就是所谓的网状结构)。到这一步,可以说 OSGi 已经在双亲委派之外另起了一套体系。
打破之后要付的代价:类唯一性
前面三种都是「为了解决具体问题不得不破」。还有一种更纯粹的打破方式——热部署:同一个类名,用一个新的类加载器再加载一遍,就能拿到一份全新的 Class 对象,老的那份连同它的类加载器一起被 GC 掉。JRebel、Spring Boot DevTools、各种脚本引擎走的都是这条路。
而这引出了全文最该记住的一条规则:
类在 JVM 里的唯一标识是「类加载器 + 全限定类名」这个二元组,不是单纯的全限定名。
两个不同的类加载器加载同一个 class 文件,得到的是两个完全不相干的 Class 对象。instanceof、equals、强制类型转换,在这两个类之间全部失效——哪怕它们字节码逐字节相同。

开头那个 NoClassDefFoundError 就是这个原理的产物。类似地,这个坑我自己也踩过:
我之前写过一个热部署的小工具,用自定义类加载器重新加载配置类。结果 instanceof 判断一直返回 false,两个对象打印出来的类名一模一样,equals 却是 false。排查到半夜才反应过来——它们压根不是同一个类,只是名字撞了。

后来把类加载器实例按类名做了缓存,同一个类名永远复用同一个加载器,问题才消失。
所以回头看整篇文章:JVM 类加载机制里,双亲委派不是一条不可动摇的铁律,而是一份可协商的默认契约。默认情况下,它优先保证安全和唯一性;当你的场景(SPI、多版本共存、热部署)跟这个默认目标冲突时,重写 loadClass 就是正当手段。但要清楚代价——你换来的每一分灵活性,都是从类唯一性上借来的。
参考链接
- Java 虚拟机规范第 5 章:Loading, Linking, and Initializing(搜:JVMS §5.5 Initialization)
- OpenJDK
java.lang.ClassLoader源码(搜:OpenJDK ClassLoader.java) - Apache Tomcat 类加载器官方说明(搜:Tomcat class loader howto)
- OSGi Core 规范(搜:OSGi Core Specification)
本文 JVM 类加载机制相关的行为差异,以 OpenJDK 25(当前 LTS)与 JDK 8 为基准核对;JDK 9 引入模块化后,扩展类加载器更名为平台类加载器,其余委派结构未变。不同发行版与版本细节请以官方文档为准。
我是晚安code,持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊:你有没有被「同一个类名两个 Class 对象」坑过,最后是怎么定位出来的?