Java继承深入浅出:超类、子类、extends、重写与多态

发布时间:2026/10/12 16:17:18
Java继承深入浅出:超类、子类、extends、重写与多态
Java 的继承说到底是三件事超类、子类、extends。我见过不少新手在“父类”和“子类”之间绕晕明明是入门级的 Java 继承却被网上教材整得像玄学。“什么是继承”这种基础送分题也是 java 面试里最容易被问懵的。这篇文章我会用大白话把 Java 继承从头捋一遍从超类和子类的定义到构造器执行顺序再到方法重写、多态、访问修饰符最后把面试经常追着问的坑一起排掉。适合刚学 Java 基础的朋友也适合临时抱佛脚准备 java 面试的开发者。其实继承并不难难的是很多人没有把“关系”这个词想清楚。后面我按一条线来讲先搞懂继承是什么再亲手写一个继承案例然后逐个深挖重写、super、多态这些核心机制最后聊聊继承的边界——什么时候不该用继承。整个过程都会有真实代码和踩过的坑不看空洞的理论。1. 继承到底是什么先想清楚“父子关系”1.1 超类和子类的准确定义超类英文叫 superclass也有人叫父类、基类子类叫 subclass也叫派生类。这两个词其实是一个硬币的两面只要有一个类 A 被另一个类 B 通过extends引用A 就是超类B 就是子类。Java 里每个类只能有一个直接超类这就是单继承。举个例子。你定义一个Animal类里面有成员变量name和age有方法eat()。再定义一个Dog类继承它那么Dog就是子类Animal就是超类。Dog天然拥有name和age天然可以调用eat()不需要重新写一遍。用术语讲Dog是一种Animal也就是常说的 is-a 关系。新手经常纠结“子类是不是把超类的代码复制了一份”。不是。子类对象里确实会分配超类字段的空间但这些代码在字节码层面并没有被复制到子类里。它是通过继承机制获得访问权配合 JVM 的方法查找逻辑工作的。理解这一点很重要否则后面学“方法重写”和“多态”会很吃力。1.2 没有继承时写代码有多痛苦我刚学编程时写过一段很蠢的代码先定义一个CatService再定义一个DogService两个类里各写一遍eat()和sleep()只是打印日志不同。后来产品加需求要在eat()前后各加一行埋点我打开两个文件复制粘贴改了三遍还漏改了一处。这就是继承要解决的第一个痛点公共代码抽到超类。你把name、age、eat()、sleep()放进Animal所有动物子类直接复用改一次超类所有子类一起生效。继承解决的第二个痛点是统一行为入口。假如要写一个“开饭”的方法参数是Animal无论是Dog还是Cat都能传进去方法内部调用它们各自的eat()。没有继承的话你得写feedDog(Dog dog)和feedCat(Cat cat)两个重载子类一多就爆炸。而有了继承一个feed(Animal animal)就够这就是多态的基础。继承解决的第三个痛点是扩展。别人写的Animal类你是不能随便改的但可以通过继承加新方法、重写旧方法在不动原类的前提下扩展能力。这种“对扩展开放、对修改关闭”的思想也是面试常考的面向对象设计原则里的开闭原则。2. 手把手写一个继承案例extends 怎么用2.1 定义超类把公共成员往上提先写一个最简单的超类public class Animal { protected String name; protected int age; public Animal(String name, int age) { this.name name; this.age age; } public void eat() { System.out.println(name is eating...); } public void sleep() { System.out.println(name is sleeping...); } }字段我用protected而不用private是因为稍后子类要直接访问。protected的访问级别是“包内友好 子类可访问”对继承场景非常合适。eat()和sleep()是公共行为所有动物都有放在超类里再合理不过。超类最好提供一个有参构造器把对象初始化的条件说清楚。如果你不写任何构造器Java 会默认给一个无参构造器但只要写了有参构造器默认无参构造器就没了这一点很多人都会踩到后面我会细讲。2.2 创建子类extends 一行之后的变化接着写子类public class Dog extends Animal { public Dog(String name, int age) { super(name, age); } public void bark() { System.out.println(name is barking...); } }Dog继承了Animal所以它可以直接读name和age直接调用eat()、sleep()同时自己新增了bark()。这个bark()是Animal没有的能力是子类特有的扩展。注意构造器第一行写的是super(name, age)。因为超类没有无参构造器子类构造器必须在第一行显式调用超类构造器把参数传过去否则编译直接报错。如果你超类保留的是无参构造器子类不写super()也可以编译器会自动补上。创建对象跑一下Dog dog new Dog(Wangcai, 3); dog.eat(); // 超类方法可用 dog.bark(); // 子类方法可用dog.eat()能执行就是继承带来的直接好处。这个案例虽然短但把超类、子类、extends、super 这几个核心词的用法全串起来了。2.3 构造器执行顺序为什么“先有爸爸后有儿子”和热词“父类子类析构函数执行顺序”相关不过我们先说构造。Java 中构造顺序非常固定先执行超类构造器再执行子类构造器。原因很简单子类对象包含超类的部分这部分没有初始化完成子类自己的初始化就没有基础。你想想Dog的name是Animal定义的字段如果Animal构造器不先跑name就是 null后面bark()里打印就出问题。public class Animal { public Animal() { System.out.println(Animal constructor); } } public class Dog extends Animal { public Dog() { System.out.println(Dog constructor); } } public class Main { public static void main(String[] args) { Dog dog new Dog(); } }输出Animal constructor Dog constructor再说“析构函数”这个词。C 里的析构函数执行顺序是子类先、父类后但 Java 里根本没有析构函数。Java 的对象回收由垃圾收集器管理finalize()方法从 Java 9 开始已经标记为废弃也压根不保证执行时机。所以如果你是从 C 转 Java请忘掉“析构顺序”这回事。Java 中你要做资源清理应该实现AutoCloseable接口配合 try-with-resources 使用而不是寄希望于类似析构的机制。在面试里构造器执行顺序还有几个进阶问法如果超类没有无参构造器会怎样如果不写super(...)会怎样其实刚才已经回答了——编译期强制你在子类构造器第一行显式调用超类可用构造器。这也是为什么我在 2.2 不写super(name, age)就编译不过的原因。3. 方法重写在超类规则上“自定义”3.1 重写Override和重载Overload不是一回事新手最容易把这两个概念搅在一起面试也经常用它们做对比。简单说重写发生在继承关系中方法签名和方法名、参数列表完全一致子类重新实现超类的方法重载发生在一个类内部方法名相同但参数列表不同和继承没有必然关系。public class Animal { public void eat() { System.out.println(Animal eats); } } public class Dog extends Animal { Override public void eat() { System.out.println(Dog eats bones); } // 这是重载不是重写因为参数不同 public void eat(String food) { System.out.println(Dog eats food); } }上面的Dog里eat()没有参数是对超类Animal#eat()的重写eat(String food)是新增的重载方法两者并存互不干扰。执行时new Dog().eat()走的是重写后的版本new Dog().eat(apple)走的是重载版本。判断一个方法到底是重写还是重载最稳的方法就是看三点方法名是否相同参数列表是否相同是否跨类继承关系。三者同时满足前两个且发生在继承关系里才是重写。3.2 super 关键字的三种用法super在继承里是一种“回到超类”的引用。很多人以为super和this是对称的实际上super不是一个真正的对象引用它更像是编译器帮你在子类中定位超类成员的语法糖。有三个主要用法第一在子类构造器第一行调用超类构造器public Dog(String name, int age) { super(name, age); }第二在子类方法中调用被重写的超类版本Override public void eat() { super.eat(); // 先执行超类原来的逻辑 System.out.println(Dog adds bones...); }第三访问超类的字段public void printName() { System.out.println(super.name); }不过第三种我实际开发中很少用因为子类内部直接访问name也一样如果字段名被局部变量遮蔽可以用super.name来区分。写重写方法时我建议养成的习惯是先调用super方法再在合适位置增加子类逻辑或者反过来但一定要有清晰顺序。很多 bug 就出在忘记调用超类方法导致原有逻辑被整个覆盖。3.3 重写方法的四个硬性约束第一加Override注解。这不是语法要求但强烈建议加它能让编译器帮你检查“这个方法到底有没有重写成功”。如果方法签名写错不加注解时你可能以为重写了实际却是在子类里新增了一个重载方法加了注解就会直接编译报错。第二子类重写方法的访问权限不能比超类更严格。超类方法是public子类重写就不能是protected或private只能一样或更开放。原因很好理解凡是能用超类对象调用这个方法的地方换成子类对象也必须能调用不能因为换成子类反而被限制住。第三返回值类型可以是超类方法返回类型的子类型这叫协变返回类型。比如超类返回Animal子类重写返回Dog是可以的。但返回类型是基本类型时则必须完全一致。第四static方法和final方法不能被重写。static属于类final被刻意锁死。你可以在子类写一个同名的静态方法但那叫隐藏shadowing不叫重写调用时跟着引用类型走不是动态绑定这个细节面试喜欢挖坑。4. 访问修饰符与多态继承的光和影4.1 四个访问级别在继承中分别是什么脾气Java 有四个访问修饰符public、protected、默认包私有、private。它们在继承中的表现差异很大建议直接记这个表修饰符同类同包子类跨包任意类public可以可以可以可以protected可以可以可以不行默认可以可以不行不行private可以不行不行不行注意protected那行很有迷惑性。很多人以为“子类都能访问父类 protected 成员”这句话严格来说只说对了一半。跨包子类确实能访问protected成员但只能在子类自己的对象里通过继承关系访问不能在其他代码里用子类对象去访问父类的protected成员。举例说Dog里写new Dog().name是可以的但你在另一个类里写dog.name如果name是protected就可能报错。private成员看似被子类完全隔离其实它们在子类对象的内存中仍然存在只是子类代码没有访问权。你可以在超类里提供public getter/setter子类就能间接操作这些私有字段。这也是“封装”和“继承”配合使用的经典模式。4.2 多态父类型引用指向子类对象先看代码Animal a new Dog(Wangcai, 3); a.eat();a的声明类型是Animal实际对象是Dog。调用a.eat()时JVM 在运行期根据对象的实际类型去找方法执行的是Dog重写后的eat()。这就是动态绑定也是多态的核心。多态最大的价值在写通用代码。你写一个方法public void feed(Animal animal) { animal.eat(); }传入Dog可以传入Cat也可以以后新增一个Bird子类也照样能传进来完全不需要改feed方法。如果没有多态你只能写一堆重载或者用instanceof判断类型代码又臭又长。很多人把“封装、继承、多态”当成三个割裂的概念其实它们是递进关系封装把数据和操作绑定到类里继承让人能复用和扩展类多态则让“超类引用 子类对象”这种组合发挥出统一处理的威力。面试时说清楚这条关系链比背三个定义强得多。4.3 向下转型与 instanceof什么时候需要“还原身份”多态允许向上转型但反过来就有代价。Animal a new Dog(...)之后你想调用Dog特有的bark()不能直接a.bark()因为编译期a的类型是Animal。这时候需要向下转型if (a instanceof Dog dog) { dog.bark(); }Java 16 之后instanceof支持模式匹配边判断边声明变量上面写法很干净。转型前用instanceof判断是必须的。如果不判断就强制转型遇到实际类型不是Dog的情况运行期会抛出ClassCastException。向下转型在写框架代码时很常见比如遍历对象集合后需要按具体类型处理。正常业务代码里如果频繁向下转型往往说明超类抽象设计得不够好很多应有能力没有定义到超类上。能少转就少转。5. 别把继承当万能钥匙组合优先于继承5.1 滥用继承的坏味道继承虽然好用但它也是类之间最强的一种耦合方式。子类一旦继承超类超类的任何改动都可能波及子类。如果继承层次达到三层以上比如A被B继承、B被C继承改A的一个方法签名B和C可能都要跟着改这就是“脆弱的基类问题”。还有一个经典的反面案例用继承去表达“有”的关系。比如你要写一个Student它“有”手机号码按本能你可能想定义一个Phone类让Student继承它。但Student并不是一种Phoneis-a 关系不成立。这种错误继承会让类职责混乱Student和Phone的语义完全被绑架。Java 不支持多继承其中一个原因就是多继承容易引发菱形问题D同时继承B和C而B、C都重写了A的方法D到底继承哪个版本Java 用“类单继承 接口多实现”绕开了这个困境接口之间冲突可以自己显式解决。这个知识点面试也常问别只知道“Java 不支持多继承”就结束要能说出原因。5.2 组合才是更安全的复用方式组合的意思是一个类持有另一个类的引用通过调用对方的方法来复用能力。例如public class Dog { private TrainingService trainingService; public Dog(TrainingService trainingService) { this.trainingService trainingService; } public void train() { trainingService.train(this); } }这里Dog和TrainingService是 has-a 关系。相比继承组合的耦合更弱TrainingService怎么变Dog的代码受影响面小甚至可以通过替换不同实现来改变Dog的行为。那什么时候该用继承我的经验是三个条件同时满足才用明确的 is-a 关系、子类确实需要复用超类的大部分行为、并且需要利用多态把子类对象当作超类来用。条件缺一优先考虑组合。5.3 final 关键字主动“断子绝孙”也是一种设计final用在类上表示这个类不能被继承用在方法上表示这个方法不能被子类重写。最常见的例子是String类它就是final的。为什么设计成final因为String在 JVM 里被大量用作哈希键和常量池数据如果允许子类继承并重写表现可能导致各种安全性和一致性灾难。你无法保证所有第三方写的String子类行为都一致。自己设计类时final也很有用。如果一个类当初就没打算被继承加上final能防止别人误用也让代码意图更明确。线程安全、不可变设计、常量类这几类场景往往都需要final来保驾护航。在实际项目里我见过有人为了图省事把所有工具类都写成可继承的“沼泽类”最后改动时举手维艰。把类设计成final其实是对未来维护做减负别觉得不让继承就是小气。6. 高频面试题与避坑清单带着答案上考场6.1 继承相关面试题速答表这里我把入行以来遇到的、和继承相关的面试问题整理成一张速查表背熟基本能顶住关于继承的追问问题核心答案super 和 this 的区别this 指当前对象super 是访问超类成员的语法入口super() 只能在子类构造器第一行调用子类可以访问父类的 private 字段吗不能直接访问但该字段存在于子类对象中可通过父类提供的 public/protected 方法间接操作静态方法能被重写吗不能。同名静态方法在子类中是隐藏调用时看引用类型不是动态绑定构造器能被重写吗构造器不继承所以不存在重写这一说为什么 Java 不支持多继承避免菱形问题类只允许单继承接口允许多实现来弥补扩展需求向上转型和向下转型的区别向上转型自动安全面向多态编程向下转型强制且需要 instanceof 校验父类构造器抛异常时子类怎么做子类构造器无法捕获父类构造器异常只能把同样的异常继续向上抛或换成可用构造器Integer 是 Number 的子类吗是Integer extends Number体现了包装类型的继承关系上面这几个问题尤其是 super 和 this、静态方法重写、为什么单继承几乎隔三差五就会出现在 java 面试题里。如果你能顺着答案把前文对应的知识点再解释一遍面试官对你的理解深度会更认可。6.2 实操中踩过的坑构造器里的隐藏陷阱有一个坑很容易踩在超类构造器里调用一个被子类重写的方法。看下面这段代码public class Animal { public Animal() { eat(); // 注意这里动态绑定到子类 } public void eat() { System.out.println(Animal eat); } } public class Dog extends Animal { private String food bone; public Dog() { super(); } Override public void eat() { System.out.println(Dog eat food); } }new Dog()的执行顺序是先进入Dog构造器然后调用super()在Animal构造器里执行eat()。由于多态这里动态绑定到了Dog的eat()而此时Dog的字段food还没初始化结果是打印Dog eat null。真实开发中超类构造器尽量不要调用可重写的方法。如果非要调用就把依赖的字段挪到构造器参数里传进来或者把方法设计成final。我也见过不少老手在这个问题上翻车因为打印结果和预期不一致排查很久才发现是初始化顺序导致。6.3 深拷贝时候的继承字段问题热词里有个“java对象深度拷贝”它和继承也有关系。如果你把对象拷贝和继承混在一起坑会更多。比如public class Animal implements Cloneable { protected String name; protected ListString habits new ArrayList(); Override public Animal clone() { try { Animal copy (Animal) super.clone(); copy.habits new ArrayList(habits); return copy; } catch (CloneNotSupportedException e) { throw new RuntimeException(e); } } } public class Dog extends Animal { protected String breed; Override public Dog clone() { return (Dog) super.clone(); } }这里有一个关键细节Dog.clone()调用super.clone()但super.clone()最终是Object.clone()在起作用它会把Dog对象的所有字段一次性浅拷贝包括超类的name和habits。如果你只在Animal.clone()里深拷贝了habits那么经由Dog.clone()调用时复制逻辑照样会执行到Animal.clone()里的深拷贝所以habits依然是深拷贝。但如果Dog自己也有引用类型字段Dog必须在自己的clone()里处理否则就是浅拷贝。这说明一个规律继承体系里的 clone、equals、hashCode 都是连锁操作。重写子类方法时往往要同步考虑超类字段的完整性。写 clone 和 equals 时先调用 super 版本再补充子类字段顺序和构造器正好相反但这个先后逻辑很多人记不住容易漏字段。6.4 关于“析构函数执行顺序”的补充我看到热词里有“父类子类析构函数执行顺序”这里再明确一次Java 中没有析构函数。资源释放应该用AutoCloseable接口。如果超类和子类都实现了close()执行顺序是“子类关自己的再关超类的”这一点和构造顺序相反也符合“先做好自己的清理再清理基础部分”的逻辑。但这个和 C 的析构函数执行顺序只是表面相似底层机制完全不同别混在一起答。写代码时我一般会用 try-with-resources 来保证关闭顺序try (Dog dog new Dog()) { dog.doSomething(); }前提是Dog实现了AutoCloseableclose()里释放它自己持有的资源并在最后调用或者依赖超类的close()方法释放共享资源。这种写法比依赖finalize()可靠得多。6.5 最后的实战建议关于继承我的体会是入门时一定要拿笔在纸上画出类的继承关系图把超类的字段方法标出来再标上子类新增和重写的部分。别看这一步简单它能帮你把“子类成员 继承来的 自己增加的 自己重写后的”这个模型焊死在脑子里。我带了几年新人发现凡是能用一张图画清楚继承关系的人写面向对象代码出 bug 的概率会少一半。写代码之前我也会先问自己三句话这个类是真正的 is-a 关系吗子类真的需要被当作超类来用吗超类里的这部分逻辑换成组合是不是更稳三个问题都能过关再用继承。最后送一个小技巧如果你在子类里频繁用super去访问超类公有方法而且每个子类都要重写不同行为那说明超类的抽象层可能设计得不够。好的继承设计应该是子类重写的方法数量越少越好超类把“骨架”搭好子类只负责补充变化的细节。这个判断标准可以拿来审视你自己写的每一个继承关系。