类加载机制
一个 .class 文件从磁盘进入 JVM,要经过加载、验证、准备、解析、初始化五个阶段,之后才可能被使用和卸载。理解这个过程,才能解释静态代码块为什么在特定时机执行、static final 常量为什么不触发初始化、类加载器泄漏如何导致元空间 OOM。本文以 JDK 8 与 JDK 17 为基准,按阶段拆解。
类的生命周期
类的完整生命周期包括七个阶段:
加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载
其中验证、准备、解析统称连接。加载、验证、准备、初始化和卸载的顺序是确定的,解析阶段为了支持动态绑定,可以在初始化之后才开始(运行时绑定)。
加载
加载阶段做三件事:
- 通过类的全限定名获取定义此类的二进制字节流。
- 把字节流中的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表该类的
java.lang.Class对象,作为方法区数据的访问入口。
第一步「获取二进制字节流」是各种黑科技的发源地:从 ZIP 包读取(JAR)、从网络读取(Applet)、运行时计算生成(动态代理)、从其他文件读取(JSP 转 Servlet),都由自定义类加载器实现。
数组类不通过类加载器创建,而是由 JVM 直接在内存中生成,其元素类型由类加载器加载。
验证
验证阶段确保字节流符合规范,不会危害虚拟机安全。主要包括:
- 文件格式验证:魔数是否为
0xCAFEBABE、主次版本号是否在支持范围内、常量池索引是否越界。 - 元数据验证:是否有父类、是否继承了 final 类、抽象方法是否实现。
- 字节码验证:操作数栈类型是否匹配、跳转指令是否指向方法体内。
- 符号引用验证:解析阶段发生的检查,确认引用的类、字段、方法确实存在且可访问。
验证阶段可以通过 -Xverify:none 关闭(JDK 13 起该参数被移除,改用 -XX:-BytecodeVerificationRemote 等),能缩短启动时间,但生产环境不建议关闭。
准备
准备阶段为类变量(static 变量)分配内存并设置零值。注意两个细节:
- 分配的是类变量,不包括实例变量,实例变量随对象分配在堆中。
- 设置的是零值,不是代码中写的初始值。
public static int value = 123在准备阶段后是 0,赋值 123 发生在初始化阶段的<clinit>中。
例外是 static final 常量:public static final int VALUE = 123 在准备阶段就赋值为 123,因为常量在编译期已放入常量池。
JDK 7 起,类变量随 Class 对象一起放在堆中;JDK 8 起方法区由元空间实现,类元数据在本地内存,但静态变量仍在堆里。
解析
解析阶段把常量池中的符号引用替换为直接引用。符号引用是一组描述目标的字符串,直接引用是内存地址或偏移量。
解析可以发生在初始化之前(静态解析),也可以在首次使用时进行(动态解析)。invokedynamic 指令就是为动态语言支持设计的,解析结果在第一次调用时由 BootstrapMethod 决定。
初始化
初始化是执行类构造器 <clinit> 方法的过程,也是唯一由代码逻辑控制的阶段。<clinit> 由编译器自动收集类中所有静态变量的赋值动作和静态代码块合并生成,顺序与源文件中的顺序一致。
注意:静态代码块只能访问定义在它之前的静态变量,定义在之后的变量可以赋值但不能读取。这是「非法前向引用」的编译期规则。
触发初始化的六种情况
JVM 规范规定了「有且只有」以下六种情况会触发初始化:
- 遇到
new、getstatic、putstatic、invokestatic四条字节码指令。 - 使用
java.lang.reflect包的方法对类进行反射调用。 - 初始化一个类时,其父类尚未初始化,先初始化父类。
- 虚拟机启动时,用户指定的主类(含
main方法)。 - 使用
MethodHandle解析到REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄。 - JDK 8 起,接口定义了 default 方法,其实现类初始化时接口要先初始化。
以下情况不会触发初始化,是面试和排障的高频考点:
- 通过子类引用父类的静态字段,只初始化父类。
- 通过数组定义引用类,如
User[] users = new User[10],不初始化User。 - 引用
static final常量,编译期已存入调用方常量池。 - 使用
ClassLoader.loadClass()加载类,只完成加载和连接,不初始化。
public class InitOrder {
static class Parent {
static { System.out.println("Parent init"); }
static int value = 10;
}
static class Child extends Parent {
static { System.out.println("Child init"); }
}
public static void main(String[] args) {
System.out.println(Child.value);
Child[] arr = new Child[3];
System.out.println("array created");
}
}
输出只有 Parent init 和 10、array created,Child 从未初始化。这就是「父类静态字段不触发子类初始化」和「数组不触发元素类初始化」的实证。
线程安全
JVM 保证 <clinit> 在多线程环境下正确加锁同步。如果多个线程同时初始化一个类,只有一个线程执行,其余阻塞等待。如果初始化过程中抛出异常,该异常会被包装为 ExceptionInInitializerError,后续线程访问该类会得到 NoClassDefFoundError。
这个机制是「静态内部类实现单例」的原理:
public class Singleton {
private Singleton() {}
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
Holder 在首次调用 getInstance() 时才初始化,由 JVM 保证线程安全,且没有同步开销。
类加载器
JVM 中有三类内置加载器:
| 加载器 | 加载路径 | 实现语言 |
| --- | --- | --- |
| Bootstrap ClassLoader | JAVA_HOME/lib 下的核心类库 | C++ |
| Extension / Platform ClassLoader | JDK 8:JAVA_HOME/lib/ext;JDK 9+:平台模块 | Java |
| Application ClassLoader | classpath | Java |
JDK 9 模块化后,扩展类加载器改名为平台类加载器(Platform ClassLoader),负责加载 java.se 等平台模块;核心类库的加载方式也从 rt.jar 变为模块系统。
自定义类加载器只需继承 ClassLoader 并重写 findClass:
public class HotClassLoader extends ClassLoader {
private final Path classDir;
public HotClassLoader(Path classDir, ClassLoader parent) {
super(parent);
this.classDir = classDir;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] bytes = Files.readAllBytes(classDir.resolve(name.replace('.', '/') + ".class"));
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
}
热部署正是通过新建一个类加载器加载同名类实现的:同一个类被不同加载器加载后,在 JVM 中就是两个不同的类,instanceof 判断会失败,类型转换会抛 ClassCastException。
类的卸载
类被卸载需要同时满足三个条件:
- 该类的所有实例都已被回收。
- 加载该类的
ClassLoader已被回收。 - 该类的
Class对象没有任何引用(包括反射、JNI、静态字段)。
Bootstrap 加载的类基本不会被卸载。自定义类加载器加载的类在热部署场景下会频繁卸载,这也是元空间需要限制的原因。
常见坑 / 最佳实践
- 在静态代码块中抛异常。异常被包装为
ExceptionInInitializerError,之后任何访问都变成NoClassDefFoundError,堆栈会丢失原始原因,排查成本极高。静态初始化应尽量简单,把复杂逻辑移到方法中懒加载。 - 依赖
static final常量做动态配置。常量在编译期被内联到调用方,修改常量所在类并重新编译,调用方不重新编译就看不到新值。配置类不应使用static final基本类型或字符串。 - 自定义类加载器忘记关闭资源或持有外部引用。类加载器被
ThreadLocal、线程池、JMX、JDBC Driver 持有时无法回收,导致元空间持续增长。Tomcat 热部署后Metaspace上涨是典型案例。 - 用
Class.forName与loadClass混用。前者默认触发初始化,后者不触发。框架中加载驱动、SPI 实现时这个差异会直接影响静态代码块是否执行。 - 在 JDK 17 上沿用
-Xbootclasspath覆盖核心类。模块化后核心类被封装,该参数已移除或受限,应改用--patch-module或-javaagent。
小结
- 类生命周期为加载、验证、准备、解析、初始化、使用、卸载,前五个阶段的顺序固定。
- 准备阶段只分配零值,
static final常量是唯一例外。 - 初始化由
<clinit>完成,只有六种情况会触发,数组和常量引用不会。 - JVM 保证
<clinit>线程安全,这是静态内部类单例的基础。 - 类卸载要求实例、类加载器、Class 对象三者都不可达,元空间 OOM 多半源于类加载器泄漏。
- 点赞
- 收藏
- 关注作者
评论(0)