1. 为什么要单独聊继承中的成员变量访问
先说个场景:你在刷 Java 面试题时,经常会看到类似这种题目——父类和子类定义了同名的成员变量,然后用子类对象去访问这个变量,最后输出是什么?很多刚学完封装、继承、多态的人,面对这种题会凭"继承就是子类能用父类的东西"这一条直觉去猜答案,结果一去对答案就懵了:明明我已经用子类对象在访问,为什么输出的是父类的值?
这个疑惑的根源,在于大多数人把"方法"和"成员变量"在继承里的表现混为一谈了。方法有覆盖(Override),变量没有覆盖这一说,只有"隐藏"(Hide)。而且父类成员变量若被设置为私有,子类根本看不到;若设置为默认或受保护,子类即便继承到了,还会被自己的同名变量"压住"。这些规则如果没理清楚,后面做项目时就会出现一种很诡异的 bug:你明明在子类里给某个字段赋了值,父类方法里读到的却是另一个同名字段的旧值,还不报错、不警告,程序老老实实跑出错误结果。
我当年在接手一个老项目时,就被这种问题坑过一次,排查了整整一下午。代码里有一组基类和派生类,基类维护了一个status字段,派生类为了"方便"又自己声明了一个status,结果业务方法通过父类引用来更新状态时,写的是一套字段,页面读取走的是另一套字段,状态永远对不上。这种问题的排查难度不在于逻辑复杂,而在于Java语法层面完全"合法",没有任何异常提示,你只能靠对成员变量访问规则的深刻理解才能发现。
所以这篇文章,我想把继承中成员变量访问这一小块知识彻底掰开揉碎。内容不会多到让你头晕,但会覆盖三个关键点:就近原则到底是什么规则、this和super在变量访问里各自扮演什么角色、以及父类和子类同名字段背后的"隐藏"机制到底怎么在内存中体现。适合正在复习 Java 基础准备面试的人,也适合那些被隐藏 bug 折腾过、想彻底搞懂底层原理的开发新手。
话不多说,直接进入第一个核心规则。
2. 就近原则:从小到大,编译器到底优先看谁
2.1 一个最基础的"局部变量遮蔽成员变量"示例
所有 Java 教材在讲成员变量访问时,都会先提一条规则:如果某个方法里同时存在局部变量和成员变量同名,那么在方法内部直接写这个名字,访问到的一定是局部变量。这就是所谓的"就近原则"。
来看下面这个最简单的类:
public class ScopeDemo { private int value = 10; public void test() { int value = 20; System.out.println(value); // 输出多少? } }答案显然是 20。因为test()方法体内的局部变量value离访问点最近,访问自然落在它身上。这里的value = 10这个成员变量并没有被删除,它只是被局部变量"遮蔽"了,在test()方法作用域内直接用变量名访问,是摸不到它的。
这个例子太基础,很多教程讲到这里就停了。但真正值得思考的问题是:Java 的"就近"到底按什么粒度来算?它和继承中父类变量、子类变量之间的优先级有什么关系?我们继续往下拆。
2.2 就近原则的作用域层级:方法 > 子类 > 父类
当变量出现在继承体系中,"就近原则"就不是单纯指局部变量 vs 成员变量了,而是一套完整的查找顺序。我总结成一句话,你拿笔记下来就行:
在子类方法的局部作用域中直接使用一个变量名时,Java 的查找顺序是:局部变量 → 子类自身成员变量 → 父类成员变量。
如果我想在方法里访问父类继承来的brand = "父类品牌",而当前子类又正好没有定义同名的brand成员变量,那直接写System.out.println(brand)就能拿到父类的变量,因为查找顺序往上走,只有子类自己那一层没有同名变量时,才会继续往上找。
一旦子类自己也定义了一个brand,使用变量名时就会直接被子类的brand拦截,父类那个同名字段就成了"被隐藏"状态。下面这段代码可以完整验证这条规则:
class Parent { String brand = "ParentBrand"; } class Child extends Parent { String brand = "ChildBrand"; public void printBrand() { String brand = "LocalBrand"; System.out.println(brand); // 输出 LocalBrand(局部变量) System.out.println(this.brand); // 输出 ChildBrand(子类成员) System.out.println(super.brand);// 输出 ParentBrand(父类成员) } } public class ScopeTest { public static void main(String[] args) { Child child = new Child(); child.printBrand(); } }输出结果相信你已经能预测了:
LocalBrand ChildBrand ParentBrand这个方法内部就有三个brand同时存在:一个来自局部变量,一个来自子类成员变量,一个来自父类成员变量。直接写名字,按就近原则命中局部变量;用this强制指定当前对象身上的成员;用super强制指定父类中的成员。三种访问路径,一条代码里全部体现出来了。
2.3 条件允许才往上找,但父类私有变量是个例外
很多资料会把"就近原则"概括成一句话:先看当前作用域,再看父类。但这里有一个非常重要的前提——父类的成员变量必须对子类可见。
在 Java 的访问修饰符体系里,private变量虽然也属于父类,但对子类而言是完全不可见的。子类在查找变量时,即便自己这层没有同名变量,它也不会"偷偷绕到父类"去访问一个 private 变量——因为这根本做不到,编译器层面就不允许。你只能用父类提供的公有或受保护的方法去间接访问它。
所以更准确的说法是:
查找顺序是"逐层向上",但每一层都只能访问到对当前类可见的成员变量。如果父类成员是 private 的,那它对子类来说就像不存在一样,谈不上"最近",也谈不上"遮蔽"。
举个反面例子:
class Parent { private String secret = "父类私密信息"; public String getSecret() { return secret; } } class Child extends Parent { public void printSecret() { // System.out.println(secret); // 编译报错,secret 不可见 System.out.println(getSecret()); // 合法,通过父类公共方法访问 } }这个例子清楚表明了:就近原则不是"只要是父类变量就往上找",而是"在可访问的范围内就近找"。private成员从一开始就退出了子类的可见范围,它只能由父类自己通过方法暴露。
2.4 就近原则容易让人忽略的两层细节
第一层细节是赋值操作同样遵循就近原则。不只是读取变量名时按这个顺序,写入时也一样。你在方法里写brand = "Hello",它实际修改的是局部变量brand而不是成员变量brand——除非你写this.brand = "Hello"或super.brand = "Hello"。这就造成了一个常见的错误:在方法里想给成员变量赋值,但忘了加this,结果局部变量被赋值了,成员变量纹丝不动。程序不报错,但你调完方法后发现对象状态没变。
第二层细节是形参也是局部变量。构造器或方法里如果形参名和成员变量名字相同,恭喜你,经典的教学用例来了:
public class Student { private String name; public Student(String name) { name = name; // 这是经典错误:两边都是形参 } public void setName(String name) { this.name = name; // 这才是正确的成员变量赋值 } }Student(String name)构造器里的name = name,左边的name不是指成员变量name,而是指形参name。整个表达式就是"把形参赋值给形参",成员变量完全没动。这种代码编译能过,运行也不报错,但对象建立后name字段永远是 null。这种坑就是典型的"就近原则"没理解透造成的。
3. this 和 super 的本质:两种完全不同层级的引用
3.1 this 本质是"当前实例的引用变量",super 是"编译器开的后门"
很多 Java 初学者会有一个直觉认知:this指向当前对象,super指向父类对象。这前半句是对的,但后半句存在一个大坑。
this的确是对当前实例的一个引用,它是实实在在存在的,你可以把它传给其他方法,也可以把返回值类型写成当前类。但super并不是"父类对象的引用"――它更像是一个编译器层面的语法标记,告诉 JVM:"接下来我要访问的成员,请从当前对象的父类区域里去解析。"
之所以这么说,是因为在 JVM 的内存模型里,一个子类对象在堆中并不是分成"一个父类对象 + 一个子类对象"两块。恰恰相反,子类对象就是一个整体对象,它内部实际包含了一个完整的父类实例部分。super只是限定了访问范围的"指示符",本质上访问的还是同一个对象里的成员。
这个区别抛出一个很常见的面试追问:"子类对象能 new 一个父类对象出来吗?" 当然不能。super也无法脱离当前实例单独存在。你不可能写出super.println()这样脱离对象实例的调用。
3.2 this 用在变量访问上,到底做了什么
当我们在子类方法里调用this.brand时,JVM 做的事是:从当前对象的成员区域开始查找brand字段。当前对象是谁?是这个被创建出来的Child对象。而Child类自己定义了一个brand,所以this.brand命中它,而不会命中去父类部分找。
这里有个非常关键的推论:如果你用一个子类对象去调用一个从父类继承来的方法,而该方法内部使用了this访问一个字段,那这个this指向的是当前这个子类对象,不是你想象中"父类方法里的父类对象"。
看这段代码:
class Parent { String brand = "ParentBrand"; public void show() { System.out.println("show() 访问到的 brand = " + this.brand); } } class Child extends Parent { String brand = "ChildBrand"; } public class ThisTest { public static void main(String[] args) { Child child = new Child(); child.show(); // 输出 ChildBrand 还是 ParentBrand? } }很多初学者会以为show()方法是"父类的方法",所以方法里的this.brand应该访问父类的brand。这个理解是错误的。this是运行时的动态概念,它指向真正被创建的那个对象。你这个对象是用Child类构建出来的,那this.brand在编译后执行时,解析的就是子类里定义的brand字段。所以上面程序的输出是ChildBrand。
这个点非常重要,因为它是"变量隐藏"的核心运行规则,也是和"方法覆盖"运行时行为相对照的关键差异,后面第四大节我会专门扩展这层。
3.3 super 访问父类成员,实际原理是什么
再看super.brand的细节。当编译器看到super.brand时,它会生成一条特殊指令,在同一个对象中,跳过当前类自己声明的brand字段,去查找父类链中最近一层声明的同名brand字段。
注意,这里的"跳过当前类"是关键的逻辑。即使子类没有声明brand,super.brand也能正常工作,它会继续向上找到父类里的brand。但如果父类里也没有brand,而是在祖父类里才有,编译器会继续往上找,只要可见性允许。
所以super的实际行为是:沿着本类 → 父类 → 祖父类……逐层向上,找到第一个可见的同名字段,而且优先跳过本类自己的声明。
我再用一张日常生活的类比来说:假设家庭成员都有"手机"这个概念(同名属性),儿子、父亲、爷爷各自都有一部手机。"你站在客厅喊一声'看下手机',找到的肯定是离你最近的儿子的手机;但你说'父亲那部手机给我看一下',那就会直接跳过你儿子那部,去拿父亲的手机。"super就是这个"点名父亲"的作用。
3.4 this() 和 super() 在构造器里的不可共存约束
谈变量访问必然会关联到构造器,因为构造器就是用来给成员变量赋初值的,而this()和super()的规则恰好和变量访问逻辑相互呼应。
先说规则:在一个构造器的第一行,你必须写且只能写一个this(...)或super(...),两者不能同时出现,也不能都不写——不写的话,编译器会默认在第一行插入一个无参super()。
这个设计本质上是为了保证继承体系中父类成员的初始化链路是完整的。
class Parent { protected String name; public Parent(String name) { this.name = name; System.out.println("父类有参构造执行"); } } class Child extends Parent { private int age; public Child(String name, int age) { super(name); // 第一行:显式调用父类构造器 this.age = age; // 第二行:子类自己的初始化 } }如果你在Child的构造器里,第一行写this.age = age;,编译器会报错吗?不会,因为this.age = age不是构造器调用语句。构造器调用语句必须是"用完即走"的那种:this(...)或super(...)。而你一旦在第一行放了别的语句,编译器就会偷偷给你补一个super()。如果父类没有无参构造器,编译直接报错,提示你必须显式调用一个匹配的父类构造器。
这个约束经常被面试官当作"Java继承三连问"中的一环:继承体系中,构造顺序是先父后子;如果你想让子类构造器调用另一个子类构造器,就用this(...),但链路的终点总归是一个调用了super(...)的构造器。
4. 变量隐藏 vs 方法覆盖:最容易被误解的一组对比
4.1 同名方法叫覆盖,同名变量只能叫隐藏
在 Java 语法层面,"隐藏"(Hide)和"覆盖"(Override)看起来都会产生"子类用自己的成员压住父类成员"的效果,但它们是两套完全不同的机制。搞清楚这两者的差异,是理解成员变量访问规则的进阶关键。
先说方法覆盖。子类定义了一个与父类方法签名一模一样的方法,这个子类方法会覆盖(Override)父类方法。运行时,JVM 根据对象的实际类型决定调用哪个版本——即便你用一个Parent类型的引用指向一个Child对象,调用引用上的方法时,实际执行的依然是Child里覆盖后的版本。这就是多态的动态分派。
再说变量隐藏。子类定义了一个和父类同名的成员变量,父类变量只是被"隐藏",没有被删除。变量的解析依赖引用类型——如果你用一个Parent类型的引用去访问brand,那编译器根据引用类型直接解析到Parent.brand;如果你用Child类型的引用去访问,解析到的就是Child.brand。不是运行时动态决定,而是编译期就定死了。
4.2 一个实验同时验证动态分派和静态解析
我们直接跑一段对比实验:
class Parent { String brand = "ParentBrand"; public void whoAmI() { System.out.println("Parent 方法"); } } class Child extends Parent { String brand = "ChildBrand"; @Override public void whoAmI() { System.out.println("Child 方法"); } } public class DynamicTest { public static void main(String[] args) { Parent ref = new Child(); System.out.println(ref.brand); // 输出 ParentBrand ref.whoAmI(); // 输出 Child 方法 } }这个实验是面试中非常高频的考点。ref的静态类型是Parent,实际指向的对象是Child。当访问ref.brand时,编译器根据静态类型Parent,直接定位到Parent.brand,输出ParentBrand。当调用ref.whoAmI()时,JVM 根据实际对象类型Child,执行被覆盖后的方法,输出Child 方法。
一句话总结这个荒诞但正确的结果:方法看对象实际类型,变量看引用声明类型。
4.3 为什么 JVM 要这样设计变量访问
你可能会问:既然变量访问这么容易搞混,JVM 为什么不把变量也做成"运行时动态解析",让Parent ref = new Child(); ref.brand直接命中Child.brand?
这个问题的答案和 Java 的发展背景有关。变量覆盖(也就是重新解析字段)在运行时做动态绑定,会带来一个非常棘手的后果:在父类构造器中给字段赋值时,你无法确定这个赋值操作会不会被运行时指向另一个字段,导致父类初始化逻辑失控。我们常说的"在构造函数中调用可覆盖方法有风险",同理,如果字段也做动态分派,父类构造函数的安全边界将彻底崩塌。
再加上性能和简单性的考虑,Java 选择的做法是:变量按声明类型静态解析,方法按实际类型动态分派。这个设计美中不足的地方是它不够贴合直觉,但万幸的是,规则本身清晰、稳定,一旦记住就不会有歧义。
4.4 规则冲突时的几个经典面试题套路
基于这个对比,面试题经常变化出以下几种套路:
第一轮:直接问你输出是什么。Parent ref = new Child();打印ref.brand和ref.whoAmI()的输出。
第二轮:让你改这个实验。把brand变量都变成static,再问输出是什么。实际上,static变量也遵循类似静态解析的规则,且static字段本来就建议通过类名直接访问,绕开引用类型的干扰。
第三轮:把brand变成private,再问ref.brand能不能通过编译。答案是不能,因为private变量对Parent类外部的main方法不可见。这也提醒我们,讨论变量隐藏之前,一定要先确认可见范围。
这些面试套路考察的核心只有一个:你到底有没有分清"编译期解析"和"运行期分派"这两条主线。
5. 构造器中的变量访问:一处极其隐蔽的陷阱
5.1 父类构造器访问"子类同名变量"的真实效果
既然我们已经知道,方法调用是运行时动态分派的,那么把这个规则放进构造器里,就会出现一个极其隐蔽但危害巨大的陷阱。
假设父类构造器里调用了一个可被覆盖的方法,而子类覆盖了该方法。创建子类对象时,父类构造器先执行,它内部调用的那行方法会命中的是子类的覆盖版本——有些面试官就喜欢在这时候再叠加一个成员变量访问问题:如果子类覆盖的实现里访问了某个字段,而该字段在子类中还没来得及初始化,会发生什么?
经典代码如下:
class Parent { public Parent() { System.out.println("父类构造器开始"); init(); System.out.println("父类构造器结束"); } protected void init() { System.out.println("父类 init() 被调用"); } } class Child extends Parent { private String name = "默认名"; public Child() { super(); // 隐式调用 System.out.println("子类构造器执行,此时 name = " + name); } @Override protected void init() { System.out.println("子类 init() 被调用,name = " + name); } } public class ConstructorTrapTest { public static void main(String[] args) { new Child(); } }这个程序运行起来,输出会非常有意思。因为Parent()构造器在执行时,会触发init()的动态分派,进入子类Child的init()。此时子类的name字段还没有被赋值——子类成员变量的初始化语句private String name = "默认名"要等父类构造器返回、子类构造器自身进入后才执行——所以打印出的是 null。
这在开发中意味着什么?如果你在父类构造器里调用了一个可覆盖方法,而覆盖方法又依赖子类未初始化的成员变量,那程序就会在一个看似完全正常的新对象创建过程中,输出一个 null 或者零值。不是并发问题,不是序列化问题,纯粹是"构造器顺序 + 动态分派"叠加的结果。
5.2 如何避免这类陷阱
最稳妥的做法是:父类构造器只做父类自己成员的初始化,不调用可覆盖方法。
如果确有需要,把被调用方法的逻辑设计成处理 null 也安全的,或者干脆使用private方法——private方法不可覆盖,在父类构造器里调用时,一定会走父类自己的实现,不会被子类的覆盖影响。
这段经验是我在实际项目里踩出来的。当时一个公共基础类在构造器里调用了一个loadConfig()方法,子类覆盖了这个方法预加载自己的配置数据,结果每次服务启动时,子类配置里的关键字段全是初始值,因为父类构造器执行时子类字段还没来得及初始化。排查到最后,就是临时改设计,去掉父类构造器的调用链,才彻底解决问题。从此以后,我在代码评审里看到父类构造器调用非 private 方法,基本都会提醒对方:这里大概率暗藏初始化顺序问题。
6. 完整实战:一个结合继承、隐藏、方法调用的封送场景
6.1 业务背景与代码设计
前文讲了很多规则和坑点,现在我们来把它们放到一个稍微完整一点的业务场景里验证,同时再看一个经常被初学者忽略的“隐藏字段导致状态不一致”的经典实例。
假设我们有这样一个需求:电商系统要处理不同类型的支付订单,包含基础订单信息(订单号、金额、状态)和信用卡专属信息(卡号尾号)。为了复用,我们设计一个BaseOrder作为父类,再设计CreditCardOrder作为子类。
class BaseOrder { protected String orderNo = "BASE-NO"; protected double amount = 0.0; protected String state = "BASE-PENDING"; public void displayInfo() { System.out.println("订单号: " + orderNo); System.out.println("金额: " + amount); System.out.println("状态: " + state); } } class CreditCardOrder extends BaseOrder { protected String state = "CARD-PENDING"; // 隐藏父类的 state private String cardTail = "1234"; public CreditCardOrder(String orderNo, double amount) { this.orderNo = orderNo; this.amount = amount; } public void markPaid() { // 错误写法:直接给 state 赋值,命中的是子类的 state state = "PAID"; } public void showState() { System.out.println("子类的 state: " + state); System.out.println("父类的 state: " + super.state); } }现在如果这样调用:
public class OrderMain { public static void main(String[] args) { CreditCardOrder order = new CreditCardOrder("ORD-001", 99.8); order.markPaid(); order.displayInfo(); order.showState(); } }你能猜到输出吗?markPaid()给子类的state赋了"PAID",而父类displayInfo()方法内部访问的state是它自己那层BaseOrder里声明的state,所以打印出来的状态还是BASE-PENDING。这个结果会让很多人当场愣住:我明明调用了markPaid(),状态应该变成 PAID 了呀?为什么显示出来是 BASE-PENDING?
这个乱象的根源,就是子类在同名变量上做了"隐藏",但父类方法内部的字面引用是按"父类视角"来解析的。用我们之前的理论解释:displayInfo()是在BaseOrder类里定义的,它内部写state时,由于没有显式的this,编译器按定义类的作用域去解析这个变量,命中的就是BaseOrder.state。它和运行时实际对象是不是CreditCardOrder无关,因为变量的解析是编译期按静态上下文进行的。
6.2 问题根因与正确设计路径
在这个例子中,真正的设计问题出在子类不应声明一个与父类语义重叠的同名state字段。如果确实需要子类有独特的状态定义,就应该取不同的名字(如cardState),避免隐藏行为。如果状态逻辑完全一致,则根本不需要在子类重新声明,直接继承父类字段,统一由markPaid()给父类字段赋值即可。
修改方案如下:删除CreditCardOrder里的state字段声明,markPaid()里state = "PAID"就会命中父类的state,displayInfo()打印的也是同一个字段,状态自然正确。
如果出于某些契约要求,子类必须保留自己的state(比如你无法改动父类代码),那么访问时必须显式区分:子类内业务逻辑读子类的state就写this.state,父类逻辑需要读父类的旧值就写super.state,而且你要时刻清楚业务各处代码到底操作的是哪一个字段。但这终究是拆东墙补西墙,代码可读性和维护性都会下降。
这个实战悲剧的核心教训就是:继承体系里,字段尽量别重名。真要重名,就不要指望"暗箱操作"能自动帮你做正确的事情;所有字段访问都必须显式标注来意。
7. 几个值得写进笔记的边界细节
讲完实战,我再说几个平时容易被忽略、但关键时刻能救命的边界细节。这些内容不一定能在教材里看到完整描述,但非常实在。
7.1 通过父类引用无法访问子类新增变量
有个很常见的错误操作:用一个Parent类型的引用接收了一个Child对象,然后想通过这个引用访问子类特有变量childOnly。这种代码在编译期就直接报错,提示找不到符号childOnly。因为编译器只知道引用类型是Parent,而Parent没有这个字段。即便实际对象是子类,也不能通过父类引用来直接访问子类新增的成员。这也侧面说明了:变量访问在编译期决定了编译是否可以通过,运行时改变不了已经确定的合法范围。
如果需要通过父类引用来访问子类特有字段,只能向下转型:
Parent ref = new Child(); if (ref instanceof Child) { Child c = (Child) ref; System.out.println(c.childOnly); }7.2 静态变量不参与同样的"隐藏",但访问容易踩雷
static成员变量也一样遵循"不覆盖、只隐藏"的规则,但因为它属于类级别,建议总是通过类名访问,而不是通过实例引用访问。比如Parent.count和Child.count是两个不同的静态变量,Parent ref = new Child();然后ref.count命中的依然是Parent.count,因为编译器依旧按引用类型解析。如果你想表达"子类的静态变量",就应写Child.count。混用引用访问静态成员时,IDE 甚至会给你一个警告,提示"静态成员应该通过类名访问",但你如果没留意警告,照样会踩坑。
7.3 字段访问与 getter/setter 的不对称
还有一个实际编码中经常出现的建议:不要通过字段隐藏来搞"状态分层",尽量让字段私有,并统一用方法访问。
当字段是private时,根本不存在子类隐藏父类字段这一说,子类看不到父类 private 字段,也无从声明同名变量去"遮蔽"。但如果父类提供了公共的 getter/setter,子类就可以正常操作同一个字段,状态永远是同一份数据。这其实是最干净的设计。
很多企业在代码规范里会强制要求:字段一律private,访问一律通过方法。这样做不仅是为了封装,也是为了彻底规避"继承中成员变量隐藏"带来的混乱。Java 官方也推荐面向接口和访问器方法编程,而不是面向字段编程。虽然多写几个 getter/setter 显得啰嗦,但换来的是明确的数据通路,和高枕无忧的状态一致性。
有人会担心这么设计少了一些"灵活性",但我的看法是:继承体系里的字段一旦允许跨类直接可见,你的代码就等于在多个作用域之间架起了无形的桥梁,每个人都在桥上来回走,但谁也不知道桥的另一端到底是哪一个字段。这种隐患出现一次,节省的代码量就全赔进去了,甚至还不止。
8. 我的排错路线与经验收尾
最后分享两个我遇到的真实问题,以及对应的排查思路,希望能帮你建立一种"遇到变量访问异常时,能快速定位根因"的直觉。
第一个问题,源自一次老代码维护。某业务模块的功能是把订单状态置为已退款,结果页面一直显示退款中,数据库状态也完全没变。排查时我先看代码,发现退款方法里写的是order.state = "REFUNDED",这个order实际类型是子类,而子类又声明了同名state字段,所以这一行赋值操作命中的是子类隐藏字段,根本没有落到数据库映射字段所在的父类字段上。整个链路里没有异常、没有警告,状态就是不动。排查思路很简单:先看类型,再看字段声明位置,最后确认赋值语句命中哪个字段。一旦把隐藏字段识别出来,问题立刻水落石出。
第二个问题,来自一次团队代码评审。一个小伙子在父类构造器里调用了一个非 private 的init()方法,用来初始化基础配置。他声明的意图是"让子类可以覆盖 init 做扩展",这个意图本身没错,但他没注意到init()内部使用了子类尚未初始化的成员变量,导致项目启动时偶尔出现空指针。当时我给出的方案很简单:把init()拆成两个方法,一个private final用在父类构造器里,一个protected开放给子类覆盖,但后者绝不能被父类构造器调用。这样既保留了扩展能力,又规避了初始化顺序的雷区。
踩过这些坑之后,我个人的体会是:Java 继承中成员变量的访问规则,本质上就是"编译期按声明类型解析字段 + 运行期按实际类型分派方法"这两条主线交织的结果。你只要抓住这两条主线,无论题目换成什么形态——父类引用、子类隐藏字段、构造器内方法调用、甚至多层继承——答案都能在几秒内推导出来,不需要死记硬背。
最后再送你一个小技巧:日常写代码时,如果发现某个字段在子类和父类中同名,先别急着通过this和super区分,而是停下来问问自己——有没有可能换个更清晰的命名?绝大多数情况下,让人困惑的代码不是语法不合法,而是命名在诱导误读。把这篇文章的规则消化掉,再加上一个好的命名习惯,这个知识点基本上就不会再给你添乱了。