类加载的五个阶段
一个类从 Class 文件变成可以使用的运行时类型,要经过加载、验证、准备、解析和初始化。前三步顺序明确,解析可以按 JVM 实现和实际使用延后,初始化只在发生主动使用时执行。
1. 加载创建运行时类型
加载阶段完成三件事:
- 根据二进制名称找到 Class 文件的字节流。
- 根据字节流创建 JVM 内部的类或接口表示。
- 向 Java 代码提供对应的
Class<?>对象。
字节流可以来自文件、模块、网络、数据库或运行时生成器。JVM 规范约束的是字节流内容和加载结果,不限制它必须来自磁盘上的 .class 文件。
类的运行时身份由二进制名称和定义它的类加载器共同决定。同名字节码由两个互不关联的类加载器定义后,是两个不同类型。
2. 验证拒绝不合法的 Class 文件
验证用于确认 Class 文件的结构和字节码满足 JVM 约束,常见检查包括:
- 魔数、版本、常量池和属性格式是否合法。
- 类、字段和方法声明是否满足约束。
- 指令对操作数栈与局部变量的使用是否类型安全。
- 符号引用在解析时是否可以合法访问目标。
验证失败会产生 VerifyError、ClassFormatError、UnsupportedClassVersionError 等 LinkageError。具体错误取决于失败发生的位置。
3. 准备为静态字段建立默认状态
准备阶段为类的静态字段建立存储并设置默认值:
static int retries = 3;
static Object cache = new Object();
准备完成后,它们通常还是 0 和 null。源码中的 3 和 new Object() 在初始化阶段执行。
编译期常量需要单独判断:
static final int MAX_RETRIES = 3;
这类字段可以通过 Class 文件中的 ConstantValue 属性在准备阶段获得值,调用方还可能把值直接内联到自己的 Class 文件里。
4. 解析把符号引用连接到运行时目标
常量池中的类、字段、方法和接口方法最初以符号形式存在。解析检查目标是否存在、签名是否匹配、调用方是否有访问权限,并形成运行时可用的引用。
JVM 允许按需解析,所以“解析一定在初始化前一次完成”过于绝对。规范保证类在初始化前已经完成验证和准备,但部分符号引用可以直到相应指令第一次执行才解析。
常见解析错误包括:
NoSuchMethodErrorNoSuchFieldErrorIllegalAccessErrorIncompatibleClassChangeError
它们通常意味着编译时使用的依赖和运行时实际加载的依赖不一致。
5. 初始化执行类初始化方法
编译器把静态字段赋值和静态代码块按源码顺序合并到类初始化方法中:
static final Map<String, String> CONFIG = loadConfig();
static {
registerMetrics();
}
第一次主动使用该类时,JVM 执行它的类初始化方法。初始化类之前会先初始化其父类;接口的初始化规则不同,不会因为初始化一个接口而自动初始化所有父接口。
常见主动使用包括:
- 创建类的实例。
- 调用类声明的静态方法。
- 读取或写入类声明的、不是编译期常量的静态字段。
- 某些反射和 MethodHandle 调用。
- 初始化入口类。
只引用 SomeType.class、创建该类型数组,或读取已经内联的编译期常量,通常不会触发初始化。
6. 初始化失败会被记住
如果静态初始化抛出非 Error 异常,第一次主动使用通常会看到 ExceptionInInitializerError。这个 Class 随后处于错误状态,其他主动使用会得到 NoClassDefFoundError: Could not initialize class ...。
final class Config {
static final URI ENDPOINT = URI.create(System.getenv("ENDPOINT"));
}
把外部连接、线程等待或复杂业务放进静态初始化,会让失败发生在难以控制的时刻,也会扩大启动和并发风险。更适合的做法是显式初始化并返回可观察结果。
7. 常见问题
7.1 Class.forName 和 ClassLoader.loadClass 一样吗
常用的 Class.forName(name) 会加载并初始化类;loadClass(name) 通常只加载,不主动初始化。Class.forName 也有接收 initialize 参数和指定类加载器的重载,判断时应看实际调用。
7.2 ClassNotFoundException 和 NoClassDefFoundError 有什么区别
ClassNotFoundException 是显式加载 API 找不到目标时抛出的受检异常。NoClassDefFoundError 是 JVM 在使用某个已在编译期存在的定义时无法得到它,原因可以是运行时缺少依赖,也可以是该类之前初始化失败。
7.3 静态字段在准备阶段还是初始化阶段赋值
默认零值在准备阶段建立;普通源码赋值和静态代码块在初始化阶段执行;带 ConstantValue 属性的编译期常量是例外。
8. 面试题
8.1 类加载包含哪些阶段,每个阶段做什么
出现公司:阿里巴巴、字节跳动
考察重点
- 加载、验证、准备、解析和初始化的职责。
- 准备阶段默认值与初始化阶段源码赋值的区别。
- 解析可以延后的规范边界。
相关内容:第 1 节“加载创建运行时类型”至第 5 节“初始化执行类初始化方法”。
参考回答
加载负责取得字节流并创建运行时类型;链接包含验证、准备和解析:验证检查 Class 与字节码,准备建立静态字段的默认状态,解析把符号引用连接到运行时目标。初始化再执行静态字段赋值和静态代码块。
不能把解析描述为永远一次性发生在初始化前。JVM 只要求初始化前完成验证和准备,符号引用可以按使用时机解析。
8.2 类初始化在什么情况下触发
出现公司:阿里巴巴
考察重点
- 主动使用和被动引用的区别。
- 父类、接口和编译期常量的初始化规则。
- 初始化失败后的错误状态。
相关内容:第 5 节“初始化执行类初始化方法”和第 6 节“初始化失败会被记住”。
参考回答
创建实例、调用类声明的静态方法、访问非编译期常量静态字段,以及某些反射调用都会触发初始化。初始化子类前先初始化父类。读取已内联的编译期常量、引用类字面量或创建数组通常不会触发目标类初始化。
初始化只执行一次并受同步保护。第一次初始化失败后,该类会被标记为错误状态,后续使用通常得到 NoClassDefFoundError。