图 1:本文封面,六步流水线的颜色区分两个阶段。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「new 一个子类,静态块、实例块、构造器谁先谁后」是 Java 面试的钉子户,答案一句话能背完,但面试官真正想听的是:为什么是两个阶段,而不是一条六步的直线。
先拆阶段:类初始化一次,实例初始化每次。
new 一个子类对象,背后其实发生了两场初始化,规则完全不同。
第一场是类初始化(对应字节码里的 <clinit>)。Java 语言规范规定,类在首次主动使用时初始化,且先初始化父类:父类的静态字段赋值与静态块按声明顺序执行,然后才轮到子类的。整个过程在类的生命周期里只跑一次——第二次、第三次 new,静态块都不会再出现。
第二场是实例初始化(对应 <init>),每次 new 都完整跑一遍。语言规范第 12.5 节给出的构造器处理流程是:先递归调用父类构造器(显式或隐式的 super),父类构造器执行到它的第 4 步时执行父类的实例字段赋值与实例块,然后执行父类构造器体;回到子类后,执行子类的实例字段赋值与实例块,最后才是子类构造器体。
两场拼起来,口诀就成立了:先父后子、先静态后实例、块先于构造器。对应输出就是:父类静态块、子类静态块、父类实例块、父类构造器、子类实例块、子类构造器。
最容易踩的坑:构造器里调用可覆写方法。
注意上面的顺序:父类构造器执行时,子类的实例字段还没赋值——它们要等父类构造器返回后才初始化。如果父类构造器里调用的方法恰好被子类覆写,语言规范明确说:与 C++ 不同,Java 在对象初始化期间不做特殊分派处理,覆写方法会被直接调用,哪怕此时对象还没初始化完。子类方法此时读到的字段是默认值(null、0),而不是你以为的初始值——这就是「半初始化状态」陷阱。
用代码把顺序和陷阱一起跑出来。
下面这段程序把六步顺序打印出来,并让父类构造器调用一个被子类覆写的方法,观察子类字段的取值。用 JDK 17 编译运行(javac InitOrder.java && java InitOrder):
public class InitOrder {
static class Parent {
static { System.out.println("1. 父类静态块"); }
{ System.out.println("3. 父类实例块"); }
Parent() {
System.out.println("4. 父类构造器");
describe(); // 经典陷阱:构造器里调用可覆写方法
}
void describe() { System.out.println(" Parent.describe 被调用"); }
}
static class Child extends Parent {
static { System.out.println("2. 子类静态块"); }
private final String tag = buildTag(); // 非常量表达式,不参与编译期内联
static String buildTag() { return "子类字段已就绪"; }
{ System.out.println("5. 子类实例块"); }
Child() { System.out.println("6. 子类构造器"); }
@Override
void describe() { System.out.println(" Child.describe 被调用,tag = " + tag); }
}
public static void main(String[] args) {
System.out.println("== 第一次 new Child() ==");
new Child();
System.out.println("== 第二次 new Child()(静态块不再执行)==");
new Child();
}
}
实测输出:
== 第一次 new Child() ==
1. 父类静态块
2. 子类静态块
3. 父类实例块
4. 父类构造器
Child.describe 被调用,tag = null
5. 子类实例块
6. 子类构造器
== 第二次 new Child()(静态块不再执行)==
3. 父类实例块
4. 父类构造器
Child.describe 被调用,tag = null
5. 子类实例块
6. 子类构造器
输出里有三个信息点。第一,六步顺序与口诀一致。第二,第二次 new 时静态块消失了——类初始化只跑一次,实例初始化每次都跑,两个阶段的分野被第二次运行直接坐实。第三,父类构造器里调用的确实是子类的覆写方法,而此刻 tag = null:子类的字段赋值(第 5 步之前的那次实例字段初始化)还没轮到。顺带一提代码里 tag 用 buildTag() 赋值而不是直接写字面量,因为编译期常量会被内联进调用处,陷阱就复现不出来了——这个细节本身也是个考点。
图 2:两阶段时间轴与半初始化陷阱的位置。
工程里这个坑在哪里出现。
框架装配与插件体系大量依赖继承初始化顺序:自定义算子、拦截器、模板方法基类,如果基类构造器里调用了留给子类覆写的钩子方法,子类实例就会在字段未就绪时被回调。Spring 这类框架把初始化回调(如 afterPropertiesSet)放在依赖注入完成之后,正是为了绕开这个窗口期——看懂 JLS 的顺序,就看懂了框架为什么这样设计。
回到面试现场,建议的答题结构:先背出六步顺序,再主动拆成「类初始化 vs 实例初始化」两个阶段说明各自触发时机,最后补半初始化陷阱与「构造器别调可覆写方法」的工程建议。顺序是及格线,两个阶段和陷阱是加分项。你在基类构造器里踩过这个坑吗?欢迎讲讲当时怎么定位的。