Android 面试走到高阶环节,考察方式会从"问知识点"切换成"沿着知识点连续追问"。构造方法与初始化顺序就是最常被选中的切入口:一层层问下去,知识的边界在哪里,候选人自己最清楚。这道题考的不是"能不能背出顺序",而是能不能讲清"为什么是这个顺序"以及"顺序错乱时会出什么妖"。
先把结论立住:初始化顺序是分层的
一句话:一个类被实例化时,大致顺序是——父类静态变量与静态块,子类静态变量与静态块,父类实例变量与实例块,父类构造器,子类实例变量与实例块,子类构造器。静态部分只在类首次加载时执行一次,实例部分每次 new 都从头走一遍。
这里要先立住一个前提:构造器隐式或显式调用父类构造器,决定了父类必须先于子类完成初始化。如果你不在子类构造器第一行写 super(...),编译器会自动插入 super() 去调父类无参构造;一旦父类没有无参构造又没显式调用,编译直接失败。这条铁律是后面所有顺序讨论的根。
机制拆解:静态、实例与多态下的陷阱
静态部分属于"类",在类加载(首次主动使用)时执行,且只一次;实例部分属于"对象",每次 new 都重新走。父类静态先于子类静态,是因为子类加载依赖父类已加载——JVM 必须先解析父类。
实例部分是关键难点:父类实例变量与实例块按代码顺序执行,然后才是父类构造器体;之后子类实例变量与实例块,最后子类构造器体。注意实例块和字段初始化是"按出现顺序"交织执行的,不是"字段全初始化完才跑块"。
真正的陷阱在多态:如果父类构造器里调用了一个被子类重写的方法,而此时子类的字段还没初始化(子类实例变量在父类构造器之后才赋值),那么该方法读到的子类字段是默认值(0 / false / null),而不是你以为的初值。这就是著名的"构造器里不要调用可重写方法"铁律的来源——顺序叠加多态,会制造出极其隐蔽的 bug。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// 构造方法与初始化顺序:静态 → 父类实例 → 父类构造 → 子类实例 → 子类构造
class Parent {
static {
System.out.println("P static"); } // 类加载时一次
int x = initX(); // 父类实例字段
Parent() {
System.out.println("P ctor, x=" + x); }
int initX() {
return 1; }
}
class Child extends Parent {
static {
System.out.println("C static"); }
int y = 2; // 子类实例字段,在父类构造之后才赋值
Child() {
super(); System.out.println("C ctor, y=" + y); }
@Override int initX() {
return y; } // 多态:父类构造里调用,此时 y 还是 0
}
// 输出顺序:P static → C static → P ctor, x=0 → C ctor, y=2
这段代码值得盯三处:第一处,静态块先于一切,且父类静态在子类静态之前;第二处,父类构造器运行时子类字段 y 尚未赋值,所以 x = initX() 经多态调到子类重写版,返回的是默认值 0——这就是构造器调用可重写方法的坑;第三处,子类实例字段与构造器在父类之后才执行,印证了"父类先初始化"的铁律。把这段输出顺序讲顺,这道题就过关了。
最常见的几个坑
最常见的坑是在父类构造器里调用了被子类重写的方法,结果读到未初始化的子类字段(默认值),产出看似随机的错误。正确做法是构造器里只调 private/final 方法或干脆不调用会被重写的方法;需要模板方法模式的,把可变点放到构造完成之后由显式 init() 触发。
其次是以为静态块会每次 new 都执行。静态属于类,只在类首次被加载时跑一次,之后无论 new 多少对象都不再执行。混淆静态与实例,会在排查"为什么计数器没重置""为什么配置没重新加载"时走弯路。
还有一个更隐蔽的坑:忽略字段初始化和实例块的执行顺序。字段赋值和实例块(花括号块)是"按源码出现顺序"依次执行的,如果把依赖前面字段的逻辑写在前面的块里,可能读到未赋值的字段。把初始化逻辑按依赖顺序排列,或在构造器里统一收口,能避开这类时序雷。
再一个是忘记显式 super(...) 导致父类无参构造被自动调用,而父类其实依赖带参构造完成关键初始化,于是拿到半初始化的父类状态。需要传参给父类时,要在第一行显式 super(args),并保证父类提供了对应构造器。
面试中的经典追问
"请简单介绍一下构造方法与初始化顺序?"——一句话定位:静态(类加载一次)→ 父类实例与构造 → 子类实例与构造;一个场景佐证:父类构造里调重写方法会读到子类字段默认值;一个边界收尾:静态只执行一次、实例每次 new 都重走。按"结论—依据—边界"三步走最稳。
"为什么父类构造要先于子类?"——动机是"子类依赖父类已就绪的状态",机制是子类构造第一行隐式或显式调用 super(),JVM 强制父类先完成;代价是父类构造里若碰多态会踩未初始化字段的坑。讲到这再补"所以构造器别调可重写方法",基本就稳了。
"静态变量和实例变量有什么区别?"——先列归属:静态属于类、一份共享、类加载时初始化一次;实例属于对象、每 new 一份、每次重新初始化;再按生命周期与内存位置对比,最后给场景结论。把"静态只一次"摆到台面上再决定用哪个。
"你在项目里因为初始化顺序踩过什么坑?"——行为题,拿真实案例说话。比如曾因父类构造调用了子类重写方法,导致对象在构造阶段就拿到错误默认值,靠日志和断点定位到初始化时序,修复方式是把可变点移出构造器。讲"复现—定位—修复"的结构,比空谈概念有说服力得多。
工程落地:真实项目里怎么用
初始化顺序在项目里的高频落点:一是框架的 Bean/组件生命周期(先静态配置、再实例注入、再启动钩子),理解顺序才能排"空指针在初始化阶段"的怪问题;二是单例与静态缓存的懒加载,确认静态只初始化一次且线程安全;三是把"构造器只做必要赋值、复杂初始化放到显式 init() 或依赖注入完成后"作为团队约定,从根本上规避构造期多态陷阱;四是字段初始化尽量简单、不死循环依赖。
一个稳妥的工程约定:构造器里不调用 public/protected 的可重写方法;需要传参给父类必显式 super(args);静态资源的加载与释放配对管理;复杂初始化收口到统一的 init() 而非分散在字段与块。把这几条固化,初始化相关的时序坑能少一大半。
还有一类和初始化顺序高度相关的典型错误是双重检查锁(double-checked locking)里的指令重排:在没加 volatile 的年代,单例实例的"分配内存、赋值引用、调用构造器"三步可能被重排,导致另一个线程拿到"引用已非空但构造尚未完成"的半成品对象。它的根因正是"引用赋值"和"构造完成"这两件事的顺序不被保证。理解初始化顺序之后,再去看 volatile 如何禁止重排、保证"构造完成才可见",这道题就能从"背写法"上升到"懂原理"。
在 Android 侧,这还牵连到自定义 View 的构造:布局填充时系统通过反射或工厂调用 View 的构造器,若构造器里做了重活或访问尚未就绪的依赖,会在首帧造成卡顿甚至崩溃。把较重的初始化挪到 onAttachedToWindow 或 onFinishInflate 之后,是规避这类"构造期未就绪"问题的常用手段——本质上就是别在构造阶段依赖还没准备好的东西,和"父类构造里不调可重写方法"是同一根原则的延伸。
给正在准备面试的你
理解初始化顺序之后,需要在真实项目里练一遍:写一个父类和子类,各自加静态块、实例块、构造器并打印,跑一次看输出顺序;再把父类构造里调用重写方法那一段加进去,亲眼看到子类字段返回 0。这两步做下来,比再读三遍资料都管用。
如果只记两句话,就记这两句:第一,顺序是"父类静态 → 子类静态 → 父类实例与构造 → 子类实例与构造",静态只执行一次;第二,父类构造里不要调用可重写方法,否则会读到尚未初始化的子类字段默认值。把这两句讲顺,初始化顺序这一关就过了。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android 软件开发面试·从入门到精通」连载系列
上一篇:类与对象的内存布局:一个对象到底占多少字节
下一篇预告:this-与-super:最容易混淆的两个指向
有任何问题欢迎在评论区留言交流。