
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字段。注意这里的跳过当前类是关键的逻辑。即使子类没有声明brandsuper.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就会命中父类的statedisplayInfo()打印的也是同一个字段状态自然正确。如果出于某些契约要求子类必须保留自己的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区分而是停下来问问自己——有没有可能换个更清晰的命名绝大多数情况下让人困惑的代码不是语法不合法而是命名在诱导误读。把这篇文章的规则消化掉再加上一个好的命名习惯这个知识点基本上就不会再给你添乱了。