☰
深入理解 JVM 类加载过程:加载、验证、准备、解析、初始化 与 双亲委派模型全解析
2026/11/5 3:54:08 网站建设 项目流程

深入理解 JVM 类加载过程:加载、验证、准备、解析、初始化 与 双亲委派模型全解析

我们写好的.java文件经过编译变成.class文件,但.class文件静静躺在硬盘上是"死"的——只有被 JVM加载到内存、经过一系列处理之后,类才能被使用。这个"从磁盘上的字节码文件,变成内存中可直接使用的类"的过程,就是类加载过程。本文将完整讲解类加载的五个步骤,以及其中加载阶段的核心机制——双亲委派模型。


一、什么是类加载

类的生命周期是:

加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载 └──────── 类加载过程 ────────┘

其中前五个步骤(加载、验证、准备、解析、初始化)就是"类加载过程"。也可以按 JDK 规范的说法归并为三大部分:

  • 加载(Loading):把类的.class文件读入内存;
  • 链接(Linking):包含验证、准备、解析三个子步骤;
  • 初始化(Initialization):执行类的初始化代码。

接下来先看"什么时候才会进行类加载",再逐个拆解这五个步骤。


二、什么时候进行类加载?(类加载的时机)

类加载(尤其是初始化这一步)是懒加载式的——JVM 规范允许类加载器在"必须用到这个类"时才加载,而不是启动时一股脑全部加载。什么时候会触发类加载呢?

2.1 触发类加载的场景(主动引用)

以下情况会立即触发类的加载和初始化:

  1. 创建类的实例:执行new Person()时,Person 类必须先加载好;
  2. 访问或修改类的静态变量(非static final常量):如Person.count++;
  3. 调用类的静态方法:如Person.run();
  4. 反射调用:执行Class.forName("Person")会强制触发加载和初始化;
  5. 初始化子类时,父类还没初始化:会先触发父类的加载和初始化(父类先行);
  6. 启动类:main 方法所在的类,JVM 启动时就加载;
  7. JDK 7+ 的动态语言支持:如MethodHandle的某些场景。

2.2 不会触发类加载的场景(被动引用)

以下情况不会触发类的初始化(加载可能发生,但初始化不会):

  1. 通过子类引用父类的静态字段:Child.staticVar只触发父类初始化,子类不初始化;
  2. 定义类数组:Person[] arr = new Person[10]只创建数组对象,Person 类不初始化;
  3. 引用编译期确定的常量:static final int MAX = 10在编译期就放入调用方的常量池,运行时根本不经过 Person 类。

2.3 用代码验证类加载时机

