做Java开发这些年,被面试官问过最多的基础题里,"new一个对象时,静态变量、成员变量、构造方法到底按什么顺序执行"绝对排得上号。这题表面一看人人都会背:"静态变量先执行,然后是成员变量,最后构造方法"。但把父子类、静态代码块、实例代码块这些条件加进来之后,能答完整的人就少了一大半。我第一次认真答这道题是在一场电话面试里,当时只说了"静态先行、构造最后",结果对面追问了一句:"那子类构造方法里的super()是在成员变量赋值之前还是之后?"我当场卡壳。后来花了整个下午写测试类、翻Java语言规范、用javap看字节码,才把这条链路彻底理顺。这篇文章就把我验证的过程和最终结论完整分享出来,正在准备Java面试的同学可以直接拿来当复习资料,已经在写业务代码的老开发,也能借此理清Spring Bean初始化时那些日志为什么按某种顺序打印。
1. 先把执行顺序的正解摆出来
1.1 一条主线:类初始化和实例初始化是两个阶段
要回答这道题,第一步是把问题拆成两半:一半是类加载阶段的初始化,另一半是new对象时的实例化。这两个阶段触发时机不同、执行内容不同、颗粒度也不同,混在一起谈必然乱。
类初始化,触发点是"类第一次被主动使用",比如new对象、访问类的静态字段、调用静态方法、用反射操作类。执行的内容只有两样:静态变量的显式赋值、静态代码块。实例初始化,触发点才是"new",执行内容是成员变量的显式赋值、实例代码块,以及构造方法。
所以完整答案应该是这样的:
首次创建某个类的对象时,JVM会先完成这个类的类初始化——具体来说,先初始化父类,再初始化当前类;父类和子类各自内部,静态变量和静态代码块按书写顺序从上到下执行。类初始化全部结束之后,才开始实例初始化:先执行父类的成员变量赋值和实例代码块、再执行父类构造方法,然后回到子类,执行子类的成员变量赋值和实例代码块、再执行子类构造方法。
这里的核心顺序可以理解为:类静态资源永远在对象诞生之前就绪,父类永远在子类之前就绪,成员变量初始化永远在构造方法体之前完成。
1.2 为什么是这个顺序:后面其实有规范在约束
很多面试题只让你背顺序,但真正理解背后逻辑,遇到变体题才不会慌。这个顺序不是某个框架约定的,而是Java虚拟机规范实打实规定的。
先说为什么静态先于实例。static修饰的字段属于类级别,存储在类元数据中,所有实例共享同一份。JVM的类加载生命周期里有一个专门的"准备"阶段,会为静态变量分配内存并设置默认值(int是0、引用是null、boolean是false),后面的"初始化"阶段再执行静态变量的显式赋值和静态代码块。也就是说,一个类的静态资源在类加载完成那一刻就已经彻底定稿,而对象此时可能一个都还没创建。既然静态资源是"类级资产",它自然要先于任何"对象级资产"就绪。
再说为什么父类先于子类。继承的本质是子类依赖父类的字段和方法,编译器在生成子类的 和 方法时,都会先把父类对应的初始化链路走完,再处理子类自身的内容。这个顺序既保证子类访问父类字段时父类数据可用,也保证子类构造方法里隐式调用super()时父类已经是一个完整状态。
最后说为什么成员变量赋值在构造方法之前。Java语言规范关于类实例创建的部分写得很清楚:new一个对象时,会先分配内存并清零,然后按文本顺序执行实例变量初始化和实例初始化块,最后才执行构造方法体。换句话说,构造方法里读到的成员变量,已经不是默认值了,它已经被显式赋值逻辑处理过。这个设计很实际——构造方法体里的业务逻辑往往依赖字段初值,如果字段还是null或0,构造方法根本没法正常工作。
2. 核心细节拆解:静态变量、成员变量、构造方法各自的位置
2.1 静态变量:所有实例共享的类级资产
静态变量的本质是"归属于整个类"的变量。它不存放在任何一个对象里,而是随类加载一起初始化,随类卸载一起消亡。所有实例访问它时,看到的都是同一个值,任何一个实例修改它,其他实例都能感知。
静态变量的初始化包含两个关键点时点:JVM类加载的"准备阶段"会把静态变量设置为默认值,这一步不执行任何Java代码;"初始化阶段"才按代码书写顺序执行显式赋值语句和静态代码块。这里的"书写顺序"是个容易忽略的细节——静态变量声明写在前面,就先执行这个变量的赋值;静态代码块写在前面,就先执行静态代码块。很多人背的"静态变量先于静态代码块"并不严格成立,严格答案是"谁写在前谁先执行"。
举个例子:
public class StaticOrder { static { System.out.println("静态代码块先执行"); } static int value = init(); static int init() { System.out.println("静态变量赋值后执行"); return 1; } }这段代码静态代码块写在静态变量前面,所以输出是先"静态代码块先执行",再"静态变量赋值后执行"。
静态变量还有一个经典的延伸知识点:static final修饰的基本类型和String字面量会被编译器当作编译期常量,直接在引用处内联。这意味着如果你用StaticOrder.CONST这种常量,类甚至都不会触发初始化,静态代码块自然不会执行。这个细节在排查"为什么静态块没跑"时特别有用。
2.2 成员变量:每个对象独立的内存快照
成员变量(实例变量)存储在堆内存的对象实例里,每个对象一份,互不干扰。它的初始化时机和静态变量完全不同——不是类加载时做的,而是每一次new对象时重新走一遍。
new对象时,JVM先给对象分配一块连续内存并把所有字段清零,这一步叫"零值初始化"。接着会进入构造器调用链:先调用父类构造器,父类构造器内部又会递归处理更上层的父类,每一层的成员变量显式赋值和实例代码块会在该层构造器体执行前完成。
很多人会有个误解:成员变量赋值先于构造方法,所以构造方法里一定能看到有效值。这个理解在大方向上没错,但有一个关键陷阱——如果某个成员变量的赋值逻辑依赖调用子类重写的方法,而构造方法里又提前调用了这个重写方法,就可能拿到null。为什么?因为父类构造器执行时,子类的成员变量赋值还没有执行,子类字段此刻只有零值。这个问题我在第三节踩坑部分会详细讲。
成员变量本身的执行顺序也是按书写顺序来的。成员变量声明和实例代码块是"合并排序"的关系——比如以下代码先写实例代码块再写成员变量赋值,实例代码块就先执行:
public class InstanceOrder { { System.out.println("实例代码块先执行"); } int field = init(); int init() { System.out.println("成员变量赋值后执行"); return 1; } }这一点在面试里经常作为"陷阱"出现,因为不少资料只会笼统地说"成员变量初始化在构造方法之前",并不会提醒你"成员变量和实例代码块之间还要看书写顺序"。
2.3 构造方法:初始化的最后一棒
构造方法是整个对象初始化链路的收尾动作。它主要负责三件事:调用父类构造器、完成当前类字段初始化和实例代码块之外的最后装配、以及执行业务层面的初始化逻辑。
从字节码角度看,构造方法编译后会生成一个名为 的方法。这个方法的指令顺序非常固定:先调用super()或this(),然后按文本顺序执行当前类的实例字段赋值和实例代码块,最后才是构造方法体里你写的那些代码。很多人以为"构造方法体就是构造方法的全部",实际只看到了最后一段。
构造方法还有两个常考的细节。第一,如果类没有显式声明任何构造方法,编译器会生成一个默认无参构造,它的第一行是super(),实际上是调用父类无参构造。第二,如果构造方法第一行是this(...)重载调用,当前类的成员变量初始化仍然会先完成,然后才进入被调用的那个构造方法体。也就是说,无论构造器入口是super还是this,字段初始化和实例代码块都在构造器体之前执行。
2.4 静态代码块与实例代码块:藏在排序里的隐形指针
代码块不属于任何方法,却能在初始化过程中被自动执行,原因是编译器把它们"打散合并"到了特定的方法里。静态代码块会被合入类初始化方法 ,实例代码块会被合入构造方法 。
这两个方法使用javap就能看到。类初始化方法 里的逻辑就是静态变量赋值语句和静态代码块的顺序拼接;构造方法 里的逻辑则是super()调用、成员变量赋值、实例代码块、构造器体四个部分的顺序拼接。换句话说,你写的代码块并不是"另起炉灶",而是被编译器插入到了框架固定的位置上。
搞清楚这一点,很多疑惑都能解开。比如"为什么静态代码块不能访问成员变量"——因为 执行时根本没有实例对象存在,没有this可用;"为什么两个实例代码块的执行顺序和书写顺序一致"——因为编译器就是按文本顺序把它们搬运进 的。代码块的本职是给初始化过程增加一种"无方法名、无参数、每次new都执行"的扩展点,适合放那些不管用哪个构造方法都必须执行的逻辑。
3. 实操验证:写代码、看字节码,把顺序钉死
3.1 多级继承的完整测试代码
光看理论容易记串,我建议所有人都亲手跑一遍下面的代码。我故意把静态代码块、成员变量声明的位置打乱,这样能直观看到"按书写顺序"到底是什么意思。
public class InitOrderDemo { public static void main(String[] args) { System.out.println("===== 第一次 new Son ====="); Son son = new Son(); System.out.println("===== 第二次 new Son ====="); Son son2 = new Son(); } } class GrandFather { { System.out.println("GrandFather 实例代码块"); } int gfField = print("GrandFather 成员变量"); static { System.out.println("GrandFather 静态代码块"); } static int gfStatic = print("GrandFather 静态变量"); GrandFather() { System.out.println("GrandFather 构造方法"); } static int print(String msg) { System.out.println(msg); return 1; } } class Father extends GrandFather { int fField = print("Father 成员变量"); static int fStatic = print("Father 静态变量"); static { System.out.println("Father 静态代码块"); } { System.out.println("Father 实例代码块"); } Father() { System.out.println("Father 构造方法"); } static int print(String msg) { System.out.println(msg); return 1; } } class Son extends Father { { System.out.println("Son 实例代码块"); } int sField = print("Son 成员变量"); static { System.out.println("Son 静态代码块"); } static int sStatic = print("Son 静态变量"); Son() { System.out.println("Son 构造方法"); } static int print(String msg) { System.out.println(msg); return 1; } }复制到本地直接运行,输出顺序就是最权威的答案。我再强调一次:为什么把部分代码块写在字段前面?因为这里故意测试"书写顺序"的作用。比如GrandFather类中实例代码块写在成员变量之前,所以实例初始化时先打"实例代码块",再打"成员变量";而Son类中实例代码块也写在成员变量之前,同样先实例代码块再成员变量。如果你把代码块挪到后面,输出顺序就会跟着反过来。
3.2 预期输出与执行顺序解读
运行结果如下(为了排版我整理了缩进):
===== 第一次 new Son ===== GrandFather 静态代码块 GrandFather 静态变量 Father 静态变量 Father 静态代码块 Son 静态代码块 Son 静态变量 GrandFather 实例代码块 GrandFather 成员变量 GrandFather 构造方法 Father 成员变量 Father 实例代码块 Father 构造方法 Son 实例代码块 Son 成员变量 Son 构造方法 ===== 第二次 new Son ===== GrandFather 实例代码块 GrandFather 成员变量 GrandFather 构造方法 Father 成员变量 Father 实例代码块 Father 构造方法 Son 实例代码块 Son 成员变量 Son 构造方法这个输出有三处特别值得注意。
第一,静态初始化只出现了一次。第二次new Son时,类已经加载并初始化过,JVM不会再执行任何静态代码。这是"类初始化只发生一次"的直观证据,和new多少次无关。
第二,第一次new Son时,静态区域完全走完才开始实例区域。类初始化的顺序是父类静态在前、子类静态在后;但实例初始化又从父类实例部分开始。所以你会看到"GrandFather静态... → Son静态... → GrandFather实例..."这种交错,其实本质是"所有类的类初始化整体完成后,才开始所有类的实例初始化"。
第三,Father和GrandFather内部的输出顺序差异。Father类里成员变量写在代码块之前,所以"成员变量"先于"实例代码块";GrandFather和Son里代码块写在字段前,所以"实例代码块"先于"成员变量"。这正好验证了那条容易忽略的规则:成员变量和实例代码块是同一层级按书写顺序执行的,谁也不天然优先。
3.3 用javap查看字节码,眼见为实
代码运行可以验证结果,但想看清楚"为什么编译器让它这样执行",还是得看字节码。JDK自带工具javap就能做到。
编译上面代码后,执行:
javap -c -p GrandFather.class重点看两个方法: 和 。
方法中可以看到静态代码块内容和静态变量赋值逻辑按源码顺序被字节码串起来。以GrandFather为例,先看到静态代码块里的打印语句相关字节码,再看到gfStatic字段赋值调用print方法,顺序和源码完全一致。 方法则更清晰,方法一开始就是invokespecial调用父类构造器,也就是super(),然后才是实例代码块和gfField字段赋值的相关字节码,最后才是构造方法体里打印语句对应的代码。
这一眼就能看出真相:构造方法 并不是"先有方法体再有字段赋值",而是"先super(),再字段和代码块,最后方法体"。字节码比任何博客都诚实,我强烈建议你自己跑一次javap,把 和 对照着看一遍,比背十遍面试答案都管用。
3.4 边界场景:new两次、反射、static final常量
验证完基础顺序,再补几个边界场景,这些也都是面试官喜欢“顺手加一问”的变体点。
第一个场景是new两次,前面已经验证:第一次new时执行全部静态初始化,第二次new只走实例初始化。这个特性的本质是JVM在类加载完成后给类做了一个"已初始化"标记,再次使用直接跳过。
第二个场景是静态方法new自身对象。假设有个类Test,main方法里执行new Test(),此时Test类还没有初始化。new会先触发Test的类初始化,等类初始化完全结束后再执行实例初始化。如果Test的静态代码块里又写了new Test(),就会陷入一个危险的自引用:类初始化还没结束就试图创建实例,虽然多数情况下能创建成功,但后续字段、静态资源的就绪状态可能不符合预期,属于极其容易踩坑的写法,正常人不会这么写,面试时能说清楚“类初始化未完成时创建实例有风险”就已经是加分项。
第三个场景是反射触发。使用Class.forName("Son")会触发Son的类初始化,和new一样会把静态块执行一遍;但如果用ClassLoader.getSystemClassLoader().loadClass("Son")这种方式,默认不会触发初始化,静态块也不会执行。两者差异在框架源码里经常是关键点。
第四个场景是static final常量。如果一个静态变量是static final的基本类型或者字符串字面量,比如static final int X = 10,编译器会把它内联到所有引用处,读取X根本不触发类加载和初始化。所以哪怕Test类里静态块写了一大堆,只要别人只访问X,静态块一次都不会跑。我在实际工作中就遇到过这种"静态块没执行"的排查,最后发现对方引用的是一个编译期常量。
4. 常见问题与面试变体速查
4.1 高频面试题与一句话答案
把这类问题上最常见的变体整理成一张表,面试前一小时翻一遍足够:
| 面试变体 | 一句话答案 |
|---|---|
| 静态变量和静态代码块谁先执行? | 看书写顺序,谁写在前谁先执行 |
| new子类对象时父类静态代码块会执行几次? | 整个程序生命周期最多一次 |
| 成员变量赋值和实例代码块谁先执行? | 也是看书写顺序 |
| 子类构造方法里第一行为什么必须调用super或this? | 确保父类初始化完成、当前类初始化链完整 |
| 静态代码块里能访问成员变量吗? | 不能,编译期间就报错,因为此时没有实例 |
| 构造方法里读取成员变量,能读到显式赋值后的值吗? | 能,字段初始化和实例代码块先于构造器体执行 |
| static final常量会触发类初始化吗? | 不会,编译期内联,连类加载都不触发 |
| 类初始化时父类和子类的顺序? | 先父类后子类,且每个类的静态部分只执行一次 |
这张表对应的原理,前面每一节基本都拆过。能用自己的话把每个答案展开讲清楚,面试这关就稳了。
4.2 实战踩坑记录
顺序问题的价值不只在于面试,写代码时理解不透一样会踩坑。分享三个我实际遇到过的案例。
第一个坑是构造方法里调用可重写方法。我曾在某个父类构造器里调用了this.init(),本意是让子类扩展初始化逻辑。当时父类定义了一个默认实现,某个子类重写init()并访问了自己的成员变量。运行后子类成员变量一直是null,排查了很久才发现:父类构造方法执行时,子类的成员变量赋值还没开始,此时子类字段只有零值初值。这个案例在Spring源码里也有相关设计考量,Spring特意避免在构造方法里调用可重写方法,而改用afterPropertiesSet之类的回调。规避方案很简单:不要在构造器里调用可重写方法,把扩展逻辑放到一个可覆盖的模板方法之外,或者用初始化回调。
第二个坑是静态变量相互依赖导致取到null。两个静态变量的初始化存在先后依赖,而书写顺序又把被依赖的变量写在了后面:
public class ConfusingOrder { static String a = b; // b此时还是null static String b = "hello"; }结果是a最终是null,因为执行a的赋值时,b的显式赋值还没执行,b还停留在准备阶段的默认值。这种代码编译不报错,运行时结果却和直觉相反。养成习惯:静态变量之间的初始化不要搞交叉依赖,如果实在有依赖,把被依赖者写前面,或者把赋值逻辑搬进静态代码块并严格排好顺序。
第三个坑是静态代码块里初始化集合、IO资源等操作。如果这些操作抛了异常,比如数据库连不上,JVM会把异常包装成ExceptionInInitializerError抛出,而且这个类从此标记为"初始化失败",后续每次使用都会抛NoClassDefFoundError。我在某个老项目里见过线上环境因为一个静态Map初始化时读了不存在的配置文件,导致整个功能模块不可用,重启才能恢复。静态初始化不是懒加载兜底,它更接近"一次失败,永久失败",做静态资源预热时一定要考虑降级和异常兜底。
4.3 排查顺序问题的一个实用工具包
真遇到诡异的初始化顺序问题,我的排查套路是三层递进:第一层,在关键位置加打印语句,静态块、实例块、构造方法各打一行,先看整体顺序;第二层,用javap -c -p看 和 的字节码确认编译器实际生成顺序;第三层,如果类比较多,用JVM参数-XX:+TraceClassLoading观察类的加载顺序,配合日志定位是谁先触发了谁的初始化。
还有一个加分技巧:重点关注框架场景下的初始化顺序,最典型的就是Spring中Bean的构造方法、字段注入、@PostConstruct之间的关系。Spring的Bean生命周期本质上也是在Java原生顺序基础上插入回调:先构造方法(此时成员变量初始化已完成),再依赖注入,然后才执行@PostConstruct。如果不理解原生顺序,就很容易误以为字段注入发生在构造方法之前,从而写出依赖注入字段的构造逻辑,最后拿到null。看Spring的AbstractAutowireCapableBeanFactory源码时会发现,它是在调用构造器创建实例后,才调用populateBean做属性填充。这个顺序和Java语言规范是一致的,底层顺序理解了,框架层面的生命周期也顺带通了。
最后再分享一个我个人至今受用的习惯:我写类时永远把静态资源放在类的最上方,按"静态变量、静态代码块、成员变量、构造方法、业务方法"排布。这不算什么高深技巧,但能有效避免交叉依赖和书写顺序混乱带来的问题。遇到复杂的继承结构,先用最小复现代码验证一遍执行顺序,再动正式代码。这比对着文档猜要快得多,也更让人放心。