资深 Java 面试到 Agent 开发工程师 · 第 11 集
类已经加载,静态代码也不一定执行。加载让 JVM 认识这个类;初始化才会执行静态字段的赋值代码和静态代码块。框架扫描到一个类,不等于这个类已经把连接、配置等准备好了。
1. 读到了常量,为什么没有初始化日志?
上一集讲对象为什么还占着内存。这次看对象所属的类:程序什么时候开始执行它的静态代码?
把下面的代码保存为 LoadingDemo.java:
java
class Config {
static final int LIMIT = 3;
static int timeout = initTimeout();
static int initTimeout() {
System.out.println("Config 初始化");
return 1000;
}
}
public class LoadingDemo {
public static void main(String[] args) throws Exception {
System.out.println(Config.LIMIT);
ClassLoader loader = LoadingDemo.class.getClassLoader();
Class<?> type = Class.forName("Config", false, loader);
System.out.println("已加载:" + type.getName());
Class.forName("Config", true, loader);
System.out.println(Config.timeout);
}
}
运行 javac LoadingDemo.java、java LoadingDemo,输出依次是:
text
3
已加载:Config
Config 初始化
1000
LIMIT 是 final 修饰的基本类型变量,用常量表达式赋值,属于编译期常量;String 类型也可以满足这套条件。编译 LoadingDemo 时,3 就写进了使用它的字节码;读取它不触发 Config 初始化。static final Integer LIMIT = 3 或 static final int LIMIT = readLimit() 都不属于这种常量。
三参数 Class.forName 中的 false 表示这次调用不要求初始化。返回以后可以获取类名,initTimeout() 仍没执行。传 true 才在这里触发初始化;以后正常使用这个已初始化的类,不会重复执行这段静态赋值。
2. 从 class 文件到静态代码,中间发生了什么

| 阶段 | JVM 做什么 | 对本例意味着什么 |
|---|---|---|
| 加载 | 找到类的二进制表示,建立运行时的类信息和对应的 Class 对象 |
JVM 已认识 Config,可以返回它的 Class |
| 链接:验证 | 检查字节码是否满足格式和类型安全等约束 | 不是任意一份字节都能作为 Java 类执行 |
| 链接:准备 | 为静态字段分配存储,先设默认值 | 普通 int 字段 timeout 此时是 0,还没调用 initTimeout() |
| 链接:解析 | 把按名字记录的类、字段、方法引用,转换成 JVM 能使用的具体引用 | 执行访问时能找到实际目标;解析可以按需发生 |
| 初始化 | 按规则处理常量字段,执行静态字段赋值和静态块 | 本例调用 initTimeout(),把 timeout 赋为 1000 |
加载必须早于链接,验证和准备必须在初始化前完成。解析可以延后到相关引用真正被使用时,所以不能画成"所有引用全部解析完,才执行任何静态代码"。
准备阶段的默认值,和源代码里的赋值是两回事。static int n = 7 中,准备先给 n 放 0;初始化时才执行赋成 7 的代码。编译期常量还有专门步骤:按 JVM 规范,带 ConstantValue 属性的静态常量字段,在初始化流程中、执行类初始化方法之前被赋常量值。它们不需要像 initTimeout() 一样运行普通赋值代码。
编译器通常把静态字段赋值和静态块合到一个名为 <clinit> 的类初始化方法中,按照它们在源文件中的顺序执行。它不是构造器;构造器 <init> 在创建实例时执行。只有常量赋值、没有其他静态初始化代码的类,也可能没有 <clinit>。
顺序会影响结果。例如 static int a = readB(); static int b = 7;,若 readB() 返回 b,初始化 a 时 b 还没赋成 7,所以 a 得到 0。通过方法读字段能出现这个现象;直接在字段初始化表达式里向后引用,可能被编译器判为非法前向引用。
3. 哪些操作会触发初始化?
| 操作 | 初始化情况 |
|---|---|
new Config() |
创建实例前要初始化对应类 |
| 读取或写入类声明的普通静态字段、调用它声明的静态方法 | 使用前要初始化声明它的类 |
Class.forName("Config"),或三参数版本传 true |
要求初始化;前者默认要求初始化 |
三参数 Class.forName 传 false、Config.class、创建 Config[] |
这些操作本身不触发 Config 初始化 |
读取前面的 Config.LIMIT |
使用编译期常量,不触发声明类初始化 |
一个子类访问写成 Child.value,若字段实际上声明在 Parent,这次访问只要求初始化 Parent,不会因此初始化 Child。
执行一个类的普通静态赋值和静态块前,要先完成父类初始化。接口有不同规则:初始化接口时,不会顺带初始化所有父接口;初始化一个类时,它直接或间接实现的、声明了默认方法的接口也需要按规则先初始化。不能把"父类先初始化"直接套成"所有父接口都先初始化"。
同一个类由多个线程同时首次使用时,JVM 会协调初始化。一个线程执行,其他需要初始化它的线程等待。因此,把慢网络请求写进静态块,会让这些线程一起等;若初始化代码又相互等待,还可能卡住。框架启动时发现类、第一次请求才触发它的初始化,这两件事也可能隔很久。
应用可以在启动时检查配置、建立必需连接,检查通过后再接流量。连接建立失败时报告具体原因,需要重试的由组件管理;应用停止时关闭连接。这样,启动失败、运行中恢复和停止都有对应的处理,不会全部挤在只执行一次的静态块里。
4. JVM 到哪里找这个类?
Class.forName 还接收一个类加载器,负责找到这个类。Java 21 中常见的是启动类加载器、平台类加载器和应用类加载器。它们分别承担核心类、平台类和应用类等加载任务;业务里的插件也可以有自己的加载器。
常见的双亲委派,是当前加载器先请父加载器尝试加载,父加载器找不到,当前加载器才自己找。"父"是加载器的委派关系,和 extends 的类继承关系不同。

