讲讲JVM 的类加载完整生命周期
JVM(Java虚拟机)的类加载机制是Java语言实现“一次编写,到处运行”以及动态扩展的基础。一个类的完整生命周期包含 7 个阶段,分别是:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。
其中,验证、准备、解析这三个阶段统称为链接(Linking)。
为了方便记忆,你可以按照这个流程图来理解:加载 -> [验证 -> 准备 -> 解析](链接) -> 初始化 -> 使用 -> 卸载
下面我为你详细拆解每一个阶段的核心任务和底层原理。
1. 加载(Loading)
任务: 把 .class 文件(二进制字节流)搬到内存里,并在堆中生成一个代表这个类的 java.lang.Class 对象。
具体步骤:
- 通过类的全限定名获取定义此类的二进制字节流。
(注意:JVM并没有规定必须要从本地.class文件中获取,它可以来自 ZIP/JAR 包、网络传输、动态代理生成(如 CGLib)、JSP 编译等。) - 将这个字节流代表的静态存储结构转化为方法区(JDK 8 之后的元空间)的运行时数据结构。
- 在内存(堆区)中生成一个代表这个类的
java.lang.Class对象,作为程序访问方法区中这个类各种数据的“入口”。
核心参与者: 类加载器(ClassLoader)。这里会涉及到经典的“双亲委派模型”。
2. 链接(Linking)
链接阶段的作用是将载入到 JVM 的类数据整合到 JVM 的运行环境中去。它分为三个子阶段:
2.1 验证(Verification)
任务: 保证加载进来的字节流符合 JVM 规范,不会危害虚拟机的安全。
如果你的字节码是被恶意篡改过的,JVM 会在这个阶段拒绝加载并抛出 VerifyError。
- 文件格式验证: 检查开头是不是魔数
0xCAFEBABE,主次版本号是否在当前 JVM 接受范围内等。 - 元数据验证: 对字节码进行语义分析(例如:这个类是否有父类?是否继承了不允许被继承的
final类?非抽象类是否实现了所有的抽象方法?)。 - 字节码验证: 最复杂的一步,通过数据流和控制流分析,确保程序语义是合法、符合逻辑的(例如保证类型转换是安全的)。
- 符号引用验证: 确保后续的“解析”阶段能正常执行(例如看能否通过符号引用找到对应的类、方法、字段)。
2.2 准备(Preparation)—— (面试高频考点)
任务: 为类变量(静态变量,被 static 修饰的变量)分配内存,并设置默认初始值(零值)。
核心注意点:
- 这里只处理类变量,不处理实例变量。实例变量会在对象实例化时分配在堆中。
- 这里的“设置初始值”通常是指数据类型的零值(如
0,0L,null,false)。- 例如:
public static int value = 123;在这个阶段,value的值会被设为0,而不是123。真正的赋值123要等到后面的初始化阶段。
- 例如:
- 例外情况(常量): 如果变量被
final修饰(即常量),例如public static final int value = 123;,在编译时就会生成ConstantValue属性,JVM 在准备阶段就会直接把value赋值为123。
2.3 解析(Resolution)
任务: 将常量池内的符号引用替换为直接引用。
- 符号引用: 就是一组字符串,比如
java/lang/String,或者某个方法的名字和描述符。它只是用来无歧义地定位目标。 - 直接引用: 也就是可以直接指向目标的指针、相对偏移量或句柄。
- 简单来说,就是把代码里的那些“名字”变成内存里真正的“内存地址”。
3. 初始化(Initialization)
任务: 真正开始执行类中定义的 Java 代码。执行类的初始化方法 <clinit>()。
底层原理:
<clinit>()方法是什么? 它是编译器自动收集类中的所有静态变量的赋值动作和静态语句块(static {})合并产生的。- 执行顺序: 编译器收集的顺序是由语句在源文件中出现的次序决定的。
- 线程安全: JVM 会保证一个类的
<clinit>()方法在多线程环境中被正确地加锁、同步。如果多个线程同时去初始化一个类,只会有一个线程去执行,其他线程会被阻塞。(这就是为什么用静态内部类实现单例模式是绝对线程安全的)。 - 父类优先: JVM 会保证在子类的
<clinit>()执行前,父类的<clinit>()已经执行完毕。
什么时候会触发初始化?(主动引用)
JVM 规范严格规定了有且只有 6 种情况必须对类进行初始化:
- 遇到
new(实例化对象)、getstatic(读取静态字段)、putstatic(设置静态字段)、invokestatic(调用静态方法)这四条字节码指令时。 - 使用
java.lang.reflect包的方法对类进行反射调用时(如Class.forName("xxx"))。 - 初始化一个类时,如果发现其父类还没初始化,则需先触发父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含
main()方法的那个类),虚拟机会先初始化这个主类。 - 使用 JDK 7 开始提供的动态语言支持时,如果一个
java.lang.invoke.MethodHandle实例解析的结果为静态方法/字段的引用。 - 当一个接口中定义了 JDK 8 新加入的默认方法(被
default修饰),如果这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
(注:除此之外的所有引用类的方式都不会触发初始化,称为*被动引用。例如:通过子类引用父类的静态字段,只会触发父类的初始化;通过数组定义来引用类,不会触发该类的初始化。)*
4. 使用(Using)
类初始化完成后,这个类就可以在程序中使用了。开发者可以:
- 通过
new关键字创建该类的实例对象。 - 调用它的静态方法。
- 访问它的静态变量。
5. 卸载(Unloading)
任务: 当一个类不再被使用时,JVM 会将其清理出内存(方法区/元空间),回收资源。
触发条件极其苛刻,必须同时满足以下三个条件:
- 该类的所有实例都已经被回收: Java 堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收: 除非是自定义的类加载器,否则像 JDK 自带的启动类加载器、扩展类加载器、应用类加载器是不可能被回收的。
- 该类对应的
java.lang.Class对象没有在任何地方被引用: 保证无法在任何地方通过反射访问该类的方法。
应用场景:
通常只有在使用自定义类加载器的场景下(如 OSGi、JSP 的热部署/热插拔、Tomcat 为每个 Web 应用创建独立的类加载器),为了防止方法区内存泄漏(OOM),才会频繁涉及到类的卸载。普通的 Java 程序运行期间,类基本不会被卸载。
总结
理解类加载的生命周期,最重要的是区分 加载(生成 Class 对象)、准备(赋零值) 和 初始化(赋实际值、执行 static 块) 这三个关键节点。这也是排查 Java 程序中诡异的类冲突、空指针异常或静态变量状态异常的理论基础。