在Java程序运行中,类的初始化顺序由JVM规范严格定义,它直接决定了静态资源何时就绪、实例字段何时赋值。很多诡异的空指针其实都源于对初始化阶段的误解。下面通过具体代码来拆解整个流程。

一、类的生命周期与初始化触发条件
Java类从被加载到虚拟机内存开始,会经历加载、链接(验证、准备、解析)、初始化、使用和卸载几个阶段。其中初始化阶段才是真正执行类构造器<clinit>()方法的过程,这个方法由编译器自动收集类中的所有静态变量赋值动作和静态语句块合并产生。
并不是类一被加载就立刻初始化。JVM规定只有在遇到new、getstatic、putstatic、invokestatic这四条字节码指令,或者反射调用,或者子类初始化触发父类初始化等“主动使用”场景时,才会执行初始化。如果只是声明一个类的数组类型,或者引用了类里编译期常量,都不会触发初始化。
二、静态部分的初始化顺序
在初始化阶段,JVM会先保证父类完全初始化,再初始化子类。同一个类内部,静态变量和静态代码块严格按照在源文件中出现的先后顺序执行。下面这段代码可以清晰看到顺序:
public class Parent {
public static String pStaticVar = initPVar();
static {
System.out.println("父类静态块执行");
}
private static String initPVar() {
System.out.println("父类静态变量赋值");
return "parent";
}
}
public class Child extends Parent {
public static String cStaticVar = initCVar();
static {
System.out.println("子类静态块执行");
}
private static String initCVar() {
System.out.println("子类静态变量赋值");
return "child";
}
}
当首次主动使用Child类时,控制台输出依次为:父类静态变量赋值、父类静态块执行、子类静态变量赋值、子类静态块执行。这说明父类静态内容整体优先于子类,且各自内部顺序与书写一致。
这种顺序带来的实际影响是,如果子类静态块依赖父类静态变量,由于父类先完成,所以不会有问题;但若父类静态块依赖子类才会设置的某个值,则必然拿到null。因此在架构设计上,应禁止父类静态逻辑反向依赖子类。
三、实例对象的初始化顺序
当使用new创建对象时,在类已初始化的前提下,会调用<init>()实例构造器。顺序为:父类实例变量赋值和父类实例块按序执行,然后父类构造器,接着子类实例变量和实例块按序执行,最后子类构造器本体。
public class Base {
private String bVar = "base_var";
{
System.out.println("父类实例块:" + bVar);
}
public Base() {
System.out.println("父类构造器");
}
}
public class Derived extends Base {
private String dVar = "derived_var";
{
System.out.println("子类实例块:" + dVar);
}
public Derived() {
System.out.println("子类构造器");
}
}
新建Derived对象后输出为:父类实例块:base_var、父类构造器、子类实例块:derived_var、子类构造器。可见实例字段的赋值早于构造器方法体,但晚于父类全部实例初始化。
这一机制常导致在父类构造器中调用被子类重写的方法时,子类字段还未赋值,从而读到默认值。因此编码规范要求:构造器中避免调用可重写方法,正是为了规避这种半初始化状态。
四、综合顺序与常见误区
把静态和实例放在一起,完整顺序是:父类静态变量与静态块、子类静态变量与静态块、父类实例变量与实例块、父类构造器、子类实例变量与实例块、子类构造器。下面用一张简表归纳:
| 阶段 | 执行内容 | 次数 |
|---|---|---|
| 类加载初始化 | 父类静态->子类静态 | 仅一次 |
| 对象实例化 | 父类实例->父类构造->子类实例->子类构造 | 每次new |
一个典型误区是认为静态块一定先于main方法执行。实际上main方法本身就在某个类的静态上下文中,该类在main被调用前已初始化,所以写在main前的静态块会先跑,但其他无关类的静态块不会自动执行。
另一个坑是接口中的静态变量。接口本身不要求子接口或实现类初始化时一并初始化父接口,只有在真正用到父接口静态字段时才触发。这和类继承的强制父类初始化不同,需要特别留意。
五、利用顺序保障配置安全加载
在真实项目里,常把数据库连接池参数放在静态块中读取配置文件。由于静态块只跑一次且早于任何实例创建,可以保证后续所有对象拿到的都是已就绪的连接池。示例:
public class DataSourceHolder {
private static final Properties cfg = new Properties();
static {
try {
cfg.load(DataSourceHolder.class.getResourceAsStream("/db.properties"));
System.out.println("配置加载完成:" + cfg.getProperty("url"));
} catch (Exception e) {
throw new ExceptionInInitializerError(e);
}
}
public static String getUrl() {
return cfg.getProperty("url");
}
}
如果配置加载失败,静态块抛出错误会使类初始化失败,JVM后续对该类的所有使用都会收到ExceptionInInitializerError,这比在对象里才发现连接地址为null要安全得多。
理解并善用这套顺序,不仅能写出可预测的启动逻辑,也能在排查“为什么值为空”“为什么块没执行”时迅速定位到是主动使用时机不对,还是继承链上的顺序预期错误。