Java 类加载的过程是怎样的?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

这道题是 Java 基础面试中的高频题,面试官通过它主要想考察你以下几个方面的能力:

  1. 对 JVM 整体结构的理解:你是否清楚类数据在 JVM 内存中是如何存放的(方法区/元空间),以及 Class 对象的作用。
  2. 对类生命周期完整流程的掌握:是否能把加载、链接(验证、准备、解析)、初始化这几个阶段清晰地串联起来,并理解它们各自的核心工作。
  3. 对关键细节的深入程度
    • 是否知道准备阶段和初始化阶段为变量赋值的区别(默认值 vs 初始值)。
    • 是否了解解析阶段的时机(静态解析 vs 动态解析)。
    • 是否知道什么情况下会触发类的初始化(主动引用 vs 被动引用)。
  4. 理论联系实际的能力:能否将类加载机制与日常开发中的类加载器、双亲委派模型、甚至是某些热部署框架的原理联系起来。

核心答案

Java 类加载的过程,指的是 JVM 将描述类的数据(通常是从 .class 文件中获取)加载到内存,并对其进行验证、准备、解析以及初始化,最终形成可以被 JVM 直接使用的 Java 类型(java.lang.Class 对象)的过程。

简单来说,这个过程可以分为三个核心阶段:加载链接初始化。其中链接阶段又细分为验证准备解析 三个子步骤。

深度解析

下面我们逐步拆解这几个阶段,结合 JVM 底层机制,让你不仅知道是什么,更知道为什么。

1. 加载(Loading)

这是类加载的 “第一步”。在这个阶段,JVM 主要完成三件事:

  • 通过一个类的全限定名获取定义此类的二进制字节流。这个字节流可以从本地文件系统(.class 文件)、JAR 包、网络、甚至是动态代理生成的字节码中获取。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。这些数据结构包含了类的字段、方法、接口等信息。
  • 在内存(堆)中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

注意:这个阶段并不完全由 JVM 主导,开发人员可以通过自定义类加载器(ClassLoader)来控制二进制字节流的获取方式,为实现热部署、代码加密等功能提供了可能。

2. 链接(Linking)

链接阶段的核心任务是将加载到内存中的二进制数据整合到 JVM 的运行时状态中。它包含三个子步骤:

  • 验证(Verification)

    • 目的:确保被加载的类的信息符合 JVM 规范,并且不会危害 JVM 自身的安全。这是 JVM 的一道重要安全屏障。
    • 工作:包括文件格式验证(是否以魔数 0xCAFEBABE 开头)、元数据验证(这个类是否有父类、是否继承了 final 类等)、字节码验证(对方法体进行语义分析,保证操作数栈类型安全)和符号引用验证(对常量池中的符号引用进行匹配性校验)。
  • 准备(Preparation)

    • 目的:为类中的静态变量(static 修饰的变量) 分配内存,并将其初始化为默认值
    • 关键理解:这是初学者最容易混淆的地方。准备阶段分配的内存是在方法区(JDK 8 之后是中的元空间概念,但逻辑上仍是方法区)中。赋的默认值是指 Java 语言规定的各种数据类型的零值,例如:
      • private static int count = 10; 在准备阶段后,count 的值是 0,而不是 10。
      • private static final int MAX = 100; 对于 final static 变量(编译时常量),在准备阶段就会直接赋值为指定的值(100),因为它在编译期就已经确定了。
  • 解析(Resolution)

    • 目的:将常量池内的符号引用替换为直接引用
      • 符号引用:以一组符号来描述所引用的目标,可以是任何形式的字面量,只要无歧义地定位到目标即可。比如一个类中引用了另一个类,在编译阶段,这个引用是以字符串形式存在的全限定名。
      • 直接引用:可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。比如,解析后,类 A 的 invokevirtual 指令可以直接通过虚方法表的偏移量来调用类 B 的方法。
    • 时机:JVM 规范并没有规定解析阶段发生的时间,它可以在类被加载时就进行(静态解析),也可以在某个符号引用被首次使用前才进行(动态解析,也称为延迟解析)。例如,对于 invokedynamic 指令,它的解析就是动态的。

3. 初始化(Initialization)

这是类加载过程的最后一步,也是真正开始执行类中定义的 Java 程序代码(字节码)的阶段。

  • 核心机制:执行类构造器 <clinit>() 方法。这个方法不是由程序员编写的,而是由 Javac 编译器自动生成的。它由类中所有静态变量的赋值动作静态代码块 static {} 中的语句合并而成。

  • 执行顺序<clinit>() 方法中的语句顺序,与源文件中静态变量定义和静态代码块的顺序一致。

    public class MyClass {
        private static int a = 1; // 赋值动作合并到 <clinit>
        static {
            b = 2; // 静态代码块合并到 <clinit>
            // 此时 a 已经赋值为 1
        }
        private static int b = 3; // 注意,这里的赋值会覆盖静态代码块中对 b 的赋值
    }
    

    在这个例子中,<clinit>() 的执行逻辑等价于:

    a = 1;
    b = 2; // 来自静态代码块
    b = 3; // 来自 b 的显式初始化
    

    最终 b 的值是 3。

  • 触发条件(主动引用):JVM 规范严格规定了有且只有 6 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

    1. 遇到 newgetstaticputstaticinvokestatic 字节码指令时。即:实例化对象、读取或设置一个静态字段(被 final 修饰的静态字段除外)、调用一个类的静态方法。
    2. 对类进行反射调用时(Class.forName() 等)。
    3. 当初始化一个类时,发现其父类还没有进行过初始化,则需要先触发其父类的初始化(接口除外,除非接口定义了 default 方法)。
    4. JVM 启动时,用户需要指定一个要执行的主类(包含 main() 方法的类),虚拟机会先初始化这个主类。
    5. 当使用 JDK 7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStaticREF_putStaticREF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
    6. 当一个接口中定义了 JDK 8 新加入的 default 方法,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

总结

Java 类加载过程是一个严谨且有序的生命周期管理。它始于加载,将类的二进制流引入 JVM;然后经过链接阶段的验证、准备和解析,对类数据进行校验、内存分配和符号引用的转换;最终通过初始化执行类的 <clinit> 方法,为静态变量赋予程序员期望的初始值。这个过程保证了 Java 程序在运行时的类型安全、内存布局规范,并为后续的对象实例化做好了准备。