深入理解 JVM 类加载过程:加载、验证、准备、解析、初始化 与 双亲委派模型全解析
我们写好的
.java文件经过编译变成.class文件,但.class文件静静躺在硬盘上是"死"的——只有被 JVM加载到内存、经过一系列处理之后,类才能被使用。这个"从磁盘上的字节码文件,变成内存中可直接使用的类"的过程,就是类加载过程。本文将完整讲解类加载的五个步骤,以及其中加载阶段的核心机制——双亲委派模型。
一、什么是类加载
类的生命周期是:
加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载 └──────── 类加载过程 ────────┘其中前五个步骤(加载、验证、准备、解析、初始化)就是"类加载过程"。也可以按 JDK 规范的说法归并为三大部分:
- 加载(Loading):把类的
.class文件读入内存; - 链接(Linking):包含验证、准备、解析三个子步骤;
- 初始化(Initialization):执行类的初始化代码。
接下来先看"什么时候才会进行类加载",再逐个拆解这五个步骤。
二、什么时候进行类加载?(类加载的时机)
类加载(尤其是初始化这一步)是懒加载式的——JVM 规范允许类加载器在"必须用到这个类"时才加载,而不是启动时一股脑全部加载。什么时候会触发类加载呢?
2.1 触发类加载的场景(主动引用)
以下情况会立即触发类的加载和初始化:
- 创建类的实例:执行
new Person()时,Person 类必须先加载好; - 访问或修改类的静态变量(非
static final常量):如Person.count++; - 调用类的静态方法:如
Person.run(); - 反射调用:执行
Class.forName("Person")会强制触发加载和初始化; - 初始化子类时,父类还没初始化:会先触发父类的加载和初始化(父类先行);
- 启动类:main 方法所在的类,JVM 启动时就加载;
- JDK 7+ 的动态语言支持:如
MethodHandle的某些场景。
2.2 不会触发类加载的场景(被动引用)
以下情况不会触发类的初始化(加载可能发生,但初始化不会):
- 通过子类引用父类的静态字段:
Child.staticVar只触发父类初始化,子类不初始化; - 定义类数组:
Person[] arr = new Person[10]只创建数组对象,Person 类不初始化; - 引用编译期确定的常量:
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文件(字节码数据)从磁盘(或其他来源)读入内存,具体动作包括:
- 通过类的全限定名(如
java.lang.String)获取定义此类的二进制字节流——来源不限于磁盘,还可以是 jar 包、网络、动态代理运行时生成的字节码等; - 将这个字节流的静态存储结构转化为方法区(元数据区)的运行时数据结构——类的元信息(类名、方法、字段、常量池等)从此住进元数据区;
- 在堆中生成一个代表这个类的
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:模块化加载,需要动态替换类。
理解思路:双亲委派解决的是"标准类库的统一与安全",但遇到"父类加载器要加载子类可见的资源"时,只能反过来打破层级。
五、总结
- 类加载五个步骤:
- 加载:读入字节流 → 类元信息进元数据区 → 堆中生成 Class 对象(访问入口);
- 验证:格式、元数据、字节码、符号引用四道安检,保证字节码安全合规;
- 准备:给静态变量分内存、赋默认值(
static final常量例外,直接赋真值); - 解析:符号引用(名字)→ 直接引用(内存地址);
- 初始化:执行
<clinit>()(静态变量赋值 + 静态块),JVM 保证线程安全,父类先初始化;
- 双亲委派模型:类加载请求先层层上抛给父类加载器,父类搞不定才自己加载;
- 两大好处:防止核心类被篡改(安全)+ 避免同一个类被重复加载(唯一);
- 记忆链条:加载搬图纸 → 验证过安检 → 准备分房子 → 解析给地址 → 初始化干活 → 双亲委派保安全去重。
类加载机制是 JVM 把"静态的字节码文件"变成"动态的运行时类"的桥梁,理解了它,反射、动态代理、SPI、热部署等高级机制的底层逻辑就都通了。希望这篇文章对你有帮助,欢迎点赞收藏!