OpenJDK 21 的 ClassLoader.loadClass 保留查找与委派分支如下,省略计时统计:
java
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器没找到,继续尝试当前加载器
}
if (c == null) {
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
findLoadedClass 检查这个加载器是否已自己加载过、或通过委派成功取得过这个类;parent.loadClass 向上委派;findClass 才是当前加载器自己寻找类的步骤。resolveClass 负责链接,不会因为名字里有 resolve 就执行静态块。
这个默认过程让应用能复用父加载器已经提供的类。双亲委派不是 JVM 对所有自定义加载器强制规定的唯一策略,插件隔离等场景可以采用不同查找顺序。
JVM 用"类名 + 定义它的加载器"区分类。两份同名类若由不同加载器分别定义,即使 class 文件相同,也可能在强制转换时抛 ClassCastException。
插件的公共接口应由双方都能访问的同一个加载器定义,再按隔离方案加载实现。遇到"同名类不能转换",可以打印两边的 getClassLoader(),检查接口是不是在插件里又打包了一份。删依赖或改加载顺序之前,先确认是哪两个加载器各自定义了它。
父加载器也不一定能找到插件目录里的实现。某些服务发现机制会借助线程上下文类加载器,也就是当前线程携带的一个加载器引用。比如 ServiceLoader.load(Service.class) 默认用它查找服务提供者;公共接口仍须保持类型一致。业务如果没有插件或容器隔离需求,通常无须自行修改它。
5. 类明明在包里,为什么仍报"找不到"?
| 异常 | 可能发生在哪一步 | 从哪里查 |
|---|---|---|
ClassNotFoundException |
显式按名字加载时,所用加载器找不到目标 | 核对全限定名、运行时依赖和实际使用的加载器 |
ExceptionInInitializerError |
静态初始化中发生的某些异常被包装后抛出 | 看 Caused by,找第一个失败的赋值或静态块 |
NoClassDefFoundError |
运行中无法得到所需类;也可能是此前初始化已经失败 | 看完整消息和更早的日志,不直接认定缺 JAR |
例如静态字段用 Integer.parseInt(...) 读取配置,配置内容不是数字。第一次触发初始化时,NumberFormatException 会被包装进 ExceptionInInitializerError。这个类会被记为初始化失败;同一份类后续再次被要求初始化,会得到 NoClassDefFoundError,并不会重新跑一次静态块。若初始化抛出的本来就是 Error,则不按这个包装方式处理。
这时补一份 JAR 没有用。要找到最早那次异常,修正配置和初始化代码。已经失败的这份类不能靠再调一次 Class.forName 恢复;常见处理是在修正后重启对应进程或重新部署,使类重新加载。把可恢复的外部操作移出静态初始化,才方便按业务规则处理重试。
常见面试题怎么答?
1. 类加载过程有哪些阶段?加载和初始化有什么区别?
加载建立类信息与 Class 对象;链接包括验证、准备和解析;初始化处理常量并执行静态字段赋值、静态块。准备先给静态字段默认值,源代码里的普通赋值在初始化执行。验证、准备必须在初始化前完成,解析可以按需延后。三参数 Class.forName 传 false 能加载类而不要求初始化,读取编译期常量也不触发声明类初始化。
2. 双亲委派怎么工作?为什么同名类可能不能互相转换?
默认 loadClass 先检查是否已有类,再委派父加载器,父加载器找不到才调用自己的 findClass。这样可以复用父加载器加载的类,但自定义加载器可以改变策略。JVM 以类名和定义加载器共同确定类型;同名类由不同加载器分别定义,仍是两个类型。排查要比较双方加载器,保证共享接口只有一个共同定义,不能只检查包名。
3. ClassNotFoundException 和 NoClassDefFoundError 有什么区别?
前者常在显式按名字加载、当前加载器找不到类时出现。后者是链接错误的一种,既可能与运行时缺少类有关,也可能来自类初始化失败后的再次使用。静态初始化先抛 ExceptionInInitializerError、以后抛 NoClassDefFoundError 的情况,要追最早的原因,修配置或代码并重新加载,不能只加 JAR。
读完自测
1. 把 LIMIT 改成 static final Integer LIMIT = 3,第一行输出前会发生什么?
参考要点:Integer 引用不属于这里的编译期常量变量。读取字段会触发 Config 初始化,先输出初始化日志,再输出 3。之后的反射调用不重复初始化。
2. 扫描阶段用了 Class.forName(name, false, loader),连接失败却在第一个请求才暴露,怎么改?
参考要点:扫描没有要求初始化;首次实际使用才可能执行静态连接代码。把连接建立放进显式组件启动流程,启动时检查必需配置和依赖,成功后接流量,失败时报告原因;连接还需有关闭和必要的重试处理。只把 false 改成 true 能提前暴露初始化失败,却没有解决连接生命周期与重试问题。
3. 服务先报初始化异常,之后一直说 Could not initialize class,重新加载依赖就能好吗?
参考要点:先看最早的 Caused by,例如配置值解析失败。已失败的类不会因下次访问自动重试。修正原因后重新部署或重启以重新加载;需要恢复重试的操作移出静态初始化。不能仅凭后来出现 NoClassDefFoundError 就认定缺依赖。
参考资料
- JLS 21:加载、链接与初始化
- JVMS 21:类的创建、加载、链接和初始化
- OpenJDK 21:ClassLoader 源码
- Java 21:Class.forName 文档
- Java 21:ServiceLoader 文档
规范按 Java SE 21、源码按 OpenJDK jdk-21+35 说明;文中兼容 Java 8 的例子使用 Zulu 8 验证。