深蓝黑背景封面:右侧大标题「new 一个子类,谁先谁后」,副标题「类初始化一次,实例初始化每次」;左侧原创示意图——一条纵向执行流水线六个编号步骤:父类静态块、子类静态块、父类实例块、父类构造器、子类实例块、子类构造器,前两个用紫色标注「类加载时只跑一次」,后四个用青色标注「每次 new 都跑」;左上标注「面试经典 · Java 后端」,右上标注「Day 02 · 10-30」 图 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() 赋值而不是直接写字面量,因为编译期常量会被内联进调用处,陷阱就复现不出来了——这个细节本身也是个考点。

左侧时间轴图:第一阶段「类初始化」在类首次主动使用时触发,按声明顺序执行父类再子类的静态字段赋值与静态块,整个类生命周期只跑一次;第二阶段「实例初始化」在每次 new 时执行,父类实例字段与实例块、父类构造器、子类实例字段与实例块、子类构造器依次进行。右上演示陷阱:父类构造器里调用了被子类覆写的方法,此时子类字段尚未赋初值,读到的是默认值——标注「半初始化状态」。底部总结卡:顺序口诀「先父后子、先静态后实例、块先于构造器」;框架装配与插件基类踩坑多源于此 图 2:两阶段时间轴与半初始化陷阱的位置。

工程里这个坑在哪里出现。

框架装配与插件体系大量依赖继承初始化顺序:自定义算子、拦截器、模板方法基类,如果基类构造器里调用了留给子类覆写的钩子方法,子类实例就会在字段未就绪时被回调。Spring 这类框架把初始化回调(如 afterPropertiesSet)放在依赖注入完成之后,正是为了绕开这个窗口期——看懂 JLS 的顺序,就看懂了框架为什么这样设计。

回到面试现场,建议的答题结构:先背出六步顺序,再主动拆成「类初始化 vs 实例初始化」两个阶段说明各自触发时机,最后补半初始化陷阱与「构造器别调可覆写方法」的工程建议。顺序是及格线,两个阶段和陷阱是加分项。你在基类构造器里踩过这个坑吗?欢迎讲讲当时怎么定位的。