Java继承详解:从零基础掌握extends、super、重写与多态 继承这个概念如果你第一次接触Java很可能会被一堆术语砸晕父类、子类、基类、超类、派生类、extends、super、重写……说实话我刚开始学的时候也是这个状态明明看着代码能“大概懂”但真要自己写一个继承关系却不知道该从哪里下手。这篇文章就是专门写给零基础读者的。我会尽量用大白话把继承讲清楚它到底解决了什么问题、代码该怎么写、运行的时候背后发生了什么、以及我在实际学习和工作中踩过的坑。学完之后你能看懂常见的Java继承代码也能自己动手设计出合理的继承结构顺便应付掉大部分面试里关于继承的“八股题”考点。1. 继承到底是干什么的1.1 先从“代码重复”说起假设你现在要写一个“宠物管理系统”需要描述猫和狗。不假思索地写大概会长这样class Dog { String name; int age; public void eat() { System.out.println(name 正在吃东西); } public void sleep() { System.out.println(name 正在睡觉); } } class Cat { String name; int age; public void eat() { System.out.println(name 正在吃东西); } public void sleep() { System.out.println(name 正在睡觉); } }你看两个类里的字段和方法几乎一模一样。如果再加一个“兔子”类又得把这段代码复制一遍。麻烦不说最要命的是后续维护哪天你想把“吃东西”这个行为改得更精细比如加上“嚼一嚼再咽下去”就得在狗、猫、兔子三个类里分别改漏掉一个系统表现就不一致。这种场景下继承的价值立刻就看出来了。我们可以把这些“共同的部分”抽取到一个父类里让猫、狗、兔子都去继承它公共代码只写一次。1.2 继承的本质is-a关系继承描述的是类与类之间的“是一个”关系。狗是一类动物猫也是一类动物所以可以让Dog、Cat继承Animal。英文里叫 is-a中文理解成“属于某一大类”就行。在Java语法上用extends关键字表示继承class Animal { String name; int age; public void eat() { System.out.println(name 正在吃东西); } public void sleep() { System.out.println(name 正在睡觉); } } class Dog extends Animal { public void bark() { System.out.println(name 汪汪叫); } } class Cat extends Animal { public void meow() { System.out.println(name 喵喵叫); } }继承之后Dog自动拥有了Animal里的name、age字段和eat()、sleep()方法同时又可以定义自己特有的行为。Cat同理。公共逻辑集中在父类维护成本瞬间降下来了。提示继承不是“把一个类装进另一个类里”而是“子类自动获得父类的能力并在其基础上扩展”。一个是 is-a一个是 has-a这两种关系在面向对象设计里经常被拿出来对比。1.3 继承带来的三个核心好处第一是代码复用。公共字段和方法写一遍所有子类都能用后续要改公共逻辑只动父类就行。第二是扩展性好。新加一个Bird类只要继承Animal写自己特有的fly()方法就能接入现有体系不用动任何旧代码。第三是多态的基础。只有存在继承关系才可能出现“父类变量指向子类对象”的写法也就是后面要讲的向上转型和动态绑定。Java面向对象的“封装、继承、多态”三大特性里继承是连接封装和多态的桥梁没有继承多态基本无从谈起。2. 动手写第一个继承程序2.1 基本语法和术语继承的语法只有一个关键字extends。class 子类 extends 父类 { // 子类自己的内容 }相关术语你需要熟悉一下被继承的类父类也叫超类、基类继承别人的类子类也叫派生类Java中所有类的根父类是Object如果你定义一个类没有写extends它会隐式继承Objectclass Person { // 什么都不写也默认继承 Object }Object里提供了一些基础方法比如toString()、equals()这也是为什么任何Java对象都能调用toString()的原因。2.2 构造器的执行顺序先说super写继承代码时新手第一关就是构造器。看这个例子class Person { String name; public Person(String name) { this.name name; } } class Student extends Person { String school; public Student(String name, String school) { super(name); this.school school; } }你可能会问super(name)是什么其实在Java里创建子类对象时会先调用父类的构造器来完成父类部分的初始化。如果子类构造器里没有显式写super(...)编译器会自动加一句调用父类无参构造器的super()。这里有个经典报错场景如果父类只有带参数的构造器没有无参构造器而你子类构造器里又没有写super(name)代码直接编译失败报错There is no default constructor available in Person我刚开始学的时候这个问题真的折腾了很久。解决办法也简单要么给父类补一个无参构造器要么在子类构造器第一行显式调用父类的带参构造器。需要特别注意的是super(...)调用父类构造器必须是子类构造器的第一条语句。这个限制是为了保证先初始化父类部分再初始化子类部分顺序不能乱。2.3 方法重写子类想换个写法继承之后子类对父类的方法不满意可以“重写”它。Java里用Override注解标记class Animal { String name; public Animal(String name) { this.name name; } public void sound() { System.out.println(name 发出声音); } } class Dog extends Animal { public Dog(String name) { super(name); } Override public void sound() { System.out.println(name 汪汪叫); } }这里Dog重写了sound()把自己的叫声改成“汪汪叫”。注意Override只是个注解告诉编译器“我想重写父类方法”。写不写都能编译但建议一定要写因为如果方法签名写错比如参数写多了一个编译器能立刻发现你其实没有重写成功避免出一些很隐蔽的问题。重写时需要遵守几个规则方法名、参数列表必须和父类完全一致返回类型可以是父类方法返回类型的子类型但通常保持一致最稳妥访问权限不能比父类更严格。父类是public子类就不能改成private这里顺便把重写和重载区分开很多面试题都会问对比项重写重载方法名相同相同参数列表完全相同必须不同发生位置父子类之间同一个类里关键字通常加Override无专门注解目的改变父类行为提供多种参数形式重载实际上跟继承没有必然关系它是“同一个方法名不同参数”。重写才是真正和继承强相关的概念。2.4 访问修饰符哪些能被继承Java提供了四种访问修饰符决定了成员在继承关系里的可见性修饰符同一个类同一个包不同包子类其他任何地方private能访问不能不能不能默认不写能访问能访问不能不能protected能访问能访问能访问不能public能访问能访问能访问能访问这就引出一个常见困惑父类的private成员到底算不算被继承严格来说private字段和方法是父类内部的实现细节子类对象在内存里确实包含这部分数据但子类代码无法直接访问它们。所以很多人的通俗说法是“private成员不能被继承”其实更准确的说法应该是“private成员不能被直接访问”。如果你希望子类能直接访问父类的某个字段又不希望暴露给外部就把它设成protectedclass Animal { protected String name; }但我不建议你滥用protected因为这相当于把内部的字段暴露给了所有子类继承层级一深很容易被某个子类修改得一团糟。更稳的做法是字段保持private用getter和setter去访问这个思想也是面向对象封装性的体现。3. 深入理解继承背后的运行机制3.1 new一个子类对象到底发生了什么看到Dog dog new Dog(旺财);你以为只是调用了Dog的构造器实际上背后是一整套初始化流程加载并初始化父类的静态成员加载并初始化子类的静态成员执行父类的实例变量初始化和初始化块执行父类构造器代码执行子类的实例变量初始化和初始化块执行子类构造器代码举个可观测的例子class A { int x initX(); public A() { System.out.println(A构造器); } int initX() { System.out.println(A初始化x); return 1; } } class B extends A { int y initY(); public B() { System.out.println(B构造器); } int initY() { System.out.println(B初始化y); return 2; } } // main里执行 new B();输出结果是A初始化x A构造器 B初始化y B构造器这个顺序其实很好记从父到子先静态后实例。记住它面试里问“继承时构造器的执行顺序”你就能直接秒答。3.2 父类引用指向子类对象这是继承最精彩的用法也对应多态。Animal a new Dog(旺财);代码合法吗合法。因为Dog是Animal的子类所以Dog对象可以“看作”一个Animal对象来用。这种写法叫向上转型也叫上转型对象。但有个地方非常容易翻车这个a变量只能调用Animal里定义过的方法不能直接调用Dog里特有方法。比如a.bark()会编译报错除非Animal里也定义了bark()或者你先把a向下转型回Dog。你以为真正有意思的地方在于如果Dog重写了eat()那么a.eat()调用的会是Dog里重写后的方法而不是Animal里的原方法。这就是动态绑定编译时看左边类型运行时看右边实际类型。Animal a new Dog(旺财); a.eat(); // 输出旺财正在吃东西但用的是Dog类重写后的版本这个特性是Java实现多态的核心机制之一也是为什么继承在面向对象里如此重要。3.3 用instanceof安全判断类型有时候你拿到了一个父类类型的变量想确认它背后到底是谁就可以用instanceofAnimal a new Dog(旺财); if (a instanceof Dog) { Dog d (Dog) a; d.bark(); } else if (a instanceof Cat) { Cat c (Cat) a; c.meow(); }instanceof左边是对象右边是类如果左边对象确实是右边类型或其子类类型就返回true。这很重要因为直接向下转型有风险Animal a new Dog(旺财); Cat c (Cat) a; // 编译能过运行时会抛 ClassCastException编译器只看得到a是Animal类型Animal能不能转成Cat从类型系统看可以因为Dog和Cat都继承自Animal。但运行时才发现a实际指向的是Dog对象强行转成Cat就会炸。所以向下转型前一定先用instanceof做一次判断。3.4 一个容易忽略的小坑构造器里调用了可重写方法这个坑很有代表性。假设父类构造器里调用了一个会被子类重写的方法class Animal { public Animal() { sayHello(); } public void sayHello() { System.out.println(Animal hello); } } class Dog extends Animal { private String name 旺财; public Dog() { super(); name 阿黄; } Override public void sayHello() { System.out.println(Dog hello, my name is name); } } // main里 new Dog();你以为会输出什么“Dog hello, my name is 阿黄”实际上很可能是Dog hello, my name is null原因是构造器执行顺序先执行父类构造器而new Dog()时子类的name字段还没被赋值为“旺财”更别说后面改成的“阿黄”。父类构造器里调用的sayHello()因为动态绑定实际执行的是子类重写后的方法可此时子类字段还未初始化读出来自然就是null。这个问题的教训是不要在构造器里调用可能被子类重写的方法否则容易读到未初始化的字段。这也是学习继承时一个很有价值的“隐性知识”。4. 为什么Java是单继承却没有失去灵活性4.1 单继承的设计考量Java规定一个类只能有一个直接父类这就是单继承。C则支持一个类继承多个父类也就是多继承。多继承看起来挺强大但有个很著名的现实问题菱形继承。比如D同时继承B和C而B和C都继承自A如果A里有个字段xD里继承到的x到底来自哪条路径各种语言处理起来都费劲还容易引发歧义。Java为了简单可靠直接砍掉了类的多继承只保留“单继承”把“多继承的能力”用接口来代替。接口和类不同接口可以继承多个接口一个类也可以实现多个接口这样既保留了多继承的灵活性又避免了菱形继承的复杂度。4.2 接口如何弥补单继承的不足比如你有一个Flyable接口一个Swimmable接口interface Flyable { void fly(); } interface Swimmable { void swim(); } class Duck extends Animal implements Flyable, Swimmable { public Duck(String name) { super(name); } Override public void fly() { System.out.println(name 在飞); } Override public void swim() { System.out.println(name 在游泳); } }Duck只能有一个父类Animal但可以通过实现多个接口获得多种能力。这种设计比多继承更清晰接口只定义“能做什么”不关心具体怎么做实现细节由每个类自己决定。不只是继承体系接口还在框架设计中扮演关键角色。你在后面学Spring、MyBatis这些框架时会大量看到“面向接口编程”核心思路就是把能力声明和行为实现解耦。4.3 用final阻止继承和重写有些设计需要禁止某个类再被继承或者禁止某个方法被重写Java里用final关键字实现final class Calculator { // 这个类不能被继承 } class Parent { public final void show() { System.out.println(show方法不能被重写); } }String类就是final的所以你不能写class MyString extends String。这样设计是为了保证String的定义和行为不被篡改涉及安全性和性能优化。5. 继承中的经典误区与面试考点5.1 super和this到底怎么区分简单记this指代“当前对象”super指代“当前对象的父类部分”。两条容易记混的规则this()用来调用本类的其他构造器super()用来调用父类构造器两者都必须在构造器第一行所以不能同时出现用super.xxx可以访问被隐藏的父类成员比如子类重写了父类方法后还想调用父类原方法就在子类方法里写super.方法名()有个场景特别典型子类重写getSalary()时想拿父类计算出的基本工资再叠加自己的补贴就可以在子类里调用super.getSalary()。我在后面综合案例里会演示。5.2 静态方法的“重写”其实不是重写父类有一个静态方法子类写一个方法签名完全相同的静态方法这在语法上能过但它是“隐藏”而不是“重写”。class A { public static void test() { System.out.println(A.test); } } class B extends A { public static void test() { System.out.println(B.test); } }调用时看的是变量类型A a new B(); a.test(); // 输出 A.test因为静态方法不参与动态绑定这点和实例方法完全不同。实例方法根据实际对象类型动态绑定静态方法只看声明类型。所以看到“子类重写父类静态方法”的说法记得纠正一下静态方法不能重写只能隐藏。5.3 成员变量也不叫“重写”子类定义了和父类同名的字段这也不叫重写叫“隐藏”shadowing。调用时同样看声明类型class A { int x 1; } class B extends A { int x 2; } A a new B(); System.out.println(a.x); // 输出 1a.x用的是A类型下的x。所以实际开发里子类尽量不要声明和父类同名的字段否则很容易引起混淆。想覆盖行为用方法重写字段就别乱动。5.4 继承与组合怎么选很多面试题会这样问继承是不是面向对象的首选复用方式答案是否定的。继承是强耦合关系。子类对父类的实现细节敏感父类一变子类可能跟着遭殃这就是所谓的“继承破坏封装”。比如父类在后续版本中修改了一个protected字段的含义所有子类都得排查一遍。组合则简单得多把已有的类作为另一个类的成员变量通过调用它的方法来实现功能双方耦合度低。一个经典的反面教材是用继承实现栈// 不推荐的做法 class MyStack extends ArrayListString { // 继承了ArrayList的add、remove等所有方法 }这样写问题很大因为ArrayList有很多公开方法比如add、remove、getMyStack全都继承了使用者可以绕过push/pop随意操作元素栈的语义被破坏。更合理的做法是组合class MyStack { private ArrayListString list new ArrayList(); public void push(String item) { list.add(item); } public String pop() { return list.remove(list.size() - 1); } }到底什么时候用继承当确实存在“is-a”关系且子类能复用父类的抽象接口时用其他情况优先考虑组合。还有一点经验不要为了省几行代码强行搞继承继承关系一旦定型后续重构成本往往比预想的高得多。5.5 常见问题速查表这里把继承学习中最常见的报错和意外汇总一下方便你排查问题现象根本原因解决办法子类构造器报错找不到父类构造器父类只有带参构造器且子类没写super(参数)在子类构造器第一行显式调用super(参数)或给父类补无参构造器明明觉得重写了方法却一直没生效方法签名写错比如参数类型不一致变成了重载加上Override注解让编译器帮忙检查父类private字段在子类里访问不到private只允许同一个类内部访问改成protected或提供getter/setter向下转型抛ClassCastException对象实际类型不是目标类型转型前用instanceof判断子类构造器里读父类字段是null构造器执行顺序原因父类构造器阶段子类字段还没初始化不要在构造器中调用可重写方法同名静态方法调用结果很怪静态方法隐藏而非重写不参与动态绑定调用静态方法用类名而不是对象名子类字段和父类字段同名行为混乱字段隐藏子类不要声明同名字段6. 综合案例员工薪酬系统前面讲了这么多现在来一个稍微完整点的案例把继承的语法、构造器、重写、多态、super串起来。场景是员工薪酬计算。先定义父类Employeepublic class Employee { private String name; private double baseSalary; public Employee(String name, double baseSalary) { this.name name; this.baseSalary baseSalary; } public String getName() { return name; } public double getSalary() { return baseSalary; } public void work() { System.out.println(name 正在完成本职工作); } }然后定义子类Manager管理者除了基本工资还有绩效奖金public class Manager extends Employee { private double bonus; public Manager(String name, double baseSalary, double bonus) { super(name, baseSalary); this.bonus bonus; } Override public double getSalary() { return super.getSalary() bonus; } Override public void work() { System.out.println(getName() 正在协调团队分配任务); } }注意到几个细节子类构造器第一行调用super(name, baseSalary)完成父类字段初始化父类里name和baseSalary是private所以子类的work()里用getName()来拿名字子类重写的getSalary()调用了super.getSalary()拿到父类计算的基本工资再叠加bonus这是复用父类逻辑的标准写法再来一个子类Intern实习生没有奖金可能有补贴但结构不同public class Intern extends Employee { private double allowance; public Intern(String name, double baseSalary, double allowance) { super(name, baseSalary); this.allowance allowance; } Override public double getSalary() { return super.getSalary() allowance; } Override public void work() { System.out.println(getName() 正在学习并协助处理日常事务); } }现在看多态带来的威力。写一个统一的方法来输出每个员工的薪资和工作内容不用关心具体类型public class PayrollSystem { public static void main(String[] args) { Employee[] employees new Employee[3]; employees[0] new Employee(张三, 5000); employees[1] new Manager(李四, 8000, 5000); employees[2] new Intern(王五, 3000, 800); for (Employee employee : employees) { employee.work(); System.out.println(本月薪资 employee.getSalary()); } } }运行结果张三正在完成本职工作 本月薪资5000.0 李四正在协调团队分配任务 本月薪资13000.0 王五正在学习并协助处理日常事务 本月薪资3800.0这个案例的关键点在于Employee数组里装了三种不同子类对象遍历时调用的work()和getSalary()都是各自类型的实际逻辑这就是多态在真实场景中的体现。以后你扩展新员工类型比如加一个Salesman只要继承Employee重写自己的getSalary()PayrollSystem里的代码一行都不用改。这种“对扩展开放对修改关闭”的设计正是面向对象追求的目标之一而继承就是支撑这套体系的重要基础。最后再分享一点我自己的体会学继承不要只看语法重点要理解“类与类之间的关系”和“对象创建时的初始化顺序”。很多网上说的“父类引用指向子类对象”“动态绑定”这些概念初看都觉得抽象但你亲手敲一遍上面的案例再故意制造几个报错看看很快就会形成肌肉记忆。面试考继承翻来覆去也就那么几个点——重写和重载的区别、构造器执行顺序、为什么单继承、super的作用、继承和组合怎么选这篇文章基本都覆盖到了。剩下的就是你打开IDE新建一个类把手真正弄脏了。