类加载
类加载将 Class 字节流转换为 JVM 可以使用的运行时类型。本文先介绍加载、验证、准备和解析各自做什么,再说明初始化的触发条件。重点是区分静态字段的零值与源码赋值,以及主动使用和被动引用。
1. 面试核心
先记住这几条,再读正文- 加载阶段。 类加载包含加载、链接和初始化,链接又分为验证、准备和解析。卡 · 加载
- 准备与初始化。 准备阶段为静态字段建立存储并设置零值,初始化阶段按源码顺序执行普通静态赋值和静态代码块。
- 主动使用。 读取非常量静态字段会初始化实际声明该字段的类型。卡 · 初始化
- 父类型初始化。 JVM 初始化类之前,会先初始化父类,以及声明默认方法的直接或间接父接口。卡 · 父类与默认方法接口
- 编译期常量。 编译期常量可内联到调用方,读取时无需初始化声明类。卡 · 编译期常量与内联
2. 加载
加载解决的是“怎样从字节流得到运行时类型”。对于普通类和接口,这个阶段完成三个动作:
- 根据二进制名称查找字节流,例如
com.example.Order。 - 根据字节流创建 JVM 内部的类或接口表示。
- 提供对应的
Class<?>对象,让 Java 代码可以访问类型信息。
字节流可以来自文件、模块、网络、数据库或运行时生成器。JVM 约束字节流的内容和加载结果,因此来源不限于磁盘上的 .class 文件。
运行时类型由二进制名称和定义加载器共同确定,具体见双亲委派模型。
3. 验证
验证用于检查类定义和字节码是否满足 JVM 约束,避免执行不合法的指令。理解这一阶段时,需要区分格式检查、字节码验证与访问检查:
- 读取字节流时,JVM 检查魔数、版本、常量池和属性格式。魔数是文件开头用于识别 Class 格式的固定标记。格式不合法会产生
ClassFormatError,版本不支持会产生UnsupportedClassVersionError。 - 验证阶段检查类、字段和方法声明是否符合约束。它还检查指令边界,以及操作数栈和局部变量的类型是否匹配。字节码验证失败会产生
VerifyError。 - 解析阶段检查符号引用的目标及其访问权限。比如,调用方能否访问目标方法,要在解析该方法引用时判断。
这些错误都属于 LinkageError。具体异常说明了失败的位置,排查时应保留原始错误类型。
4. 准备
准备阶段先为静态字段建立存储,并设置类型对应的零值。此时还未执行源码中的普通赋值表达式:
static int retries = 3;
static Object cache = new Object();
准备完成后,retries 是 0,cache 是 null。到了初始化阶段,JVM 才把前者设为 3,并执行 new Object() 给后者赋值。
编译期常量的值可以直接记录在 Class 文件中,例如:
static final int MAX_RETRIES = 3;
编译器可用 ConstantValue 属性保存这个常量值。JVM 在初始化阶段读取该属性并赋值,随后才调用类初始化方法 <clinit>。调用方还可以把常量值直接写入自己的字节码,这称为内联。
4.1 编译期常量与内联
判断读取字段是否触发初始化,要看它是否属于常量变量。常量变量使用 final 声明,类型为基本类型或 String,初值是编译期可计算的常量表达式。下面把常见情况放在一起比较:
static final int LIMIT = 3; // 编译期常量。
static final String LABEL = "v" + 1; // 编译期常量。
static final Integer BOXED = 3; // 包装类型,不是常量变量。
static final int SIZE = loadSize(); // 方法调用,不是常量表达式。
读取 LIMIT 或 LABEL 时,调用方直接使用内联值,所以无需初始化声明类。BOXED 使用包装类型,SIZE 通过方法调用赋值,两者都需要初始化声明类。
内联也会影响升级:只替换常量所在的 JAR,旧调用方仍会使用旧值。要更新这部分值,需要重新编译调用方。
5. 解析
解析把字节码中的符号引用连接到实际的运行时目标。符号引用用名称和描述符表示目标,描述符记录字段类型或方法的参数、返回类型。JVM 据此查找类、字段和方法,再检查目标是否存在、签名是否匹配、访问是否合法。
解析可以延后到相应指令第一次使用符号引用时。也就是说,初始化之前必须完成验证和准备,部分引用则可以在初始化之后才解析。
编译时与运行时的依赖版本不一致,会导致目标缺失或成员形态不兼容。常见错误包括 NoSuchMethodError、NoSuchFieldError 和 IncompatibleClassChangeError。访问权限不合法则会产生 IllegalAccessError。
这些错误的定位方法见类加载故障。
6. 初始化
初始化负责执行类的静态初始化代码。编译器把需要执行的静态赋值和静态代码块按源码顺序放入 <clinit>,例如:
static final Map<String, String> CONFIG = loadConfig();
static {
registerMetrics();
}
本例初始化时先调用 loadConfig(),再调用 registerMetrics()。JVM 在首次主动使用时执行初始化,常见的触发操作包括:
- 用
new创建类的实例。 - 调用类声明的静态方法。
- 读取或写入类声明的非常量静态字段。
- 执行要求初始化的反射调用,或首次调用特定 MethodHandle。MethodHandle 是可直接调用的字段或方法句柄。
- 启动程序时,初始化包含入口方法的类。
访问静态字段时,JVM 只初始化实际声明它的类或接口。因此,通过子类名称读取父类静态字段,也只会因此初始化父类。引用 SomeType.class、创建该类型的引用数组、读取内联常量属于被动引用。它们无需初始化对应类型。
同一个运行时类型的初始化由 JVM 同步协调,成功完成后只执行一次。并发首次访问的等待机制见类加载故障。
6.1 父类与默认方法接口
类的初始化还会触发部分父类型的初始化。JVM 先初始化父类,再初始化声明默认方法的相关父接口。默认方法是接口中带方法体的实例方法,这里的父接口也包括间接继承的接口。
初始化接口本身时,只处理该接口,父接口无需随之全部初始化。下面用一个默认方法接口说明它与类初始化的关系:
interface Defaults {
int VALUE = init();
static int init() {
System.out.println("Defaults");
return 1;
}
default void run() {}
}
class Child implements Defaults {
static { System.out.println("Child"); }
}
class InitializationDemo {
public static void main(String[] args) {
new Child(); // 依次输出 Defaults、Child。
}
}
Child 声明的接口包含默认方法,所以本例依次输出 Defaults、Child。若移除 run(),本例只输出 Child。此时 Defaults 只声明静态字段和静态方法,创建 Child 无需初始化它。
7. 初始化失败
初始化失败后,当前 Class 会进入错误状态,后续主动使用得到 NoClassDefFoundError。因此,静态初始化中的外部 I/O 或复杂业务失败,会影响后续所有使用者。
首次异常、静态初始化示例与恢复方式见类加载故障。外部操作适合放入显式生命周期,原因见类初始化锁。
8. 常见问题
8.1 类加载 API 的初始化开关
Class.forName(name) 默认初始化目标类。需要延后初始化时,可以调用 Class.forName(name, false, loader),将 initialize 设为 false。ClassLoader.loadClass(name) 的默认实现只负责加载。
8.3 静态字段的赋值阶段
静态字段先在准备阶段获得零值,再在初始化阶段获得实际值。带 ConstantValue 的字段先赋常量值,随后执行 <clinit> 中的普通静态赋值和静态代码块。后两者遵循源码顺序。
9 · 面试题卡片
9.1 类加载包含哪些阶段,每个阶段做什么
出现公司:阿里巴巴、字节跳动
考察重点:说明加载、链接和初始化各自做什么,并区分零值、源码赋值与按需解析的时机。
参考回答
类加载包含加载、链接和初始化,链接又包含验证、准备和解析。
加载取得字节流并创建运行时类型。验证检查类定义与字节码是否合法,准备为静态字段建立存储并设置零值。解析再把符号引用连接到实际的类、字段或方法。
初始化先设置 ConstantValue 指定的常量值,再执行 <clinit> 中的静态赋值和静态代码块。初始化之前必须完成验证和准备;解析可以延后到具体引用被使用时。
9.2 类初始化在什么情况下触发
出现公司:阿里巴巴
考察重点:区分主动使用与被动引用,解释父类型的初始化顺序及初始化失败的后果。
参考回答
首次主动使用会触发初始化,常见操作是创建实例、调用静态方法和访问非常量静态字段。
程序入口类,以及要求初始化的反射和 MethodHandle 调用,也会触发初始化。
初始化类之前,JVM 会先初始化父类和声明默认方法的相关父接口。访问静态字段时,只初始化实际声明字段的类型。类字面量、引用数组和内联常量属于被动引用,无需初始化对应类型。
JVM 会同步协调同一类型的初始化。若初始化失败,该类型会进入错误状态,后续主动使用得到 NoClassDefFoundError。
9.3 初始化实现类时,哪些接口会先初始化?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释默认方法怎样影响父接口初始化,并区分类初始化和接口初始化。
相关内容:父类与默认方法接口。
参考回答
声明默认方法的直接或间接父接口,会在实现类初始化之前完成初始化。
因为这些接口有默认方法,JVM 会将它们纳入类的初始化流程。只声明抽象方法的接口无需因此初始化。若初始化的是接口本身,流程则只处理该接口,而非递归初始化全部父接口。
9.4 static final 字段都不会触发初始化吗?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:根据字段类型和赋值表达式,判断 final 字段是否属于编译期常量。
相关内容:编译期常量与内联。
参考回答
static final 字段中,只有编译期常量的读取无需初始化声明类。
它要求字段类型为基本类型或 String,并用常量表达式赋值。
比如,读取 static final Integer BOXED=3 仍会触发初始化,因为 Integer 是包装类型。用方法调用给 final 字段赋值时,也需要初始化后才能得到值。