类加载生命周期:加载、验证、准备、解析、初始化、卸载(055)

JVM原理与调优约 14 分钟阅读7,802 字符
知识库文档 56 / 251

一、类加载不只是"把 class 文件读进来"

很多 Java 程序员对类加载的理解停留在"JVM 从 classpath 找到 class 文件,读进来就能用了"。但实际上,类加载是一个完整的生命周期,从 JVM 的角度来看,一个类从被加载到虚拟机内存中,到最终被卸载出内存,需要经历七个阶段:加载、验证、准备、解析、初始化、使用、卸载。其中验证、准备、解析这三个阶段统称为连接(Linking)。

这七个阶段中,除了解析阶段可能在初始化之后才开始(为了支持动态绑定/晚期绑定),其他阶段必须严格按照顺序开始——但注意,这里是"开始"的顺序,而不是"完成"的顺序。这些阶段通常是交叉混合进行的,比如:加载阶段还没完全结束,验证阶段可能就已经开始了。

理解类加载生命周期,不仅是为了应付面试中"类加载过程分哪几个阶段"这样的基础题,更是为了理解更深层的问题:为什么静态变量在准备阶段被赋零值而不是代码中的初始值?为什么 new 一个对象时类加载检查可能触发完整的类初始化?为什么有些类永远不被卸载而有些类会?

文章配图
文章配图

二、加载:把字节码读入内存

加载是类加载的第一个阶段,JVM 需要完成三件事:

  1. 通过全限定名获取类的二进制字节流:这里就是"类加载器"发挥作用的地方。JVM 并不限制字节流必须来自 class 文件——它可以从 ZIP 包(JAR)、网络、数据库、甚至运行时动态生成(动态代理)。这也是为什么我们可以自定义类加载器来从任意来源加载类。
  2. 将字节流所代表的静态存储结构转化为方法区的运行时数据结构:class 文件中的常量池、字段表、方法表等静态结构,在这一步被转化为 JVM 内部使用的运行时数据结构。
  3. 在堆中生成一个代表该类的**java.lang.Class**对象:这个 Class 对象是对外暴露的"入口",通过它可以访问方法区中该类的元数据。Object.getClass()、Class.forName() 等操作返回的就是这个对象。

加载阶段可以使用 JVM 内置的引导类加载器(Bootstrap ClassLoader),也可以使用用户自定义的类加载器。关于类加载器的双亲委派模型,将在后续章节详细展开。

三、验证:确保字节码"安全无害"

验证是连接阶段的第一步,目的是确保 class 文件的字节流中包含的信息符合 JVM 规范的全部约束要求,不会危害 JVM 自身的安全。如果 class 文件不是由 Java 源码编译而来(比如手动编写或篡改),验证阶段就可能抛出 VerifyError。

验证阶段主要包括四个子阶段:

  1. 文件格式验证:检查字节流是否符合 class 文件格式规范。比如是否以魔数 0xCAFEBABE 开头、主次版本号是否在当前 JVM 接受范围内、常量池中是否有不被支持的常量类型等。这个阶段的验证基于二进制字节流进行,通过后才能进入方法区。
  2. 元数据验证:对字节码描述的语义信息进行验证,确保它们符合 Java 语言规范。比如:这个类是否有父类(除 Object 外)、是否继承了 final 类、是否实现了接口中的所有方法、字段和方法是否与父类产生矛盾等。
  3. 字节码验证:这是验证阶段最复杂的一步。通过数据流和控制流分析,确保方法体的字节码指令是合法且安全的。比如:操作数栈的数据类型和指令序列是否匹配、跳转指令是否跳转到方法体以外的位置、类型转换是否有效等。JDK 6 之后引入了 StackMapTable 属性来优化这个阶段——编译器在 class 文件中记录类型状态快照,JVM 只需验证快照之间的一致性,不需要完整模拟方法执行,大幅提升了验证速度。
  4. 符号引用验证:在解析阶段之前,确保常量池中的符号引用可以被正确解析。比如:符号引用中通过字符串描述的类是否存在、字段和方法是否存在、访问权限是否足够等。这个验证发生在解析阶段,但逻辑上属于验证的一部分。

文章配图
文章配图

四、准备:为静态变量分配内存并赋零值

