ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java对象初始化顺序全解析:静态变量、成员变量与构造方法的执行链路

Java对象初始化顺序全解析:静态变量、成员变量与构造方法的执行链路 做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自身对象。假设有个类Testmain方法里执行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语言规范是一致的底层顺序理解了框架层面的生命周期也顺带通了。最后再分享一个我个人至今受用的习惯我写类时永远把静态资源放在类的最上方按静态变量、静态代码块、成员变量、构造方法、业务方法排布。这不算什么高深技巧但能有效避免交叉依赖和书写顺序混乱带来的问题。遇到复杂的继承结构先用最小复现代码验证一遍执行顺序再动正式代码。这比对着文档猜要快得多也更让人放心。
返回列表