Java继承进阶:重写规则、super用法与构造器链全解 继承这件事在 Java 里看着简单用起来到处都是细坑。很多朋友学《从零开始学 Java》系列到了第八篇觉得已经会用 extends 写出 class B extends A就认为继承学完了。结果后面做项目遇到诡异的输出顺序、同名方法、实例化报错又得回头翻文档。本篇不谈“什么是继承”这种入门概念专门聊继承进阶重写规则、super 的三种用法、构造器链、类型转换、组合与继承的选型以及面试里真正会问到的那些细节。不管你是正在刷 Java 基础、准备 Java 面试还是想参加蓝桥杯这类竞赛这里的内容都能直接套用。1. 重新理解继承它不只是代码复用1.1 先过一道“is-a”判断题继承在设计层面最核心的判断标准不是“能不能复用代码”而是“子类是不是父类的一种”。这句话值得再读一遍。比如 Account 是父类SavingsAccount 和 CurrentAccount 是子类因为储蓄账户就是账户的一种Animal 是父类Dog 是子类因为狗也是动物。反过来如果一个场景只是“我希望 Dog 使用 Animal 里的吃饭方法”但两者的概念上没有稳定的 is-a 关系那就该考虑组合而不是硬套继承。is-a 关系看起来很好判断实际写代码时非常容易被偷懒带偏。我见过有人在订单类里继承了用户类理由是“订单里需要用户的名字和电话”。表面上一行 extends 省了很多事订单对象也确实能 getName、getPhone可问题是订单是用户吗显然不是。订单应该是“拥有一个用户引用”而不是“继承用户”。这种错误一旦写进项目后面订单需要跟用户做逻辑区分时就要靠各种 if 判断去救场越写越乱。所以你可以在动手用 extends 之前先停下来问自己一句这个类真的属于那个父类吗如果答案是“好像也不是很确定”那就别用继承。JVM 在内存里的实际情况也很有意思。很多人以为子类对象里“包含”了一个父类对象这是个不太精确但很常见的理解。从对象结构上看子类对象的内存布局里父类定义的实例字段都会作为子类对象的一部分存在。所以父类的字段并不是“复制”了一份而是子类对象本身就拥有这些字段的内存位置。这也是为什么你在子类里能访问到父类的 protected 字段能间接访问到父类的 private 字段因为它们在内存里真实存在只是访问权限对不同修饰符有不同限制。1.2 访问控制是继承的一道隐形门槛Java 有四个访问级别private、默认包级、protected、public。在继承里最容易踩坑的就是 protected。很多人记住“protected 是给子类用的”但忽略了一个边界条件不同包下的子类通过父类引用是无法访问父类对象的 protected 成员的。这句话有点绕我举个实际例子package a; public class Parent { protected int value 10; }package b; import a.Parent; public class Child extends Parent { void test() { Child c new Child(); System.out.println(c.value); // 合法因为 c 是 Child 类型 Parent p new Child(); System.out.println(p.value); // 编译报错跨包时不能通过父类引用访问 protected } }同一段代码c.value 能编译通过p.value 就报错。原因在于 Java 语言规范对 protected 的限制不同包子类只能在“自己是该类型的子类或更派生类型”的情况下访问受保护成员。用大白话说就是你只能在自己家的“子类视角”去访问不能拿着一个父类引用到处乱碰。这个规则在面试中偶尔会出现在工程里遇到时也很容易让人摸不着头脑。我建议你把 protected 当成“给子类扩展留的一扇窄门”不跨包就别用能用 public 或 package-private 就尽量不用 protected。2. 方法重写的完整规则与 super 的三种用法2.1 重写不是“我写了一个同名方法”那么简单方法重写的第一条铁律是方法签名必须一致。签名包括方法名和参数列表。如果参数列表变了那不是重写是重载。第二铁律是访问权限不能收窄。父类方法是 public子类重写方法就不能改成 protected否则编译直接报错。反过来父类方法是 protected子类改成 public 是可以的因为访问范围可以扩大。还有一个细节是返回类型。Java 5 以后允许协变返回类型也就是说子类重写方法的返回类型可以是父类方法返回类型的子类型。比如父类方法返回 Animal子类重写后可以返回 Dog因为 Dog 就是一种 Animal。这个机制简化了很多代码也体现了 Java 在表面严格下的灵活性。重写时的异常声明同样有限制子类不能抛出比父类更大范围的受检异常。父类方法声明 throws IOException子类重写时可以 throws FileNotFoundException因为它是 IOException 的子类但子类如果直接 throws Exception编译就过不去。这里的目的很清晰既然调用方已经按父类的契约处理了 IOException子类就不能偷偷把异常范围扩大否则会让调用方的异常处理失去意义。再说说 Override 注解。它不是语法必须但我强烈建议你加上。这个注解最大的价值不是给人看而是给编译器看如果你本来想重写结果方法签名写错了比如参数类型少写一个、方法名拼错一个字母编译器会立刻报错。没有这个注解你以为自己重写了实际上新建了一个无关方法父类逻辑照常执行你的逻辑永远不生效。这类 bug 排查起来极其痛苦所以在 Java 项目里公司代码规范通常都会强制加 Override。2.2 super 的三种典型用法super 关键字在继承里一共承担三种任务。第一种是调用父类构造器也就是 super(...)它必须出现在构造器的第一行。这一点算是 Java 语法里最没商量余地的规则之一。你不写 super()编译器也会自动帮你插入一个无参 super()。但如果父类没有无参构造器编译器插入的 super() 就会失败于是你必须显式写出带参的 super(...)。这个机制在创建子类对象时决定了父类构造器必然先执行我会在下一章详细讲。第二种是调用父类方法也就是 super.methodName()。当子类重写了父类方法但你仍然想调用父类版本的逻辑时就必须用 super 前缀。比如父类的 buildTitle() 已经写了通用标题格式子类重写后在前面加一段自己的前缀然后再 super.buildTitle() 拿到父类的结果拼回去。这种方式是合法的提取共用逻辑手段。第三种是访问父类字段也就是 super.fieldName。这个用法相对少因为正常情况下父类字段通过继承就能直接访问。但当你遇到字段隐藏时就有用了子类定义了一个和父类同名的字段在子类方法里写 this.fieldName 会读子类字段写 super.fieldName 会读父类字段。有一点要注意super 其实不是一个真正的对象引用不像 this 那样可以到处传递。你没法把 super 传给一个方法也没法用它做 null 判断。它只是 Java 提供的一种“指向父类那一面”的语法糖。再深入一点super 只能向上走一层你不能在子类里写 super.super.method() 去调用爷爷类的方法因为 Java 语言刻意不支持这种跨层调用这也逼着你把类设计得更清晰。3. 构造器链与实例化顺序3.1 为什么父类构造器一定先执行Java 实例化一个子类对象时不是“先建子类再建父类”而是从继承链最顶端的 Object 开始一层一层往下初始化。每一个构造器都会先调用它的父类构造器等父类构造器执行完再继续执行自己的字段初始化和构造器体。这个顺序是由编译器在字节码层面保证的子类构造器的第一条有效语句一定是 super(...) 或者 this(...)如果你没写编译器会默认插入 super()。代码能说明一切。我写过下面这段来测试输出顺序public class Parent { static { System.out.println(Parent 静态块); } { System.out.println(Parent 实例块); } Parent() { System.out.println(Parent 构造器); } }public class Child extends Parent { static { System.out.println(Child 静态块); } { System.out.println(Child 实例块); } Child() { System.out.println(Child 构造器); } public static void main(String[] args) { new Child(); new Child(); } }输出结果会让你很清楚地看到整个初始化链条Parent 静态块 Child 静态块 Parent 实例块 Parent 构造器 Child 实例块 Child 构造器 Parent 实例块 Parent 构造器 Child 实例块 Child 构造器静态块在类加载阶段执行整个 JVM 生命周期里只会执行一次所以 new 两次只输出一次静态块。实例块和构造器代码是每次创建对象都会执行的并且实例块会在构造器体之前执行。这个执行顺序是面试高发题笔试里经常让求职者写出输出结果。你只要记住一个口诀先父后子先静态后实例先字段后构造器。字段初始化的位置也值得注意。如果你声明字段时直接赋值那个赋值语句在字节码里其实会被合入构造器流程在调用父类构造器之后、执行构造器体之前完成。这也是为什么父类构造器里调用到了子类重写方法时会出问题下一节就讲。3.2 构造器里调用可重写方法是致命陷阱有一个非常经典的坑父类构造器不要调用可重写方法。看这段代码public class Base { Base() { print(); } void print() { System.out.println(Base); } }public class Sub extends Base { private String name Hello; Sub() { } Override void print() { System.out.println(Sub: name); } public static void main(String[] args) { new Sub(); } }运行结果是Sub: null为什么是新对象却输出 null因为构造流程是先调用 Object 构造器再进入 Base 构造器Base 构造器里调用了 print()。由于多态Base 构造器里的 print() 会动态分派到子类 Sub 重写的 print() 上但这时 Sub 的 name 字段还没初始化靠字节码层面的顺序父类构造器结束之后才轮到子类字段初始化所以 name 是 null。这个问题的本质是多态生效的时间比字段初始化更早。父类构造器执行期间子类重写方法就已经可以被调用了但子类的字段还是一个“半成品”状态。你要避免踩这个坑有几条路父类构造器里只调用 private、final 或 static 方法把父类构造器里需要钩子的部分用模板方法模式或者做好文档约定让所有子类知道这些钩子方法会在父类构造阶段被调用并且不能依赖子类字段。很多框架代码里也会遇到这个问题正是因此优秀的框架会在核心基类里大量使用 final 方法防止子类重写破坏初始化顺序。4. 继承、接口和抽象类的三角关系4.1 为什么 Java 就是不支持多继承我自己学 Java 那会儿也忍不住问为什么 Java 不让我同时继承两个类要回答这个问题得先理解多继承的核心问题——菱形问题。假设类 A 有一个方法 run()B 和 C 都继承 A 并各自重写了 run()然后 D 同时继承 B 和 C。这时候 D 的 run() 应该执行 B 的版本还是 C 的版本不同语言处理方式不同但很容易产生歧义和复杂规则。为了绕过这个复杂度Java 设计者最终选择了单继承多接口的方案。类和类之间只能是单继承链但接口可以多继承接口类也可以实现多个接口。接口只声明“具备什么能力”不关心具体实现细节所以即使多个接口有同名默认方法冲突规则也好定义得多。Java 8 之后接口能写 default 方法其实又带来了新型菱形问题一个类实现两个接口这两个接口刚好都有同名 default 方法这个时候编译器强制要求这个类必须显式重写该方法否则直接报错。你可以理解为 Java 把“复制粘贴同一个方法”的麻烦事交给了开发者自己决定。类优先原则也是继承和接口混用时的关键规则。如果一个类既继承了父类的具体方法又实现了接口的同名 default 方法那么父类的实现优先于接口的默认实现接口的 default 方法会被忽略。这个设计的考虑是继承是更强的绑定关系接口只是补充契约所以接口不会无故覆盖你显式继承的父类行为。4.2 抽象类和接口到底怎么选这个选择题在面试中几乎必问在工程中也经常需要决策。我的选择标准是先问动机你是想描述“这个东西是什么”还是“这个东西能做什么”抽象类更适合描述 is-a 关系因为它的构造器、字段、通用逻辑能为子类建立起真实的骨架。接口更适合描述 can-do 关系表示“这个类具备某种能力”对外暴露的是一份契约。举个例子。假设我要做一套定时任务框架。用抽象类 Task 可以定义 start()、stop()、execute() 的骨架start() 里写日志、锁、状态流转execute() 留给子类实现。这样每个子类都获得了任务的生命周期管理很合理。但如果我只是想知道“这个任务能不能被序列化”那应该定义一个 Serializable 接口跟继承无关。抽象类之所以至今仍有存在价值主要在于状态。抽象类可以声明各种非 final 字段、私有字段构造器可以做参数校验。接口里的字段只能是 public static final没法保存“每个实例自己的一份状态”。虽然接口可以有 default 方法但 default 方法无法访问非 static 的实例字段。所以当你要设计一类带有公共状态和构造逻辑的类族时抽象类依然是合适的底座。工程上很常见的实践是“接口 抽象基类”组合对外发布一个接口定义协议内部提供一个 AbstractXxx 抽象类把通用代码放进去。调用方依赖接口扩展方继承抽象类。这样既保持了对外契约的稳定又降低了实现方的重复劳动。5. 多态与类型转换从编译时到运行时的跳板5.1 动态分派到底发生在哪里多态的前提是继承核心是动态分派。当一个方法被调用时编译器看到的引用类型决定“这个方法能不能调用”运行时的真实对象类型决定“到底执行哪个版本”。这两个概念容易混淆。记得刚开始学 Java 时我总喜欢把引用类型和对象类型混为一谈。比如class Animal { void say() { System.out.println(animal); } } class Dog extends Animal { Override void say() { System.out.println(dog); } } Animal a new Dog(); a.say(); // 输出 dog这里变量 a 的编译时类型是 Animal但运行时对象是 Dog所以方法调用会动态分派到 Dog 重写的 say()。JVM 为了实现这种动态分派会为类维护一张虚方法表里面记录这个类有哪些方法、每个方法实际应该指到哪个实现地址。调用实例方法时JVM 指令 invokevirtual 会去查虚方法表而不是直接使用编译时类型里的方法地址。这也是多态高效且可靠的原因。这套机制带来一个常见的面试题静态方法有没有多态答案是没有。静态方法属于类本身通过引用变量调用时javac 看的是引用类型与运行时对象无关。所以 Animal a new Dog(); a.staticMethod() 执行的是 Animal 的静态方法哪怕这个静态方法在 Dog 里有同名版本。这也是我说“静态方法是隐藏而不是重写”的原因。5.2 向上转型、向下转型和 instanceof 避坑指南向上转型非常安全子类自动转父类不需要任何特殊语法也不用担心丢失什么东西。因为子类一定有父类的所有行为和字段父类引用看到的只是其中“父类那一面”。问题通常出在向下转型把父类引用转回子类。这种转换不是自动的需要强制转换并且运行时如果真实对象不是目标子类就会抛 ClassCastException。避免 ClassCastException 的最稳妥办法是用 instanceof 先做类型检查。Java 16 之前我只能这么写if (a instanceof Dog) { Dog dog (Dog) a; dog.walk(); }Java 16 之后可以用模式匹配简化if (a instanceof Dog dog) { dog.walk(); }不过 instanceof 也不是银弹。如果一个对象是 nullinstanceof 会返回 false不会抛异常这点一般人都知道。容易被忽略的是instanceof 对继承链上的所有父类型都返回 true。比如 Dog 对象 instanceof Animal 也是 true。所以你在做类型切换时一定要先判断子类型再判断父类型否则 if 分支顺序写反父类型分支永远先吃掉子类型分支后面的逻辑都不执行了。向下转型最常见的业务场景是拿到接口 / 抽象父类引用的对象集合时按具体类型做差异化处理。但如果你发现自己代码里到处都在向下转型说明接口或抽象父类设计得不够好通用方法没有抽象到位。一个设计良好的继承体系应该能在大多数场景下只依赖父类方法完成操作向下转型是例外而不是常态。6. 继承中最容易翻车的那批细节6.1 equals 和 hashCode 在继承里的世纪难题继承体系下写好 equals 和 hashCode是 Java 对象设计的公认难题。先看最经典的坑。我写一个 Point 类重写 equals 比较 x、y 坐标再写一个 ColorPoint 继承 Point额外加 color 字段。如果我不处理 color那么两个 color 不同的 ColorPoint 对象用 equals 比较会返回 true因为它们继承了 Point 的 equals只看了 x、y。这显然不合理。如果我直接在 ColorPoint 里重写 equals把 color 也加进去又会破坏对称性一个 ColorPoint 和一个 Point 比较Point 的 equals 只看坐标可能返回 true反过来 ColorPoint 的 equals 要求 color 也一致返回 false。同一个比较谁调用 equals 谁就影响结论这在业务里是要命的问题。常见的处理方式有两种。一种是使用 getClass() 判断两个对象必须是完全相同的类才可能相等这样 ColorPoint 和 Point 永远不相等但如果你需要“坐标相同就视为同一位置”这种方案会把父子类对象排除在外。另一种方案是只在父类用 instanceof 判断并允许子类不新增字段。可一旦子类新增字段不对称问题就无法百分百解决。所以我的实际建议是值对象优先用组合不要在这种需求下强行用继承如果确实要用继承就把 equals 的判断标准放在最顶层的抽象父类统一规定或者用 getClass() 明确地禁止跨类比较。hashCode 也必须跟 equals 保持一致。equals 相等的两个对象hashCode 必须相同否则在 HashMap 这类数据结构里会出现“同一个对象存在两个桶里”的诡异行为。最省心的办法是直接用 Objects.hash 或者 Java 的 record 特性来生成。6.2 final、private、static 与继承的边界final 修饰符在继承里承担两道防线final 类不能被继承final 方法不能被子类重写。String、Integer 这些核心类就是 final 类语言设计者不希望开发者在这些基础类型上随意做扩展从而破坏不可变性。工程上也可以借用 final 来关闭继承能力我见过不少团队把默认类设计成 final只有当确认有扩展需求时才去掉 final。这是一个好习惯。private 方法在继承视角里很有意思。父类 private 方法对子类完全不可见所以子类定义一个同签名的方法只是新方法不是重写。public 方法内部调用 private 方法时这个调用是静态绑定还是动态绑定答案是静态绑定因为 private 方法不会出现在虚方法表里调用时直接走具体指令不会有子类重写导致的行为偏移。这让我在前面构造器陷阱里优先建议调用 private 方法就是因为它不会被子类干扰。static 方法则完全是“隐藏”而非“重写”编译器依据的是引用类型而非运行类型。子类定义一个同签名 static 方法父类和子类的方法互不干涉谁引用谁就调用谁的版本。这种隐藏容易造成代码阅读上的误会我在团队里会要求能用实例方法就少用静态方法隐藏在继承链里如果必须用父子类静态方法签名最好不要完全一样。6.3 组合优于继承不是一句口号“组合优于继承”是面向对象设计里的经典建议但很多人只是听过不知道什么时候该信。核心原因在于继承会暴露父类的内部实现破坏封装。最典型的历史案例是 Stack 继承 Vector。Stack 只需要栈的 push、pop但是继承了 Vector 之后就也有 add、remove 这些操作用户可以直接往栈中间插入元素栈的语义被破坏。父类里任何需要维护的不变量在子类里都可能被绕过。继承越是往深层扩展这个风险越大。组合的思路是一个类需要另一个类的功能时把自己的父类位置占好内部持有被依赖类实例然后自己做转发。这样被依赖类的内部实现细节不会直接暴露到外层 API你完全控制哪些方法开放、哪些方法禁止。代价是多写几行转发方法但值得。我对选型的参考框架是这样的先做 is-a 判断通过后再说再看父类是否稳定如果父类还在频繁变更继承会让子类和父类一起被波及再看是否需要多态替换如果你确实需要一个存储 Animal 引用、运行时实际放入 Dog 或 Cat 的容器继承和多态是合理方案。如果以上答案都不明确那就别继承用组合先把结构搭起来。7. 实战模板方法模式与面试高频题速查7.1 一个可复用的模板方法模式案例模板方法模式是继承用得最漂亮、也最典型的场景。核心思路是父类把算法骨架定死把其中一些步骤留给子类实现。我在做报表导出功能时就实际用过这种设计来导出 CSV、JSON 等不同格式公共流程放在父类只有数据和正文格式允许子类自己定制。abstract class ReportExporter { // 算法骨架查询数据、生成标题、生成正文、写出 public final void export(String name, String path) { String[] data fetchData(name); String title buildTitle(name); String body buildBody(data); write(path, title System.lineSeparator() body); } // 必须由子类实现的部分 protected abstract String[] fetchData(String name); protected abstract String buildBody(String[] data); // 可选覆盖的部分默认给一个通用标题 protected String buildTitle(String name) { return name Report ; } // 骨架内部的私有工具方法不给子类碰 private void write(String path, String content) { System.out.println(write to path : content); } }class CsvReport extends ReportExporter { Override protected String[] fetchData(String name) { // 这里模拟查数据库 return new String[] { A,1, B,2 }; } Override protected String buildBody(String[] data) { return String.join(\n, data); } }你在子类只需要管“数据从哪来”“正文格式是什么”导出流程、写文件这些通用逻辑全被父类稳定控制住了。注意 export 方法我加了 final是刻意的因为我不希望任何子类把整个骨架打乱。模板方法模式在这种需要多个子类共享同一套流程、但流程中某些步骤各有特色的场景里非常顺手。7.2 面试高频题速查与避坑要点整理一下继这部分面试里出现频率比较高的题方便你临时抱佛脚。问题答案要点Java 是单继承还是多继承类是单继承接口可以多继承接口一个类可以实现多个接口。父类构造器和子类构造器的执行顺序先执行父类构造器再到子类字段初始化最后执行子类构造器体。子类构造器没有写 super() 会怎么样编译器会自动插入无参 super()如果父类没有无参构造器必须显式写带参 super(...)否则编译失败。static 方法能重写吗不能static 方法是隐藏编译器依据引用类型决定调用哪个版本。private 方法能重写吗不能private 方法对子类不可见子类定义同签名方法只是一个新方法。抽象类可以被实例化吗不可以即使它有构造器那个构造器也是给子类调用用的。final 类还能被继承吗不能编译器直接拒绝。接口里可以有字段吗可以但都是 public static final 常量不属于实例状态。instanceof 判断 null 会怎样返回 false不会抛空指针。父类构造器里调用子类重写方法会有什么后果子类字段此时还未初始化输出结果通常不符合预期。再多说一个细节。面试里很多人倒背“equals 要对称”但真让他们写代码时又容易只记 getClass 判断或只记 instanceof 判断。建议你在面试回答里点明如果类允许被继承且子类可能增加字段那么仅靠 equals 方法很难同时满足对称性和可扩展性这时候设计上优先用组合或者干脆把字段都用 final 修饰然后用 getClass() 严格控制类型。面试官听到这个层次才会觉得你不是在背八股文而是真的考虑过继承带来的实际问题。写到这儿也该交个底了。继承是我在 Java 里用得最谨慎的特性之一刚入门那阵子我特别喜欢 extends总觉得不继承一下就对不起面向对象的名头。实际写了几年代码、看了不少项目之后我反而更愿意先问一句“非继承不可吗”。能用组合就用组合能用接口画边界就用接口继承留给那些真正稳定的层次结构去承担。最后分享一个小经验如果你不确定一个类未来需不需要被子类扩展先标成 final等真的出现多态需求时再打开。把“可继承”变成显式决策之后踩进构造器坑和重写坑的概率会低很多。