JVM 类加载机制笔记

整理自对话讲解,覆盖类加载生命周期、编译期与运行时区别、类加载器体系、双亲委派机制、JVM 内存模型等核心内容。


目录

  1. 什么是类加载
  2. 类加载的 5 个核心阶段
  3. 编译期 vs 运行时
  4. 魔数 Magic Number
  5. 常量折叠与泛型擦除
  6. 类加载器体系与双亲委派
  7. JVM 内存模型
  8. 速记总结

一、什么是类加载

Java 的类不是程序启动时一次性全部加载,而是按需加载(懒加载)。JVM 把描述类的数据从 .class 字节码文件(或 jar、网络、动态生成等来源)加载到内存,并对数据进行校验、转换解析、初始化,最终形成可被 JVM 直接使用的 Java 类型。

类加载的完整生命周期包含 7 个阶段:

加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载
     └──────────┬──────┘
            链接 Linking

其中验证、准备、解析三个阶段合称为链接(Linking)。


二、类加载的 5 个核心阶段

1. 加载(Loading)

通过类的完全限定名(如 com.foo.User)获取字节流,三步完成:

  1. 获取二进制字节流(本地文件 / jar / 网络 / 动态代理等);
  2. 将字节流的静态存储结构转化为方法区(元空间)的运行时数据结构;
  3. 在堆中生成一个 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>() 方法——即静态变量赋值 + 静态代码块,且父类优先。

触发初始化的"主动使用"条件:

  1. new 创建实例
  2. 访问/调用类的静态字段(非 final 常量)或静态方法
  3. 反射:Class.forName("...")
  4. 初始化子类时,先初始化父类
  5. 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 的加载 → 验证 → 准备 → 解析 → 初始化。

编译期做了什么

  1. 语法检查、类型检查;
  2. 把源码翻译成字节码指令;
  3. 常量折叠、优化;
  4. 生成常量池、符号引用;
  5. 处理注解、泛型擦除等。

类加载发生的时机

类加载不是"启动时一次性完成",而是"启动时加载一部分 + 运行过程中按需持续加载"。

程序启动(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
PDF 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

双亲委派的好处:

  1. 避免重复加载:父已加载的类,子不重复加载;
  2. 安全隔离:防止核心 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 方法调用                       → 本地方法栈(线程私有)

八、速记总结

  1. 类加载 5 阶段:加载 → 验证 → 准备 → 解析 → 初始化(后三者合称"链接")。
  2. 准备阶段是零值,不是赋值;static final 常量例外,准备阶段就赋值。
  3. 初始化触发靠"主动引用",父类一定先于子类初始化。
  4. 类加载发生在运行时,不在编译期;编译期只做翻译、常量折叠、泛型擦除等。
  5. 魔数 CAFEBABE 是 class 文件开头的身份标识,验证第一步。
  6. 常量折叠=编译期提前算出结果;泛型擦除=运行时去掉泛型信息,为向后兼容。
  7. 双亲委派:自下而上委派、自上而下加载,防止核心类被篡改、避免重复加载。
  8. 内存分两类:线程私有(程序计数器、虚拟机栈、本地方法栈)+ 线程共享(堆、方法区)。
  9. 唯一不报 OOM 的区域:程序计数器;GC 主战场:堆。
  10. 类卸载:只有用户自定义类加载器加载的类,在其加载器被 GC 后才有机会卸载。
posted on 2026-10-27 18:56  一个痴迷于技术的码农  阅读(12)  评论(0)    收藏  举报