准备阶段是正式为类变量(static 修饰的变量)分配内存并设置零值的阶段。注意两个关键点:

  1. 只分配静态变量,不分配实例变量:实例变量会在对象创建时随着对象一起分配到堆上,而静态变量在准备阶段分配在方法区(元空间)中。
  2. 赋的是零值,不是代码中的初始值:比如 public static int value = 123; 在准备阶段,value 的值是 0(int 的零值),而不是 123。把 value 赋值为 123 的 putstatic 指令是在初始化阶段才执行的。

但有一个例外:如果静态变量是 final 修饰的常量(且是基本类型或字符串字面量),那么在编译期 javac 就会为它生成 ConstantValue 属性,在准备阶段 JVM 就会根据 ConstantValue 的设置将常量赋值为指定的值,而不是零值。比如 public static final int value = 123; 在准备阶段,value 就是 123。这是因为 final 常量的值在编译期就确定了,不需要等到初始化阶段执行代码。

五、解析:将符号引用替换为直接引用

解析阶段是 JVM 将常量池中的符号引用替换为直接引用的过程。符号引用是通过字符串来描述所引用的目标(如 java/lang/Object、(I)V 等),与具体的内存布局无关;直接引用是直接指向目标的指针、偏移量或句柄,与具体的内存布局相关。

解析主要针对以下七类符号引用:类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符。其中前四种是基础,后三种是 JDK 7 引入 invokedynamic 指令后新增的。

JVM 规范并没有规定解析阶段发生的具体时间,只要求在执行 anewarray、checkcast、getfield、getstatic、instanceof、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、new、putfield 和 putstatic 这 14 条指令之前,必须对它们使用的符号引用进行解析。所以解析阶段可能在初始化之前,也可能在初始化之后——这就是"晚期绑定"(Late Binding)的体现。

解析过程中可能会触发其他类的加载。比如解析一个类的符号引用时,如果这个类还没有被加载,JVM 会先触发这个类的加载过程。这也是类加载的"交叉混合"特性的一个体现。

六、初始化:执行类构造器 方法

初始化是类加载生命周期中最后一个(也是最重要)的阶段。在初始化阶段,JVM 真正开始执行程序员编写的 Java 代码——即执行类构造器 <clinit> 方法。

<clinit> 方法是由编译器自动收集类中所有静态变量的赋值动作和静态代码块(static {} 块)中的语句合并产生的。编译器收集的顺序与语句在源文件中出现的顺序一致。注意:<clinit> 和 <init> 是两个不同的方法——<clinit> 是类初始化方法(只执行一次),<init> 是对象实例化方法(每次 new 都执行)。

JVM 保证一个类的 <clinit> 方法在多线程环境中被正确地加锁和同步——如果多个线程同时尝试初始化一个类,只有一个线程会执行 <clinit> 方法,其他线程会被阻塞等待,直到 <clinit> 执行完毕。但要小心:如果 <clinit> 方法中存在死锁或长时间阻塞,其他线程将永远等待,造成应用卡死。

触发初始化的时机

只有以下六种情况会触发类的初始化(主动引用):

  1. 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时
  2. 使用 java.lang.reflect 包的方法对类进行反射调用时
  3. 初始化一个子类时,如果父类还未初始化,先触发父类初始化
  4. JVM 启动时,包含 main() 方法的主类会首先被初始化
  5. 使用 JDK 7 动态语言支持时,如果 java.lang.invoke.MethodHandle 实例的解析结果对应的类尚未初始化
  6. 接口中定义了 default 方法时,接口的实现类初始化前会触发接口初始化

其他所有引用类型的方式都不会触发初始化,称为被动引用。比如:通过子类引用父类的静态字段不会触发子类初始化、通过数组定义引用类不会触发初始化、引用常量(final static)不会触发初始化。

文章配图
文章配图

七、卸载:类从内存中消失

卸载是类生命周期的最后一个阶段。一个类要被卸载,必须同时满足三个条件:

  1. 该类的所有实例都已经被 GC 回收。
  2. 加载该类的类加载器(ClassLoader)已经被 GC 回收。
  3. 该类对应的 java.lang.Class 对象没有被任何地方引用。

即使满足这三个条件,JVM 也只是"允许"卸载,而不会强制卸载——是否真的卸载取决于 GC 策略和参数配置。在大量使用动态代理(如 Spring AOP、CGLIB)、热部署(如 OSGi、DevTools)的场景中,类的卸载机制直接影响着元空间的大小和 GC 行为。

