JVM 类加载机制详解:双亲委派模型的原理、优势与三种打破方式

简介: JVM 类加载机制到底怎么走?本文从生命周期七阶段讲起,把双亲委派模型的原理、优势与三种打破方式讲透,再补一张类初始化时机的主动 / 被动引用对照表。

大家好,我是晚安code。

假设一个场景排查一个 NoClassDefFoundError:日志里打出来的类名一模一样,代码里这个类也确确实实存在,编译能过、打包能过,一跑就炸。折腾半天才反应过来——同一个 class 文件被两个类加载器各加载了一次,在 JVM 眼里这俩根本不是一个类。

要解释清楚这件事,就得回到 JVM 类加载机制最核心的那套设计:双亲委派模型。今天把它从头拆一遍:生命周期七个阶段、四种类加载器、双亲委派的源码与优势、类初始化的触发时机,以及它到底是怎么被打破的。源码与 JVMS 条目基于 OpenJDK 25(当前 LTS),2026 年 10 月核对。

内容有点长,建议先点个收藏。

一、类加载生命周期:七个阶段里,真正容易踩坑的是「准备」和「解析」

类加载机制(Class Loading):JVM 把 .class 文件里的二进制字节流读进内存、校验合法性、转换成方法区中的运行时数据结构,并在堆里生成一个 java.lang.Class 对象的过程。你可以理解成「把一本书从仓库搬进图书馆,同时给它建一张唯一的索引卡」——索引卡就是那个 Class 对象,之后你对这本书的所有操作,都从这张卡开始。

类加载的完整生命周期有七个阶段,但真正会让人写出错误代码的只有两个:准备阶段和解析阶段——前者决定了静态变量什么时候有值,后者决定了你的程序什么时候会突然抛 NoClassDefFoundError。其余的看一眼知道怎么回事就够了。

JVM 类加载机制的生命周期七阶段:加载、链接(验证/准备/解析)、初始化、使用、卸载