publicclassParent{publicstaticinta=1;static{System.out.println("Parent 初始化");}}publicclassChildextendsParent{publicstaticintb=1;publicstaticfinalintMAX=10;// 编译期常量static{System.out.println("Child 初始化");}}publicclassDemo{publicstaticvoidmain(String[]args){System.out.println(Child.MAX);// 只输出 MAX,无任何初始化日志System.out.println(Child.a);// 只输出 "Parent 初始化" 和 1newChild();// 输出 "Parent 初始化"(若之前没触发)→ "Child 初始化"}}

一句话记忆:真正使用(new 对象、动静态成员)才加载初始化,只是"提到名字"(数组定义、常量引用、子类引用父类字段)不加载。这种按需加载的方式,让 JVM 启动更快、内存占用更少。


三、类加载的五个步骤

3.1 第一步:加载(Loading)

要做的事:把一个类的.class文件(字节码数据)从磁盘(或其他来源)读入内存,具体动作包括:

  1. 通过类的全限定名(如java.lang.String)获取定义此类的二进制字节流——来源不限于磁盘,还可以是 jar 包、网络、动态代理运行时生成的字节码等;
  2. 将这个字节流的静态存储结构转化为方法区(元数据区)的运行时数据结构——类的元信息(类名、方法、字段、常量池等)从此住进元数据区;
  3. 在堆中生成一个代表这个类的java.lang.Class对象——作为方法区中这个类的各种数据的访问入口。我们平时写的Person.class、obj.getClass()拿到的就是这个对象。

注意:加载 ≠ 初始化。加载只是把"图纸"搬进内存,还没执行任何类的初始化代码。

3.2 第二步:验证(Verification)

要做的事:确保加载进来的字节码内容是符合 JVM 规范的、安全可靠的,不会危害 JVM 自身。

为什么需要验证?因为.class文件的来源不只是 javac——字节码可以被人为篡改、甚至恶意构造(比如把操作数栈搞溢出、伪造不存在的类引用)。JVM 必须先"安检",主要包括:

验证阶段检查内容
文件格式验证是否以魔数0xCAFEBABE开头、版本号是否合法等(确认是合法的 class 文件)
元数据验证语义是否合规:有没有父类(除 Object)、是否继承了 final 类、抽象方法的实现是否完整
字节码验证最复杂的一步:分析方法的字节码,检查跳转指令是否合法、操作数栈类型是否匹配等(防"半个方法执行到一半崩了")
符号引用验证引用的类、字段、方法是否真实存在、是否可访问(解析阶段的铺垫)

比喻:这一步就像海关安检——不管你从哪来(磁盘、网络、动态生成),进 JVM 之前一律过安检,不合格直接拒收(抛VerifyError)。

3.3 第三步:准备(Preparation)

要做的事:为类的静态变量分配内存,并赋予"默认值"(零值初始化)。

publicstaticintcount=10;

准备阶段后,count的值不是 10,而是 0!

  • 此时只是分配内存并设置默认值:int→ 0,boolean→ false,引用 → null;
  • 真正赋值为 10 的动作发生在后面的初始化阶段(putstatic指令)。

特例:static final修饰的常量不一样——

publicstaticfinalintMAX=10;

final常量在编译期就能确定值,会被标记为ConstantValue,准备阶段就直接赋值为 10。

记忆:准备阶段 = 分内存 + 给默认值;“final 常量例外,准备即赋真值”。

3.4 第四步:解析(Resolution)

要做的事:把常量池中的符号引用替换为直接引用。

  • 符号引用:用"名字"来描述目标,如java/lang/Object这样的全限定名——编译期就能确定,但还没定位到内存;
  • 直接引用:指向目标的内存地址、偏移量——可以真正拿来访问了。

比如代码里调用obj.toString(),编译后常量池里存的是符号引用"toString 方法的描述";解析阶段就要确认这个方法真实存在,并把"名字"变成方法实际所在内存的地址,之后调用才能真正执行。

比喻:符号引用像"收件人姓名+电话",直接引用是"门牌号"。解析就是把通讯录里的名字翻译成实际地址,之后送快递(方法调用)才能直达。

补充:解析的时机不要求严格在初始化之前,可以等首次使用到该引用时再解析(懒解析),JVM 允许一定灵活性。

3.5 第五步:初始化(Initialization)

要做的事:真正开始执行类中的Java 代码——执行静态变量赋值和static块,把它们合起来编译成<clinit>()方法(类构造器)。

publicclassDemo{publicstaticintcount=10;// ① 静态变量赋值static{System.out.println("静态代码块");// ② 静态代码块}// ① + ② 按源码顺序合并成 <clinit>() 方法,在初始化阶段执行}

关键规则:

  • <clinit>()由 JVM 保证线程安全:多个线程同时触发一个类的初始化,只有一个线程能执行<clinit>(),其他线程阻塞——这就是"单例模式静态内部类写法"线程安全的原理;
  • 父类的<clinit>()保证先于子类执行(所以父类静态块先输出);
  • <clinit>()不必是必须的:类没有静态变量赋值和静态块,就没有这个方法。

什么时候触发初始化?(主动引用才触发,被动引用不触发)

主动引用(触发初始化)被动引用(不触发初始化)
new一个对象、读写静态变量(非 final)、调用静态方法通过子类引用父类的静态字段(只初始化父类)
反射调用(Class.forName(...))定义类数组的引用(Person[] arr)
启动类(main 方法所在类)被加载编译期能确定的static final常量引用
初始化一个类时,发现其父类还没初始化 → 先初始化父类——

记忆:初始化阶段 = 静态变量赋真值 + 静态块执行,是类加载过程中"干活"的最后一步。


四、加载阶段的核心:双亲委派模型

4.1 类加载器有哪几类

加载阶段的动作由类加载器(ClassLoader)完成。JDK 自带三层类加载器:

类加载器负责加载扫描范围
Bootstrap(启动类加载器)标准库目录:java.lang.String、java.util.*等(rt.jar/ 核心模块)扫描标准库目录
Extension(扩展类加载器)扩展库目录:javax.*、ext目录下的类扫描扩展库目录
Application(应用程序类加载器)第三方库和当前项目目录:classpath 下的类(我们平时写的代码)扫描第三方库和当前项目目录

逐层关系与查找流程(对应上面层级图):

  • 层级图中箭头表示父子关系:Application 的父亲是 Extension,Extension 的父亲是 Bootstrap;Bootstrap 已经是最顶层,它的"父加载器"是null;
  • 加载的入口是 Application 类加载器(我们平时的代码默认用它);
  • 一个类加载请求到来后:Application“往里走先不执行”,先交给父级 Extension;Extension 同样"往里走先不执行",交给 Bootstrap;
  • 走到 Bootstrap 这里为空之后,才开始在 Bootstrap 层真正执行搜索——扫描标准库目录;
  • 如果 Bootstrap没找到,返回继续查找,回到 Extension——扫描扩展库目录;
  • 如果 Extension 也没找到,返回到 Application——扫描第三方库和当前项目目录,在这里找到了,加载成功!
  • 如果走完一圈都没找到(比如随手写了一个不存在的类名),就会抛出ClassNotFoundException。

补充:理论上也可以不经过"从下往上"的委派,直接从最上面的 Bootstrap 开始往下找——但 JVM 的现有实现就是按照"先上抛、再下扫"的双亲委派方式展开的,这也正是下一节要讲的内容。

4.2 双亲委派模型是什么

双亲委派模型的核心规则:当一个类加载器收到类加载请求时,自己不先加载,而是逐层向上"委派"给父类加载器去处理;只有父类加载器"表示自己加载不了"(在自己的搜索范围内找不到),子类加载器才亲自加载。

工作流程(以加载我们自己写的Person类为例):

1. Application 收到加载 Person 的请求 2. 向上委派 → Extension 收到请求 3. 继续向上委派 → Bootstrap 收到请求 4. Bootstrap 在核心类库中找 Person → 找不到,向下"退回" 5. Extension 在扩展类库中找 Person → 找不到,继续退回 6. Application 在 classpath 中找到 Person → 亲自加载,成功!

如果加载的是java.lang.String:

1. 请求层层上抛到 Bootstrap 2. Bootstrap 在核心类库中找到了 java.lang.String → 亲自加载,直接完成! 下层加载器根本没机会插手

4.3 为什么要双亲委派?—— 两大好处

好处一:确保三个类加载的优先级(防止核心类被篡改)

假设你恶意写了一个java.lang.String放到 classpath 里,想偷梁换柱:

  • 没有双亲委派:Application 类加载器可能加载你这个假 String,全 JVM 的核心类就被污染了;
  • 有双亲委派:请求层层上抛,Bootstrap 发现自己能加载真正的java.lang.String,直接加载真货——你的假类永远没有出场机会。核心 API 得到保护。

好处二:避免类的重复加载(保证唯一性)

同一个类只会被"最有资格"的加载器加载一次。否则没有委派机制,你加载一份String、我加载一份String,内存中出现多个"同名不同源"的类,类型体系就乱了(JVM 中类的唯一性 = 类本身 + 加载它的类加载器,两个共同决定)。

4.4 破坏双亲委派模型(了解)

双亲委派不是铁律,某些场景下会被"打破":

  • SPI 机制(如 JDBC):核心库(java.sql.DriverManager)需要加载厂商实现类(MySQL 驱动在 classpath,Bootstrap 根本看不到)→ 引入线程上下文类加载器,让父类加载器"反向"委托子类加载器去加载;
  • Web 容器(Tomcat):为了实现应用间类隔离,每个 webapp 用独立的类加载器,优先加载应用自己的类;
  • 热部署 / OSGi:模块化加载,需要动态替换类。

理解思路:双亲委派解决的是"标准类库的统一与安全",但遇到"父类加载器要加载子类可见的资源"时,只能反过来打破层级。


五、总结

  • 类加载五个步骤:
    1. 加载:读入字节流 → 类元信息进元数据区 → 堆中生成 Class 对象(访问入口);
    2. 验证:格式、元数据、字节码、符号引用四道安检,保证字节码安全合规;
    3. 准备:给静态变量分内存、赋默认值(static final常量例外,直接赋真值);
    4. 解析:符号引用(名字)→ 直接引用(内存地址);
    5. 初始化:执行<clinit>()(静态变量赋值 + 静态块),JVM 保证线程安全,父类先初始化;
  • 双亲委派模型:类加载请求先层层上抛给父类加载器,父类搞不定才自己加载;
  • 两大好处:防止核心类被篡改(安全)+ 避免同一个类被重复加载(唯一);
  • 记忆链条:加载搬图纸 → 验证过安检 → 准备分房子 → 解析给地址 → 初始化干活 → 双亲委派保安全去重。

类加载机制是 JVM 把"静态的字节码文件"变成"动态的运行时类"的桥梁,理解了它,反射、动态代理、SPI、热部署等高级机制的底层逻辑就都通了。希望这篇文章对你有帮助,欢迎点赞收藏!

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询