需要注意的是,由 JVM 引导类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)和系统类加载器(Application ClassLoader)加载的类,在 JVM 生命周期中几乎不会卸载——因为这些类加载器本身永远不会被 GC 回收。只有用户自定义类加载器加载的类才有可能被卸载。

八、面试追问链

从"类加载生命周期包含哪几个阶段"这个基础问题出发,逐层追问:加载阶段具体完成哪三件事、验证阶段包括哪四个子阶段各自检查什么、准备阶段做了什么为什么静态变量只赋零值 final 常量为什么例外、解析阶段将什么替换为什么什么时候可能发生、初始化阶段执行什么 <clinit> 和 <init> 有什么区别、哪些情况会触发类初始化(主动引用)哪些不会(被动引用)、一个类需要满足什么条件才会被卸载为什么引导类加载器加载的类几乎不会被卸载、类加载过程中哪些阶段可能发生交叉混合进行。

文章配图
文章配图

延伸阅读

  • 《Java 虚拟机规范》(Java SE 21 Edition)第 5 章 Loading, Linking, and Initializing,理解 JVM 规范层面的类加载定义
  • 《深入理解 Java 虚拟机》(周志明)第 7 章,详细介绍了类加载机制和双亲委派模型
  • 「04 JVM 原理与调优」系列下一篇(056)将深入类加载器与双亲委派模型

问答(不计入阅读时长)

Q1:类加载生命周期中,为什么说"解析阶段可能在初始化之后才开始"?这和 Java 的什么语言特性有关?

A:这与 Java 的"运行时绑定"(晚期绑定 / Late Binding)特性有关。JVM 规范并没有强制要求解析阶段必须在初始化之前完成,只要求在执行某些特定指令(如 new、getstatic、invokevirtual 等)之前完成符号引用到直接引用的解析。这意味着:如果一个符号引用直到初始化阶段之后才被第一次使用,那么它的解析完全可以发生在初始化之后。比如,一个类中调用了某个方法,但这个方法所在的代码路径在初始化阶段并没有被执行到(比如在 if 分支中),那么这个方法对应的符号引用解析就可以推迟到真正调用时才发生。这种灵活性让 JVM 可以按需解析,避免在类加载时一次性解析所有符号引用带来的时间和内存开销。

Q2:准备阶段,**public static int value = 123**和**public static final int value = 123**的处理有什么区别?为什么会有这种区别?

A:public static int value = 123 在准备阶段被赋值为 0(int 的零值),代码中的 123 是在初始化阶段才通过 putstatic 指令赋值的。而 public static final int value = 123 在准备阶段就被赋值为 123。区别的原因在于:final 修饰的常量如果同时是基本类型或字符串字面量,javac 在编译时会在 class 文件的字段表中为它生成 ConstantValue 属性,这个属性直接记录了字段的初始值。JVM 在准备阶段读取 ConstantValue 属性,直接完成赋值,不需要等到初始化阶段。但注意:如果 final 字段的值是运行时才能确定的(如 public static final int value = (int) System.currentTimeMillis()),编译器不会生成 ConstantValue 属性,这个字段仍然在初始化阶段才赋值。

Q3:**<clinit>**和**<init>**有什么区别?如果类中没有静态代码块和静态变量赋值,**<clinit>**方法还存在吗?

A:<clinit> 是类构造器(Class Initializer),在类初始化阶段由 JVM 调用,只执行一次,且由 JVM 保证线程安全。<init> 是实例构造器(Instance Initializer),在每次 new 对象时调用,可以多次执行。如果类中没有静态变量赋值动作和静态代码块,编译器可能不会生成 <clinit> 方法——这不是必需的。但 <init> 方法一定会存在,因为即使没有显式定义构造方法,编译器也会生成一个默认的无参构造方法,并且 <init> 方法中始终包含对父类 <init> 的调用(super())。两者的另一个关键区别:<clinit> 不需要显式调用父类的 <clinit>,JVM 会自动保证在子类的 <clinit> 执行前,父类的 <clinit> 已经执行完毕;而 <init> 必须在方法体开头显式(或隐式)调用 super()。

Q4:通过子类引用父类定义的静态字段,子类会被初始化吗?为什么?

