跳到主要内容
约 8 分钟4 张卡

类加载

类加载将 Class 字节流转换为 JVM 可以使用的运行时类型。本文先介绍加载、验证、准备和解析各自做什么,再说明初始化的触发条件。重点是区分静态字段的零值与源码赋值,以及主动使用和被动引用。

1. 面试核心​

先记住这几条,再读正文
  • 加载阶段。 类加载包含加载、链接和初始化,链接又分为验证、准备和解析。卡 · 加载
  • 准备与初始化。 准备阶段为静态字段建立存储并设置零值,初始化阶段按源码顺序执行普通静态赋值和静态代码块。
  • 主动使用。 读取非常量静态字段会初始化实际声明该字段的类型。卡 · 初始化
  • 父类型初始化。 JVM 初始化类之前,会先初始化父类,以及声明默认方法的直接或间接父接口。卡 · 父类与默认方法接口
  • 编译期常量。 编译期常量可内联到调用方,读取时无需初始化声明类。卡 · 编译期常量与内联

2. 加载​

加载解决的是“怎样从字节流得到运行时类型”。对于普通类和接口,这个阶段完成三个动作:

  1. 根据二进制名称查找字节流,例如 com.example.Order。
  2. 根据字节流创建 JVM 内部的类或接口表示。
  3. 提供对应的 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.2 类查找异常与定义错误​

8.3 静态字段的赋值阶段​

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 字段赋值时,也需要初始化后才能得到值。

10. 参考资料​