类已经加载了,为什么静态代码还没执行?

资深 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 就认定缺依赖。

参考资料

规范按 Java SE 21、源码按 OpenJDK jdk-21+35 说明;文中兼容 Java 8 的例子使用 Zulu 8 验证。

相关推荐
337801 小时前
5分钟搞懂Spring的IoC和Bean管理
java
学到老活到老1 小时前
写了 new,就一定会在堆上分配对象吗?
java·jvm
Web极客码1 小时前
决策模型 + LLM:用 Python 拆解浏览器自动化工作流
java·python·自动化
飞天小牛肉1 小时前
线程池明明没到最大线程数,为什么任务还在排队?
java
用户072367066601 小时前
用 Groovy 做动态 Mock:参数分支、造真数据、模拟异常
java·groovy
yuniko-n1 小时前
【JUC】内存屏障
java·开发语言
泡茶喝茶写代码1 小时前
A股量化策略回测实战:从行情数据到绩效评估(第 13 篇):放量突破策略
java·python·股票数据api·股票数据api接口·股票量化数据api·股票量化数据接口
文丑颜不良啊1 小时前
LeetCode626~650题目解析
java·leetcode
tianbin9112 小时前
批处理与微调辅助场景:Claude 中转并发、链路缓存与压测对比
java·开发语言·缓存