A:不会。比如 class Parent { static int x = 1; } 和 class Child extends Parent {},执行 Child.x 时,JVM 只会初始化 Parent,不会初始化 Child。因为 x 字段定义在 Parent 中,getstatic 指令的目标类是 Parent,JVM 只需初始化 Parent 就足够了。JVM 规范明确规定:通过子类引用父类的静态字段,只会触发父类的初始化,不会触发子类的初始化。这是面试中的高频考点,也是理解"被动引用"概念的关键案例。但需要注意:如果子类中定义了同名的静态字段(字段隐藏),那么通过子类引用这个字段时,会触发子类的初始化。

Q5:多线程同时触发一个类的初始化时,JVM 如何保证**<clinit>**只执行一次?如果**<clinit>**方法中发生了死锁,会发生什么?

A:JVM 在类初始化时使用了一个同步锁机制。每个类或接口都有一个初始化锁(LC,Loading Class),第一个尝试初始化该类的线程获取锁并执行 <clinit> 方法,其他线程在获取锁失败后进入阻塞等待,直到 <clinit> 执行完毕释放锁。如果 <clinit> 方法中发生了死锁(比如两个类的 <clinit> 互相依赖对方先初始化),那么阻塞等待的线程永远不会被唤醒,最终导致应用卡死。这是一个很隐蔽的 bug:栈上不会有任何线程持有锁的明显证据,只能通过线程 dump 看到多个线程都在等待类初始化完成。JVM 本身没有超时机制来打破这种死锁,所以避免在 <clinit> 中做可能导致循环依赖的操作是非常重要的。

Q6:为什么由引导类加载器(Bootstrap ClassLoader)加载的类几乎不会被卸载?类卸载在什么场景下真正有价值?

A:类卸载的前提是加载该类的 ClassLoader 被 GC 回收。引导类加载器是 JVM 的一部分,由 C++ 实现,在 JVM 整个生命周期中一直存活,永远不会被 GC 回收,因此它加载的类(如 java.lang.String、java.util.ArrayList 等核心类)也永远不会被卸载。类卸载真正有价值的场景是:大量使用用户自定义类加载器的场景,比如动态代理(CGLIB 生成大量代理类)、热部署(OSGi 容器中模块的动态加载和卸载)、脚本引擎(Groovy、JRuby 动态编译和加载类)。在这些场景中,如果没有类卸载机制,元空间会持续膨胀,最终导致 OOM——这也是 JDK 8 将永久代替换为元空间的部分原因,元空间在本地内存中,默认无上限,但配合类卸载机制可以更灵活地管理。

Q7:验证阶段的字节码验证是最复杂的,JDK 6 引入的 StackMapTable 是如何优化这个过程的?

A:在 JDK 6 之前,字节码验证器需要完整地模拟方法的执行,通过数据流分析来推断每个指令位置上操作数栈和局部变量表的类型状态,这个过程非常耗时(尤其是对大型方法)。JDK 6 引入了 StackMapTable 属性:编译器在编译时,在 class 文件中记录下方法中某些关键位置(如跳转目标、异常处理器入口等)的"类型状态快照"——即该位置的操作数栈和局部变量表中每个槽位的确切类型。类型检查阶段(Type Checking)的验证器不再需要模拟方法执行,只需要验证这些快照之间的一致性,确保从一个快照到下一个快照的路径上,字节码指令不会产生类型不匹配。这种"基于类型状态推导"的验证方式(Type Checking Verifier)比传统的"类型推导"(Type Inference Verifier)速度更快,且可以一次验证一个方法,不需要考虑跨方法的影响。

Q8:类加载的七个阶段中,哪些是 JVM 必须严格按顺序"开始"的?哪些可以交叉混合进行?"开始"和"完成"有什么区别?

A:加载、验证、准备、初始化、卸载这五个阶段必须严格按顺序开始——即下一个阶段必须在上一阶段开始之后才能开始。但"解析"阶段比较特殊,它可能在初始化阶段之后才开始(为了支持晚期绑定/动态绑定)。这里的"开始"不等于"完成"——这些阶段在执行过程中通常是交叉混合进行的。比如:加载阶段还没有完全结束(class 文件还在读取中),验证阶段可能就已经开始了(一边读取字节流一边做文件格式验证);准备阶段和验证阶段也可能交叉进行(在准备阶段分配内存的同时,元数据验证也在并行进行)。这种交叉混合的设计是为了提高类加载的效率,避免严格的串行执行带来的时间浪费。只有"开始"顺序是规范要求的,"完成"顺序没有强制约束。