JVM 类加载机制笔记
整理自对话讲解,覆盖类加载生命周期、编译期与运行时区别、类加载器体系、双亲委派机制、JVM 内存模型等核心内容。
目录
一、什么是类加载
Java 的类不是程序启动时一次性全部加载,而是按需加载(懒加载)。JVM 把描述类的数据从 .class 字节码文件(或 jar、网络、动态生成等来源)加载到内存,并对数据进行校验、转换解析、初始化,最终形成可被 JVM 直接使用的 Java 类型。
类加载的完整生命周期包含 7 个阶段:
加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载
└──────────┬──────┘
链接 Linking
其中验证、准备、解析三个阶段合称为链接(Linking)。
二、类加载的 5 个核心阶段
1. 加载(Loading)
通过类的完全限定名(如 com.foo.User)获取字节流,三步完成:
- 获取二进制字节流(本地文件 / jar / 网络 / 动态代理等);
- 将字节流的静态存储结构转化为方法区(元空间)的运行时数据结构;
- 在堆中生成一个
java.lang.Class对象,作为访问该类数据的入口。
2. 验证(Verification)
确保 Class 文件符合 JVM 规范,不危害虚拟机自身安全。四类校验:
- 文件格式验证:魔数
0xCAFEBABE、主次版本号是否受支持; - 元数据验证:是否有父类、是否继承了
final类、方法重写是否合法; - 字节码验证:跳转指令是否越界、操作数栈类型是否匹配;
- 符号引用验证:引用的类/方法/字段能否被正确找到。
3. 准备(Preparation)
为 static 类变量分配内存并设置零值:
public static int a = 10; // 准备阶段 a = 0,而非 10
public static Object o = new Object(); // o = null
特例:
static final编译期常量(static final int A = 10)在准备阶段直接赋值 10(ConstantValue编译期已确定);- 本阶段只处理类变量(static),实例变量随对象分配时再赋初值。
4. 解析(Resolution)
把常量池中的符号引用替换为直接引用:
| 类型 | 含义 | 例子 |
|---|---|---|
| 符号引用 | 与内存布局无关的描述符号 | 全限定名 com.foo.User、方法名+描述符 |
| 直接引用 | 直接指向目标的内存地址/偏移量/句柄 | 指向方法区某方法实际地址的指针 |
解析可以是懒惰的,用到时才解析。
5. 初始化(Initialization)
真正开始执行 Java 代码,执行 <clinit>() 方法——即静态变量赋值 + 静态代码块,且父类优先。
触发初始化的"主动使用"条件:
new创建实例- 访问/调用类的静态字段(非
final常量)或静态方法 - 反射:
Class.forName("...") - 初始化子类时,先初始化父类
- JVM 启动时的主类(含
main)
不触发初始化的"被动引用":
- 通过子类引用父类的静态字段 → 只初始化父类
User[] arr = new User[10]→ 只初始化数组类- 引用
static final编译期常量 → 直接进常量池,不初始化
三、编译期 vs 运行时
分界线
.java 源码 ──javac编译──► .class 字节码 ──JVM运行时加载──► 内存中的类
(编译期产物) (类加载过程)
| 阶段 | 谁做的 | 什么时候 | 产物 |
|---|---|---|---|
| 编译(Compile) | javac 编译器 |
开发/构建时 | .class 字节码文件 |
| 类加载(Class Loading) | JVM | 程序运行期间 | 内存中的类 |
关键结论
类加载的 5 个阶段全部发生在运行时,由 JVM 完成;编译是由 javac 在更早环节独立完成的。
public class Main {
public static void main(String[] args) {
new User(); // 只有运行到这里,User 才被加载、初始化
}
}
- 编译时:javac 只知道
User类名,编译出Main.class和User.class,但不加载进内存; - 运行时:JVM 执行
new User()时,才触发 User 的加载 → 验证 → 准备 → 解析 → 初始化。
编译期做了什么
- 语法检查、类型检查;
- 把源码翻译成字节码指令;
- 常量折叠、优化;
- 生成常量池、符号引用;
- 处理注解、泛型擦除等。
类加载发生的时机
类加载不是"启动时一次性完成",而是"启动时加载一部分 + 运行过程中按需持续加载"。
程序启动(java Main)
├─► 加载主类 Main + 核心 JDK 类 ◄── 启动时
├─► new User() → 加载 User ◄── 运行中
├─► 反射 Class.forName("X") → 加载 X ◄── 运行中
└─► 程序结束,部分类被 GC 卸载 ◄── 结束时
记忆口诀:启动加载主干,运行按需加载枝叶。
四、魔数 Magic Number
是什么
魔数是文件头部固定、特定格式的标识字节序列,用来让程序快速识别"这是什么类型的文件",而非靠扩展名判断。
.class 文件开头固定是 4 个字节:
CA FE BA BE → 0xCAFEBABE
这是 Java 之父团队的彩蛋:CAFE BABE = "咖啡宝贝",呼应 Java 的咖啡标志。
作用
| 作用 | 说明 |
|---|---|
| 识别文件类型 | 一眼看出是 JVM 字节码 |
| 防止加载错误文件 | 把 .txt/.exe 改名 .class 会被拒,不崩溃 |
| 快速校验 | 文件格式验证的第一步 |
常见文件魔数对照
| 文件类型 | 魔数(十六进制) |
|---|---|
.class |
CA FE BA BE |
| ZIP / JAR | 50 4B("PK") |
| PNG | 89 50 4E 47 |
| GIF | 47 49 46 38("GIF8") |
| JPEG | FF D8 FF |
25 50 44 46("%PDF") |
|
| ELF | 7F 45 4C 46 |
魔数只是验证的第一步,通过后还会校验主次版本号(旧 JVM 跑新版本 class 会报
UnsupportedClassVersionError)、元数据、字节码等。
五、常量折叠与泛型擦除
1. 常量折叠(Constant Folding)
编译器在编译期把值能确定的表达式直接算出结果写进字节码,不留给运行时计算。
int a = 3;
int b = 4;
int c = a + b; // 编译期直接算出 c = 7
int d = 2 * 3 + 5; // 直接算出 d = 11
String s = "Hello" + "World"; // 直接拼成 "HelloWorld"
触发条件:编译期能确定是常量的表达式(字面量运算、final 编译期常量、字符串字面量拼接)。
不会折叠:运行期才确定的值(如 scanner.nextInt())。
作用:性能优化、字节码更小更快。
2. 泛型擦除(Type Erasure)
Java 泛型是编译期的东西,运行时不保留。编译器在编译后把泛型信息擦除,替换成原始类型(或边界类型)。
List<String> list = new ArrayList<String>();
String s = list.get(0);
编译后等效为:
List list = new ArrayList();
String s = (String) list.get(0); // 自动插入 (String) 强转
擦除规则:
| 泛型写法 | 擦除后 |
|---|---|
List<String> |
List |
List<Integer> |
List |
T(无边界) |
Object |
<T extends Number>(有边界) |
Number |
影响:List<String> 和 List<Integer> 擦除后类型相同,故无法重载。
原因:Java 5 引入泛型时为向后兼容旧代码,选择擦除实现;代价是运行时泛型信息丢失(不能 new T()、不能 instanceof 泛型参数)。
对比总结
| 常量折叠 | 泛型擦除 | |
|---|---|---|
| 对象 | 表达式的值 | 类型的泛型信息 |
| 目的 | 性能优化(提前算好) | 向后兼容(运行时不保留泛型) |
| 结果 | 字节码里直接是计算结果 | 字节码里类型还原成原始类型 |
六、类加载器体系与双亲委派
类加载器层次结构
┌─────────────────────────────────────────────────────┐
│ JVM 类加载器体系(层次结构) │
├─────────────────────────────────────────────────────┤
│ ┌───────────────────────────────────────────┐ │
│ │ Bootstrap ClassLoader(引导类加载器) │ │
│ │ · C/C++ 实现,虚拟机一部分 │ │
│ │ · 加载 <JAVA_HOME>/lib 核心类库 │ │
│ │ · 无父加载器,Java 程序无法直接引用 │ │
│ └───────────────────┬───────────────────────┘ │
│ ┌───────────────────▼───────────────────────┐ │
│ │ Platform ClassLoader(平台类加载器) │ │
│ │ · Java 9+ 替代原 ExtClassLoader │ │
│ │ · 加载 <JAVA_HOME>/lib/ext 扩展库 │ │
│ └───────────────────┬───────────────────────┘ │
│ ┌───────────────────▼───────────────────────┐ │
│ │ Application ClassLoader(应用类加载器) │ │
│ │ · 也叫 SystemClassLoader │ │
│ │ · 加载 classpath 上的用户类 │ │
│ └───────────────────┬───────────────────────┘ │
│ ┌───────────────────▼───────────────────────┐ │
│ │ 用户自定义 ClassLoader │ │
│ │ · 继承 ClassLoader,重写 findClass() │ │
│ │ · 热部署、类隔离、加密类加载等 │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
双亲委派机制
核心思想:类加载器收到加载请求时,先委派给父加载器,父加载器加载不了才自己尝试,保证核心类库一致性和安全性。
收到类加载请求
│
▼
┌───────────────────────┐
│ 当前加载器已加载过该类? │
└───────────┬───────────┘
否 │ │ 是
▼ └──► 直接返回已加载的 Class
┌───────────────────────┐
│ 委派给父类加载器尝试加载 │
└───────────┬───────────┘
是 │ │ 否(父加载失败)
│ ▼
│ ┌───────────────────────┐
│ │ 自己调用 findClass() │
│ └───────────┬───────────┘
│ 成功 │ │ 失败
│ │ ▼
│ │ ClassNotFoundException
└───────┬───────┘
▼
返回 Class 对象
委派顺序(自下而上,再自上而下):
用户自定义加载器 → Application → Platform → Bootstrap → 能加载则返回,否则逐级回退到自己 findClass
双亲委派的好处:
- 避免重复加载:父已加载的类,子不重复加载;
- 安全隔离:防止核心 API 被篡改(自定义
java.lang.String会被委派到 Bootstrap,加载的是真正的String)。
七、JVM 内存模型
结构图
┌────────────────────────────────────────────────────────────────────────┐
│ JVM 内存模型(运行时数据区) │
├────────────────────────────────────────────────────────────────────────┤
│ 线程私有(每个线程一份) │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ 程序计数器 │ │ 虚拟机栈 │ │ 本地方法栈 │ │
│ │ ·下一条字节码指令 │ │ ·栈帧 Frame │ │ ·执行 native 方法 │ │
│ │ ·无 OOM 风险 │ │ ├局部变量表 │ │ ·可能 StackOverflow/ │ │
│ │ │ │ ├操作数栈 │ │ OOM │ │
│ │ │ │ ├动态链接 │ │ │ │
│ │ │ │ └返回地址 │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────────┘ │
│ 线程共享(所有线程共用) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 堆 Heap(最大)·对象实例和数组·分代·GC主战场 │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 方法区 Method Area(Java8+ = 元空间 Metaspace) │ │
│ │ ·类元信息·运行时常量池·JIT缓存 │ │
│ └──────────────────────────────────────────────────────┘ │
│ 直接内存 Direct Memory(NIO 本地缓冲,非运行时数据区) │ │
└────────────────────────────────────────────────────────────────────────┘
各区域对比
| 区域 | 线程归属 | 存储内容 | 异常风险 |
|---|---|---|---|
| 程序计数器 | 私有 | 下一条字节码指令地址 | 无(唯一不报 OOM) |
| 虚拟机栈 | 私有 | 栈帧(局部变量表、操作数栈、动态链接、返回地址) | StackOverflowError / OOM |
| 本地方法栈 | 私有 | 执行 native 方法 | StackOverflowError / OOM |
| 堆 | 共享 | 对象实例、数组 | OutOfMemoryError |
| 方法区/元空间 | 共享 | 类元信息、运行时常量池 | OutOfMemoryError |
| 直接内存 | 共享 | NIO 本地缓冲 | OutOfMemoryError |
类加载 → 内存落点
.class 字节码 → 双亲委派加载 → 5 阶段 → 分别写入:
类元信息(类型、常量池、字节码、字段) → 方法区/元空间
运行时常量池(符号引用→直接引用) → 方法区常量池
堆中 Class 对象 → 堆
new 出来的实例对象/数组 → 堆
方法执行栈帧、局部变量 → 虚拟机栈(线程私有)
native 方法调用 → 本地方法栈(线程私有)
八、速记总结
- 类加载 5 阶段:加载 → 验证 → 准备 → 解析 → 初始化(后三者合称"链接")。
- 准备阶段是零值,不是赋值;
static final常量例外,准备阶段就赋值。 - 初始化触发靠"主动引用",父类一定先于子类初始化。
- 类加载发生在运行时,不在编译期;编译期只做翻译、常量折叠、泛型擦除等。
- 魔数
CAFEBABE是 class 文件开头的身份标识,验证第一步。 - 常量折叠=编译期提前算出结果;泛型擦除=运行时去掉泛型信息,为向后兼容。
- 双亲委派:自下而上委派、自上而下加载,防止核心类被篡改、避免重复加载。
- 内存分两类:线程私有(程序计数器、虚拟机栈、本地方法栈)+ 线程共享(堆、方法区)。
- 唯一不报 OOM 的区域:程序计数器;GC 主战场:堆。
- 类卸载:只有用户自定义类加载器加载的类,在其加载器被 GC 后才有机会卸载。
浙公网安备 33010602011771号