Java里的继承,三个字,算是最基础的概念之一。但恰好是这种基础概念,在实际项目里最容易被用错。我见过为了省几行代码强行拉出继承结构,结果需求一变,父类一改,所有子类跟着遭殃;也见过完全不敢用继承,连公共字段都要复制粘贴的。继承不是简单的“子类 extends 父类”,它背后牵涉到设计约束、构造链、访问权限、多态绑定机制。这篇内容从复用讲到语法细节,再带一个完整的实战案例,最后把最常见的几个坑列出来,适合正在学 Java SE 的开发者,也适合工作中想回头把基础补扎实的人。
1. 继承到底解决了什么问题
1.1 从代码复用开始说起
程序员对复用的追求,几乎是从第一行重复代码开始的。假设有一套动物体系,Dog 和 Cat 都有 name、age 字段,都有 eat()、sleep() 方法。如果不做继承,就只能各自写一遍。代码一多,问题就来了:我要给所有动物加一个“年龄换算成人类年龄”的方法,就得去每个类里改一遍,漏掉任何一个,行为就不一致。继承给了一种基础解法:把这些共性的字段和行为抽到一个父类 Animal 里,Dog 和 Cat extends Animal,共性只需要写一次。
但这个复用是有代价的。子类继承了父类的实现细节,也意味着和父类产生了强绑定。父类里改动一个方法的实现,所有子类都会受影响。后来大家提倡“组合优于继承”,就是因为继承其实是一种白盒复用,父类的内部结构对子类透明;组合则是黑盒复用,我只通过公开接口调用另一个对象的能力,内部怎么变我不关心。所以碰到复用需求时,先别急着 extends,问问自己这两类之间是否有稳定的 is-a 关系。
1.2 怎样判断该不该用继承
我平时判断的标准很简单。第一,子类确实可以完全替代父类使用,也就是满足里氏替换原则。第二,父类是稳定的抽象,而不是为了省事临时堆出来的基类。第三,编译器层面的多态会给设计带来明显收益。拿动物举例,Dog 是 Animal,Cat 也是 Animal,这是天然的分类关系,适合继承。但如果你只是想让一个工具类复用另一个工具类的几个方法,那大概率应该用组合,或者抽一个公共工具方法,而不是让工具类去继承工具类。
还有一个常见误区:“我就想用一个现成的字段,所以继承”。字段继承是最廉价的复用,但也最容易造成混乱。继承的核心价值在于行为扩展和类型统一,而不只是字段复用。如果两个类之间没有行为上的共同逻辑,仅仅有几个相同的字段,那我建议你做一个值对象类,然后用组合持有它,而不是强行造一个父类。这种判断做清楚之后,下面的语法细节才有意义。
2. 核心语法与细节拆解
2.1 类继承的基本写法
Java 里类继承的定义很简洁,关键词就是 extends。每个类只能 extends 一个父类,这是 Java 刻意做的减法,避免 C++ 里多继承带来的复杂性。所有没有显式写 extends 的类,默认都直接继承自 Object。Object 提供了 equals、hashCode、toString 等基础方法,这也是为什么任何对象都能被 toString()。
public class Animal { protected String name; public Animal(String name) { this.name = name; } public void eat() { System.out.println(name + " is eating."); } } public class Dog extends Animal { public Dog(String name) { super(name); } public void bark() { System.out.println(name + " is barking."); } }这里有两个点要留意。第一,子类可以访问父类中允许访问的成员,但 private 的字段和方法是拿不到的,所谓“继承”,继承的是 public 和 protected 层面的能力,而不是父类一切私有内容。第二,构造器不会被子类继承。你不可能 new Dog() 时直接把 Animal 的构造器“继承”过来用,子类必须重新定义自己的构造器,并且在构造过程中主动或隐式调用父类构造器。这就引出 super 关键字的用法。
2.2 构造链与 super 关键字
Java 对象构造是一个从父类到子类的链式过程。创建子类对象时,JVM 会先执行父类的构造器,再执行子类自己的构造器。如果你在子类构造器里没有写 super(),编译器会自动帮你加一行无参的 super() 调用。这个行为很隐蔽,很多新手第一次遇到 “Implicit super constructor is undefined” 编译错误时一脸懵。
比如上面例子里,父类 Animal 只有一个带 String 参数的构造器,没有无参构造器。子类 Dog 构造器里如果不显式写 super(name),编译器找不到父类无参构造器,直接报错。正确的写法必须把 super 调用放在构造器第一行。原因很简单:父类部分必须先初始化,子类才能安全地使用继承下来的字段和方法。
super 还可以用来访问父类中被子类隐藏的成员。如果子类定义了和父类同名的字段,或者重写了父类方法,想调用父类版本时,可以通过 super.xxx。注意,super 不是父类对象的引用,它更像一个编译器用来定位父类成员的关键字。你不能把 super 赋值给一个变量,这和 this 不一样。
2.3 方法重写:不只是换实现
重写(Override)是继承的重要体现。子类对父类的方法重新实现,对外仍然是同一个方法签名,但行为可以不同。这里有几个硬性规则,面试和工作中都容易踩:
- 方法名、参数列表必须完全相同,否则是重载而不是重写。
- 返回值类型可以相同,也可以是父类返回值类型的子类型,这叫协变返回类型。比如父类方法返回 Animal,子类可以返回 Dog。
- 访问权限不能比父类更严格。父类是 public,子类就不能降成 protected 或 private。
- 抛出的受检异常不能比父类更宽泛。父类不抛异常,子类就不能抛受检异常;父类抛 IOException,子类可以抛 FileNotFoundException,但不能抛 Exception。
为什么要限制访问权限和异常范围?因为继承要符合里氏替换原则。一个地方用父类类型调用方法,它能访问的权限和它需要处理的异常,子类必须能兜得住。如果子类把 public 改成 private,外部代码按父类类型调这个方法时,运行时就会直接炸。
重载和重写的区别,我常用一句话解释:重写是父子类之间、方法签名相同、跑起来看实际类型;重载是同一个类内部、方法签名不同、编译期就能决定调用哪个。两者一点不冲突,一个类里可以既重载自己的方法,又重写父类的方法。
2.4 protected 的访问权限细节
protected 可能是 Java 访问权限里最容易搞混的。它表示包内可见加子类可见,但这个“子类可见”有个限制:子类只能通过自己的实例来访问 protected 成员,不能通过父类引用去访问另一个对象的 protected 成员。
举例说明:
public class Father { protected int money; } public class Son extends Father { public void test(Father father) { // father.money 在这一行访问不了 // 只能通过自己的引用访问 this.money = 10; } }原因在于 protected 的边界是“继承链内的实例”,而不是所有 Father 类型的对象。你继承的是“属于你这条继承链”的 money,不是父类任意实例的私有财产。这个细节在重构跨包代码时特别容易踩,建议记牢。
访问权限从小到大排序是:private < 包私有(不写修饰符)< protected < public。设计父类时,如果成员只供子类使用,就设 protected;如果纯粹内部实现且不想暴露给任何人,就设 private。能不用 protected 就不用,因为 protected 其实已经是“半公开”状态,外部包只要有人继承你,就能接触到这个成员。
2.5 final 在继承中的角色
final 修饰符在继承体系里相当于“到此为止”的标记。final 类不能被继承,比如 java.lang.String。final 方法可以被子类继承,但不能被重写。final 字段如果初始化了,子类能读但不能改。
为什么 String 是 final?核心原因是安全和设计稳定。字符串被广泛用作哈希键、网络传输、类加载等场景,如果允许子类继承并篡改行为,整个安全模型会出问题。我们自己写基类时也常用 final 方法把固定算法步骤锁死,比如模板方法模式里,骨架方法定义成 final,只把可变的步骤交给子类重写。
final 还有一个容易忽视的点:final 方法在编译阶段有可能被内联优化,但现代 JVM 并不依赖这个来做性能优化。所以不要为了“性能”去随便加 final,更多是为了语义约束。
3. 多态与继承:这才是继承的灵魂
3.1 向上转型是什么
继承让一个子类对象可以被当作父类类型来使用,这就是向上转型。比如 Dog 是 Animal 的子类,你可以写:
Animal a = new Dog("旺财"); a.eat();编译阶段,a 的静态类型是 Animal,运行时,实际对象是 Dog。调用 eat() 时,JVM 会动态找到 Dog 类里重写后的 eat() 来执行。如果 Dog 没有重写,就用 Animal 里的版本。这种能力是所有面向对象多态的基础。
向上转型是自动发生的,不需要强转。反过来,向下转型就要小心,必须先通过 instanceof 判断,否则可能抛 ClassCastException。我见过不少代码用强制向下转型,却忘了上一个 if 判断,一旦传入其他子类,线上就炸。
3.2 动态绑定的机制
Java 中方法调用默认是动态绑定的。所谓动态绑定,是指运行时根据对象的实际类型决定调用哪个方法,而不是根据声明类型。JVM 在类加载时会给每个类建立一个方法表,记录所有方法签名到实际实现入口的映射。调用实例方法时,先找到实际类型的方法表,再查对应条目。
这里有个容易混淆的地方:字段和静态方法是没有动态绑定的。访问字段时,编译器看的是声明类型。例如:
class Father { String name = "father"; public void hello() { System.out.println("hello father"); } } class Son extends Father { String name = "son"; @Override public void hello() { System.out.println("hello son"); } } Father f = new Son(); System.out.println(f.name); // 输出 father f.hello(); // 输出 hello son同样的对象,读取字段和调用方法结果却不一样,这就是“字段隐藏”和“方法重写”的差异。子类里重名的字段会把父类字段隐藏掉,但通过父类引用访问时,看到的仍然是父类字段。所以设计子类时千万不要为了省事定义与父类字段同名的成员变量,这是一个非常隐蔽的坑。
静态方法的调用类似于字段隐藏。子类可以定义一个与父类静态方法签名相同的静态方法,但这不是重写,而是隐藏。调用时看的是引用类型:
Father f = new Son(); f.staticMethod(); // 调用的是 Father 里的静态方法3.3 面向抽象编程
继承 + 动态绑定带来的最大收益,是代码可以依赖抽象而不是具体实现。函数的参数类型写父类,调用方传哪个子类都行,函数内部只看到父类定义的能力。这样做的好处是新增子类时,调用方和已有逻辑都不需要改,符合开闭原则。
举个例子,系统里需要给每个员工计算工资。如果每个员工类型都写一个单独的方法,新增类型时就要到处加方法。如果参数统一用 Employee 父类,内部调用一个抽象的 calcSalary(),新增类型时只需要新增子类,不修改现有调用逻辑。这也是很多设计模式的基础:模板方法、策略模式、工厂模式都在利用继承的抽象能力。
但也别为了“面向抽象”而滥用抽象。如果一个父类只有声明能力,没有任何可以共享的逻辑,那它更像是接口的职责,而不是抽象类的职责。抽象类和接口的选用,后面实操案例会再展开。
4. 实操过程:一个员工薪资系统从 0 到 1
4.1 需求模拟:不同员工不同计薪方式
我拿一个常见的业务场景来做完整演示。现在要做一个员工信息管理系统,主要有三种员工:
- 固定月薪员工:每个月拿死工资。
- 时薪员工:按小时数乘以小时单价结算。
- 销售员工:底薪加销售额提成。
所有员工都有姓名、工号。系统需要提供一个统一的薪资打印入口,外部只传一个员工对象,就能输出正确的工资单。
如果不用继承,最直接的做法是在员工类里加一个类型字段,然后用 switch 去判断:
public String calcSalary(Employee e) { switch (e.getType()) { case MONTHLY: ... case HOURLY: ... case SALES: ... } }这种写法在业务扩展时特别难受。第四种员工类型出现时,你不仅要新增一个 case,还有可能漏改别处的 switch。时间一长,所有和员工类型有关的逻辑都堆在调用方,互相踩。继承的做法是把“计薪方式”这个变化点下沉到每个子类里。
4.2 基类设计:抽象类还是普通类
我的设计是把 Employee 做成抽象类,把 calcSalary 声明成抽象方法。抽象类的价值在于:它不能实例化(因为没有完整的计薪方式,new 出来也没有意义),但可以沉淀所有员工共有的字段和公共逻辑,比如姓名、工号、打印基本信息的方法。
public abstract class Employee { private String id; private String name; public Employee(String id, String name) { this.id = id; this.name = name; } public String getId() { return id; } public String getName() { return name; } public void printBaseInfo() { System.out.println("工号: " + id + ",姓名: " + name); } // 计薪方式留给子类 public abstract double calcSalary(); }为什么用抽象类而不是接口?因为这里所有子类都共享 id、name 字段和 printBaseInfo 方法,抽象类可以把实现细节放进去,子类直接继承,不必每个子类重复写。如果把这一层换成接口,字段和公共实现都无法“继承”,只能在每个子类里复制,或者靠默认方法——那会很别扭。
抽象方法少没关系,关键是有没有把该锁定的结构锁住。抽象类里已经写好 printBaseInfo,同时又强制子类实现 calcSalary,这种组合在业务建模里很常见。
4.3 子类实现:三个计薪方式
固定月薪员工最简单,构造器里传入固定工资,calcSalary 直接返回。
public class MonthlyEmployee extends Employee { private double monthlySalary; public MonthlyEmployee(String id, String name, double monthlySalary) { super(id, name); this.monthlySalary = monthlySalary; } @Override public double calcSalary() { return monthlySalary; } }时薪员工需要记录本月工时和时薪单价:
public class HourlyEmployee extends Employee { private double hoursWorked; private double hourlyRate; public HourlyEmployee(String id, String name, double hoursWorked, double hourlyRate) { super(id, name); this.hoursWorked = hoursWorked; this.hourlyRate = hourlyRate; } @Override public double calcSalary() { return hoursWorked * hourlyRate; } }销售员工是底薪加提成:
public class SalesEmployee extends Employee { private double baseSalary; private double salesAmount; private double commissionRate; public SalesEmployee(String id, String name, double baseSalary, double salesAmount, double commissionRate) { super(id, name); this.baseSalary = baseSalary; this.salesAmount = salesAmount; this.commissionRate = commissionRate; } @Override public double calcSalary() { return baseSalary + salesAmount * commissionRate; } }这里有个细节:每个子类构造器第一行都是 super(id, name)。因为父类没有无参构造器,所以必须显式传参。这样一个设计也保证了任何 Employee 创建时都一定有 id 和 name,不可能出现名字为空的对象。
4.4 用多态统一调度
现在写一个工资单打印入口,接收 Employee 类型即可:
public class PayrollService { public void printSalary(Employee employee) { employee.printBaseInfo(); System.out.printf("本月薪资: %.2f%n", employee.calcSalary()); } }调用方可以这样使用:
Employee e1 = new MonthlyEmployee("001", "员工甲", 8000); Employee e2 = new HourlyEmployee("002", "员工乙", 160, 50); Employee e3 = new SalesEmployee("003", "员工丙", 4000, 100000, 0.05); PayrollService service = new PayrollService(); service.printSalary(e1); service.printSalary(e2); service.printSalary(e3);你会发现,printSalary 方法完全不知道对方是哪种员工,也不需要知道。新增第四种员工时:
- 新增一个子类,继承 Employee,实现 calcSalary。
- PayrollService 和既有调用代码一行都不用改。
这就是继承配合多态带来的可扩展性。如果哪天我需要在打印前加个“员工等级校验”,只需要在父类把方法定义好,或者再引入一个策略,不会因为员工类型多而写一堆 if。
4.5 业务比较:别让 equals 失去业务含义
这个案例还有一个小坑值得提。员工对象经常要用 id 来判断是否同一个人。如果不重写 equals,两个相同 id 但不同实例的员工会被认为是不同对象,Set 和 Map 就会出问题。而如果重写 equals,就必须在父类设计时就预留好 id 的 equals 逻辑,或者让每个子类自己实现。我建议在抽象父类里根据 id 重写 equals 和 hashCode,子类不再重写,这样整个员工体系的身份规则是统一的。
@Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Employee)) return false; Employee employee = (Employee) o; return Objects.equals(id, employee.id); } @Override public int hashCode() { return Objects.hash(id); }这里要强调:equals 里用 instanceof 有一些争议,但 Employee 存在子类且业务身份只看 id 时,用 instanceof 其实符合需求。如果哪天不同子类有严格的身份区分,再调整成 getClass() 比较也不晚。
5. 常见问题与排查技巧实录
5.1 父类没有无参构造器导致编译失败
这是新手遇到最多的编译问题。父类定义了带参构造器之后,编译器不再生成默认无参构造器,子类构造器如果不显式 super(参数),编译器就找不到可调用的无参父类构造器。IDE 报错通常会显示出错行在子类构造器的第一行。
处理方案有几种:
- 父类能补一个无参构造器,但要注意无参构造器里的字段可能没初始化。
- 子类显式调用 super(参数),传对应的值。
- 如果子类本身也打算当作抽象基类,可以定义 protected 构造器。
我的习惯是:如果父类的身份字段是必填的,就直接不写无参构造器,强制子类显式传参,从编译层面保证每个继承链上的对象都有完整数据。虽然写起来麻烦一点,但比运行时空指针舒服得多。
5.2 重写方法时报错:访问权限不能缩小
重写规则里访问权限只能扩大不能缩小,这一点经常被忽略。典型场景是早期写的代码方法用 public,后来觉得暴露面太大,想改成 protected,结果发现子类和方法调用方都在报错。如果只是内部逻辑不想被外部调用,不建议通过缩小方法访问权限来达成,更应该做的是调整类之间的接口设计。
我还见过一个奇怪的组合:父类方法是 protected,子类想把这个方法暴露成 public,用 public 重写没问题;但反过来,父类是 public,子类改成 protected,编译器直接拒绝。这是因为你的代码一旦以父类类型访问这个方法,Java 必须保障它仍然可访问。
5.3 方法重写时忘了加 @Override
@Override 不是必须写的,但不写会埋雷。比如你想重写父类方法,手滑写成了 calcSalaray(),少了个 y。编译器不会报错,因为这不是重写,只是子类新增了一个方法。运行的时候,多态调用走的还是父类原方法,看似能用,实际行为不对。
我个人的铁律是:任何重写方法,一律加上 @Override。加了之后,如果方法签名和父类不一致,编译期直接报错,把问题提前暴露。IDE 的快捷键也能帮你快速生成重写方法,减少手写拼错的概率。
5.4 继承层级过深:三层以上就要警觉
“万物都是 Object 子类”给了你一条无限往下的继承链,但不代表你应该创造一个 10 层的继承树。我见过有人把订单父类拆成:BaseEntity -> AuditableEntity -> ProductOrder -> PhysicalOrder -> PhysicalSellOrder,五层下来,光是理解每个父类各自提供了什么,就要翻半天代码。
继承天生是强耦合的,层级越深,动任意一层都可能波及其他层。如果发现继承树超过 3 层,先停下来重构。最常见的出路是:
- 把中间层改成组合,用字段持有公共能力。
- 把不稳定的行为从类继承改成接口 + 默认方法。
- 把数据和行为解耦,设计成更扁平的领域对象。
另外要警惕继承被当作“配置工具”。父类加字段,子类就能用,于是基类越堆越胖,最后变成一个无所不包的大杂烩。好的父类应该是一组高内聚的共性,而不是一堆零散字段的收纳箱。
5.5 接口多实现与默认方法的冲突
Java 类只能单继承,但接口可以多实现。所以很多设计取舍里,接口充当了“多继承”的角色。接口能定义抽象方法,Java 8 开始还可以定义 default 方法。如果两个接口里有相同签名的 default 方法,一个类同时实现它们,编译器就会要求你重写这个方法并手工指定调用哪个。
举个例子:
public interface A { default void hello() { System.out.println("A"); } } public interface B { default void hello() { System.out.println("B"); } } public class C implements A, B { @Override public void hello() { A.super.hello(); // 明确调用 A 的默认方法 } }这种冲突看起来简单,但一旦接口层级变多,定位“到底调哪个默认方法”会很费劲。设计接口时,default 方法一定要克制,能不用就不用。接口的职责是把契约说清楚,而不是把业务实现塞进去。
5.6 静态方法隐藏与字段隐藏
前面讲多态时已经提到,静态方法和字段都不走动态绑定。很多同事在用工具方法时踩过坑:
public class Parent { public static void greet() { System.out.println("parent"); } } public class Child extends Parent { public static void greet() { System.out.println("child"); } } Parent p = new Child(); p.greet(); // parent,不是 child看代码的人第一眼可能以为输出 child,实际是 parent。因为 p 的静态类型是 Parent,静态方法调用在编译期就绑定了。这里的规避手段是:不要用对象引用调用静态方法,直接用类名调用。编译器也会给警告,但很多人习惯性忽略了。至于字段隐藏,前面已经演示过,记住一条:子类尽量不要声明父类里已有的字段名。
这些问题我整理成一张速查表,以后排查时直接对着看:
| 现象 | 根因 | 排查方向 |
|---|---|---|
| 子类构造器编译报错 | 父类没有无参构造器 | 子类显式调用 super(参数) |
| 多态调用输出与预期不同 | 字段或静态方法隐藏 | 查看声明类型,而不是实际类型 |
| 重写方法时编译报错 | 访问权限降低或受检异常范围扩大 | 调整子类方法的修饰符和异常声明 |
| 调用父类方法却执行了旧逻辑 | 重写方法签名不一致或没加 @Override | 补上 @Override,让编译器校验 |
| 实现多接口时报默认方法冲突 | 多个接口有相同签名 default 方法 | 在子类中重写并指定 A.super.hello() 方式调用 |
我做了这么多年项目,对继承的态度就一句话:继承是很好的抽象工具,但用的时候,一定要先想清楚是不是真正的 is-a 关系。写父类时,把公共状态和行为沉淀下来,把变化的地方设计成抽象方法或可重写方法,让子类去填空。别为了省几行代码把父类变成“万能基类”。最后再分享一个实操小技巧:子类重写方法时,先敲 @Override 让编译器替你检查;父类里非 private 方法,如果设计上允许子类重写,记得用注释说明设计意图;如果不希望子类随意重写,能加 final 就加上,这样别人扩展你的类时,接口边界会清晰很多。