先说加载(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 上的类,也就是你自己写的代码
自定义类加载器 —— —— 网络、加密文件、热部署等自定义来源

JVM 类加载机制中的双亲委派模型:类加载器收到请求先向上逐级委派,父加载器都加载不到才逐层向下自己 findClass

有一个特别容易记错的细节:「双亲」这个叫法其实有误导性,它是单亲。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 种:

  1. 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时。翻译成人话就是:new 一个对象、读写一个静态字段(编译期常量除外)、调用一个静态方法。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用。比如 Class.forName("com.example.Foo")。
  3. 初始化一个类时,如果发现它的父类还没初始化,那就先初始化父类。
  4. 虚拟机启动时,用户指定的主类(就是含 main 方法的那个)会被最先初始化。
  5. 使用 java.lang.invoke.MethodHandle 时,如果解析结果是 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 这四种方法句柄,也会触发初始化。
  6. 一个接口定义了 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 一个类加载器。它的委派顺序是:

  1. java.* 开头的类 → 委派给父加载器
  2. 类所在的包在自己的 Import-Package 里声明过 → 委派给导出该包的那个 Bundle 的加载器
  3. 都不满足 → 在自己的 Bundle-ClassPath 里找
  4. 还找不到 → 才委派给父加载器

注意第 2 条:它找的不是「父加载器」,而是平级的另一个 Bundle。这就让整个类加载结构从一棵树变成了一个多对多的图(也就是所谓的网状结构)。到这一步,可以说 OSGi 已经在双亲委派之外另起了一套体系。

打破之后要付的代价:类唯一性

前面三种都是「为了解决具体问题不得不破」。还有一种更纯粹的打破方式——热部署:同一个类名,用一个新的类加载器再加载一遍,就能拿到一份全新的 Class 对象,老的那份连同它的类加载器一起被 GC 掉。JRebel、Spring Boot DevTools、各种脚本引擎走的都是这条路。

而这引出了全文最该记住的一条规则:

类在 JVM 里的唯一标识是「类加载器 + 全限定类名」这个二元组,不是单纯的全限定名。

两个不同的类加载器加载同一个 class 文件,得到的是两个完全不相干的 Class 对象。instanceof、equals、强制类型转换,在这两个类之间全部失效——哪怕它们字节码逐字节相同。

两边长得一模一样、字节码逐字节相同,但只要是被不同类加载器加载的,在 JVM 眼里就是两个类。

开头那个 NoClassDefFoundError 就是这个原理的产物。类似地,这个坑我自己也踩过:

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

后来把类加载器实例按类名做了缓存,同一个类名永远复用同一个加载器,问题才消失。

所以回头看整篇文章:JVM 类加载机制里,双亲委派不是一条不可动摇的铁律,而是一份可协商的默认契约。默认情况下,它优先保证安全和唯一性;当你的场景(SPI、多版本共存、热部署)跟这个默认目标冲突时,重写 loadClass 就是正当手段。但要清楚代价——你换来的每一分灵活性,都是从类唯一性上借来的。


参考链接

本文 JVM 类加载机制相关的行为差异,以 OpenJDK 25(当前 LTS)与 JDK 8 为基准核对;JDK 9 引入模块化后,扩展类加载器更名为平台类加载器,其余委派结构未变。不同发行版与版本细节请以官方文档为准。


我是晚安code,持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊:你有没有被「同一个类名两个 Class 对象」坑过,最后是怎么定位出来的?

目录
相关文章
|
10天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
7742 13
|
8天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
1668 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
5天前
|
人工智能 JavaScript 芯片
DeepSeek 官方偷偷上传 Harness 桌面端安装包,我已经用上了。。附最新下载地址
DeepSeek Harness 官方的桌面端安装包被网友扒出来了,2 分钟讲明白如何使用,体验如何,适合作为 AI 编程工具么?附最新 Windows 和 Mac 双端的下载地址
1445 1
|
8天前
|
人工智能 并行计算 PyTorch
秋叶 ComfyUI 2026 整合包 v3.2 完整部署教程:Python 3.13 + Torch 2.13 全栈升级
秋叶aaaki ComfyUI 2026年8月整合包v3.2正式发布!全面升级Python 3.13.11、PyTorch 2.13.0+cu130及ComfyUI v0.30.2,原生支持MiniMax H3、Wan 2.2、Qwen-Image-2.1等2026主流音视频/图像模型,解压即用,无需环境配置。
1302 11
|
22天前
|
人工智能 自然语言处理 安全
阿里云千问办公 QwenWork详细介绍:产品核心能力、典型场景、价格及常见问题解答
千问办公是阿里云推出的一站式AI办公平台,主打"不止于对话,更注重交付",依托通义千问旗舰大模型,用户一句话即可完成数据分析、PPT生成、视频剪辑等复杂任务,直接输出可用成果。产品深度打通钉钉生态与企业OA,覆盖桌面端、网页端,提供企业标准版198元/人/月等多档订阅方案,新用户注册即赠2000积分,适配工程师、HR、财务等多职业办公场景,成为能动手干活的"全能AI同事"。
3694 10
|
7天前
|
人工智能 编解码 并行计算
MiniMax-H3 一键整合包技术文档:8G 显存运行 AI 漫剧制作 —— 角色替换 / 动作迁移 / 文图生视频部署与调参指南
MiniMax H3 是 MiniMax 开源的全模态视频生成模型,支持文/图/音/视多条件输入,输出最高2K、15秒带双声道音频视频。本文档详述其Int8量化版在8GB显存下的本地一键部署、三段式工作流(EDIT/REPLACE/CONTINUE)、参数调优及常见问题排查。(239字)
|
16天前
|
缓存 IDE Java
【保姆级】Android Studio下载、安装和汉化教程(2026最新)
Android Studio 是 Google 官方推出的免费 Android 应用开发集成环境,基于 IntelliJ IDEA,内置模拟器、调试器、性能分析及 Compose 界面工具,功能全面,文档丰富,是安卓开发首选工具。(239字)
1784 1

热门文章

最新文章