Java的面试题里,如果让我挑一个必考频率最高的点,我会先把票投给继承。不是因为它难,而是因为它能很快分清一个人是背了八股文,还是真的理解面向对象设计。这篇想做的事情很简单:把继承从设计动机、语言机制、到业务落地、再到面试临场,一条线说透,同时把“什么是继承”“不同的继承方式”“封装继承多态”这些常被一起问到的概念也串起来。适合刚开始学Java基础、还在纠结继承语法的新手,也适合准备Java面试题扫盲的开发者,同样适合工作中被乱七八糟的字段和类层次搞到头秃,想回头理一理继承边界的同学。
为了写这篇,我翻了不少JDK源码里的典型继承体系,也结合近期项目里常用的MyBatis-Plus实体类继承场景做了验证。下面这些内容基本都是从真实工程经验里出来的,不搞虚的。
1. 为什么需要继承:先用代码复用说清楚动机
1.1 没有继承的实体类有多痛苦
假设一个电商项目里有订单、售后单、退款单三种业务单据,对应的数据库表里都有id、创建时间、更新时间、逻辑删除标记这几个公共字段。如果完全不用继承,每个实体类你都得把这些字段重新写一遍,连同getter/setter加一遍。刚开始代码少还没啥,等表结构调整,比如把逻辑删除标记从deleted改成is_deleted,这三个类加三个Mapper的XML映射要一起改,少改一处就是线上事故。
我用一个简单的例子来说明这种重复感。没有继承的Order可能是这个样子:
public class Order { private Long id; private LocalDateTime createTime; private LocalDateTime updateTime; private Integer deleted; private String orderNo; private BigDecimal amount; public Long getId() { return id; } public void setId(Long id) { this.id = id; } // 后面还有一长串getter/setter }这种代码本身没什么技术含量,但它最大的问题在于“公共字段没有固定归属”。每个开发者的命名习惯不一样,有的人把删除标记叫deleted,有的人叫delFlag,还有人干脆不建这个字段。一个项目里多套实体类字段风格五花八门,一旦要统一条数据审计规则,靠人肉去对齐真能改到怀疑人生。
继承在这里面的第一个价值,就是把这些公共字段的“标准答案”提前定好,让子类不再重复定义。这个价值在代码层面叫复用,在工程层面叫对齐规范。很多Java基础教程讲到继承时老把例子放在猫、狗、动物上面,道理虽然没错,但真正让你感受到继承力量的,往往是这类业务实体字段的抽取。
1.2 继承不只复用了字段,还定义了is-a
如果说复用是继承的副产物,那继承真正解决的是建模问题:让类与类之间产生“是一个”的关系。订单、售后单、退款单都是单据,它们都属于同一个业务抽象,这时候用一个BaseBill基类去描述公共信息就是合理的;反过来,一个员工类想复用人员的性别和年龄字段,硬是让它继承User,这在语法上能跑,但业务表达就很奇怪,员工并不是一种User,公共字段的抽取不应该靠强认亲戚来解决。
判断is-a有一个很高效的套路:试着把“子类B is-a 父类A”这句话读出声,看它符不符合常识和业务表达。如果读起来别扭,大概率是继承关系设计错了。很多八股文没讲清楚的东西,核心就是这一句话。
另外值得提的是,is-a关系和封装、多态是一体三面的。封装把数据和操作包起来,继承让类之间形成父子血缘,多态则依赖继承才能出现父类引用指向子类对象的可能性。面试会被问到“封装继承多态到底是什么关系”,答案主线和这段说的其实是同一件事。
1.3 组合与继承:什么时候别用extends
要说继承被骂最多的点,就是它容易制造强耦合。父类随便加一个字段,所有子类全都受影响;父类改一个方法签名,一堆地方编译不过。所以现在工程界常说的一个原则是优先组合而不是继承。组合是“我有一个东西”,继承是“我是这个类型”。短期看组合写起来可能多几行代码,但长期看变更影响面小得多。
比如User和UserProfile,如果做成继承关系,那么UserProfile一定得承担User所有的字段;但业务上UserProfile只关心扩展信息,用组合把User作为UserProfile的一个成员字段,反而更干净。继承使用到什么程度,后面我会专门讲一讲我自己的红线标准,但这里的核心判断方式很简单:如果一个类只是恰好需要另一部分的“功能”,而不是“本质上属于那一类”,别用继承。
2. Java的继承方式有什么不同:单继承、多层继承与接口
2.1 为什么Java不允许一个类有多个父类
很多人学Java时会问:C++可以多重继承,Java为什么不行?我先说结论:Java在语言层面只允许一个直接父类,这样做让继承关系变成一棵树,而不是一张网,无论是编译检查还是JVM加载类,复杂度都轻松很多。多继承最大的问题叫菱形继承:假设类A有方法foo,B继承A并重写foo,C继承A也重写foo,然后D同时继承B和C,那么D到底应该用B的foo还是C的foo?C++解决这个问题极其费劲,而Java吸收教训,直接不让类有多继承这个选项。
这种限制不是缺陷,它是设计取舍。你需要多种能力时,Java给了另一条路:接口。一个子类可以同时实现多个接口,每个接口声明一组能力契约,接口不持有状态,冲突就没有那么致命。所以在回答“不同的继承方式”时,最好不要只说extends,接口实现和多层继承都是Java里和继承相关的方式,全提出来才显得完整。
2.2 接口弥补“多能力”,default方法圆场
前面说接口是能力契约,类与类之间的继承是血缘关系。在Java里,你要让一个类既具备可序列化的能力,又具备可比较的能力,做法是implements Serializable, Comparable 。这在设计上特别像给你安排了一堆技能证书,证书之间互相不冲突,血缘关系再复杂也没关系。
Java 8之后接口里可以写default方法,这等于接口在“能力契约”之外顺便给了默认实现。不过不要高兴太早,它也有新坑:两个接口如果都定义了同名同参的default方法,实现类必须手动实现一次才能消除冲突。顺手给一段示例:
public interface A { default void hello() { System.out.println("A.hello"); } } public interface B { default void hello() { System.out.println("B.hello"); } } public class C implements A, B { @Override public void hello() { // 必须手动解决冲突 A.super.hello(); } }当类和接口同时出现同名方法时,类的方法优先于接口的default方法。面试官偶尔会拿这种方法优先级来出题,只需要记住:类实现,优先于接口default方法;父类声明的方法,优先于接口default方法。
2.3 多层继承:能写不代表该写
常有人问,继承链最多能拉多长?语法上没限制,但工程上我非常不建议搞三层以上的继承。一层抽象、一层基类、一层具体实现,已经足够大部分业务使用。一旦形成A到B到C到D的链条,调试的时候你要把五个类的方法翻来覆去对比,代码阅读成本急剧上升。
我见过一个老系统里,一个简单的销售订单类从上到下继承了六个类,中间还有一个类的方法纯粹是空实现。想改一个字段的默认值,得沿着继承链往上找构造函数,改完以后影响了几十个实体。从那次以后我对多层继承的态度就是:尽量别把业务做成血缘关系谱系,把继承用在真正稳定不变的抽象上。这也是很多Java学习路线和代码规范里明确强调的一点——继承是一种强侵入设计,能用组合、接口解决的就别硬拉继承链。
3. 重写机制:继承中最容易翻车的区域
3.1 Override和Overload,一字之差就是另一个东西
“Override是重写,Overload是重载”,这句话背起来容易,用起来经常混。重写发生在子类和父类之间,方法名、参数列表、返回类型完全一致,子类用自己的实现把父类的实现覆盖掉,这个行为是运行时多态的根基。重载发生在同一个类里,方法名相同但参数列表不同,编译器根据参数来匹配具体调用,本质上是编译时多态。
代码辨析比叙述更快:
class Animal { public void sound() { System.out.println("animal"); } } class Dog extends Animal { @Override public void sound() { System.out.println("wang"); } public void sound(int times) { // 这是重载 for (int i = 0; i < times; i++) System.out.println("wang"); } }注意子类重写父类方法时,访问修饰符不能比父类更严格。父类是public,子类写成protected或private都是不行的,这个错误编译器会直接报出来。如果父类方法是package-private,子类在别的包下其实根本不满足重写条件,因为方法对子类都不可见,这也提醒了:重写的前提是方法必须对子类可见。
3.2 super关键字与构造器调用链
super有两个核心用法:在子类构造器里调用父类构造器,以及在子类方法里调用父类被覆盖的方法。使用super调用父类构造器有个硬性要求:必须放在子类构造器的第一行。如果你的父类没有定义无参构造器,子类构造器必须显式调用一个带参的父类构造器,否则编译会直接失败。
这里有个极容易被忽略的细节:子类构造器里,Java会自动调用父类无参构造器,但调用时机发生在子类字段赋值之前。也就是说,如果父类构造器调用了某个被子类重写的方法,此时子类字段尚未初始化,你看到的值可能是null或0。我在真实项目中踩过这个坑,当时在基类构造器里调用了一个可覆写的初始化方法,结果每个子类new出来以后,某个字段动不动就是null,排查了半天才意识到问题不在业务逻辑,而在构造器方法调用顺序。建议是:构造器里尽量只做简单赋值,不要调用可覆盖方法。
下面是直接展示这个坑的示例:
class Base { Base() { init(); } void init() { System.out.println("Base init"); } } class Sub extends Base { private String name = "java"; @Override void init() { System.out.println("Sub init, name=" + name); } // 此时name还是null }执行结果会是Sub init, name=null。这不是Bug,是初始化顺序导致的必然结果。
3.3 动态绑定:为什么同一个方法却调出了不同行为
Java的实例方法天生支持动态绑定,也就是说编译时检查方法是否存在,运行时根据对象的真实类型决定调用哪一个实现。为了加速这个选择过程,JVM在类加载阶段就为每个类建立虚方法表,基础思路是把这个类继承来的方法名映射到实际方法入口,调用时只需要查表,不用来回遍历父类。
这个机制对性能影响很小,但它带来的多态性却极其关键。看一段常见代码:
Animal a = new Dog(); a.sound(); // 实际调用Dog重写后的sound变量a的编译时类型是Animal,运行时类型是Dog。这种能力让程序在不修改调用方的前提下扩展新子类,是开闭原则的重要基础。面试多态问题的时候,能说出“动态绑定”和“虚方法表”这两个词,基本就能和大部分背答案的人区分开。
4. 继承的边界控制:final、abstract与访问修饰符
4.1 final:主动掐断继承链
如果把继承类比成血脉,final关键字就是在某些场景下写明“到我为止,不要再传”。一个类一旦被final修饰,就不能被继承,比如String、Integer这类不可变类设计上全部是final,目的就是保证类的行为不会被恶意子类破坏。一个方法被final修饰,子类不能重写它,通常用在那些算法骨架或安全校验非常敏感的流程上。
要不要给类加final,其实就是设计表态。如果你写了一个普通类,不打算让别人继承,就加上final,防止后来的人在不理解意图的情况下随意继承。反过来,如果你设计一个类就是为了被继承,应该考虑abstract化,而不是让大家各自继承这个满脑子可变的普通类。
4.2 abstract:让基类只专注“骨架”
abstract类是Java专门给“基类”预备的形态。抽象类不能直接new,通常会先把公共字段、公共模板方法写好,把变化的部分留成抽象方法,让子类负责具体实现。比如:
public abstract class BaseBill { private Long id; public void save() { validate(); System.out.println("insert into bill sql"); } protected abstract void validate(); }这样设计的好处是公共流程统一恒定,个性化校验交给子类。子类继承时只需要实现validate,模板方法save不用改。这就是一个很典型的模板方法模式雏形。
不过也有一个常见毛病:一个抽象类里放了太多公共字段和方法,十来个子类都依赖它,不知不觉就变成了“上帝类”。抽象类不是万能收纳箱,字段越多、方法越多,子类越容易被绑架。临床上比较合理的做法,是让抽象类尽量只保留这一个抽象维度上真正公共的东西,把不同维度拆开用接口组装。
4.3 访问修饰符:子类到底看得见什么
继承的前提是先“看得见”,这就要说访问修饰符。Java的四个访问级别基础要熟:
- private:本类内部才能访问,子类无权直接访问,哪怕它是亲生。
- default(不写修饰符):同包下能访问,跨包子类依然不能直接访问。
- protected:同包能访问,不同包的子类也能访问,这是为继承专门设计的。
- public:任何地方都能访问。
用表格看更直观:
| 修饰符 | 本类 | 同包子类 | 跨包子类 | 任意类 |
|---|---|---|---|---|
| private | 可以 | 不可以 | 不可以 | 不可以 |
| default | 可以 | 可以 | 不可以 | 不可以 |
| protected | 可以 | 可以 | 可以 | 不可以 |
| public | 可以 | 可以 | 可以 | 可以 |
这里最容易被忽略的是default级别。它就算被子类继承,跨包子类也访问不了父类的包私有成员。我以前接手过一个项目,公共字段不写修饰符,把所有实体类塞在同一个包下勉强能用,后来模块拆包,直接炸出一片编译错误。记住一句话:如果觉得某字段要被子类用到,就正正经经写protected或者提供protected getter,别赌包结构永远不变。
5. 业务落地:实体类继承与MyBatis-Plus的配合案例
5.1 先抽一个BaseEntity
Java后端项目里,最常实践的继承就是实体类继承。以MyBatis-Plus为例,我习惯先定义一个BaseEntity,把id、审计字段、逻辑删除标记集中管理:
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.FieldFill; import com.baomidou.mybatisplus.annotation.TableField; import lombok.Data; import java.time.LocalDateTime; @Data public class BaseEntity { @TableId(type = IdType.AUTO) private Long id; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; @TableLogic private Integer deleted; }然后让具体实体继承它:
import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) @TableName("t_order") public class Order extends BaseEntity { private String orderNo; private BigDecimal amount; }这里有一个非常关键的细节:用Lombok的@Data后,如果想让子类正确比较父类字段,@EqualsAndHashCode必须写成callSuper = true,否则Lombok生成的equals/hashCode只会比较子类自己的字段,父类id等字段被排除在外,这是隐性Bug的老源头。
5.2 字段继承下,MyBatis-Plus按实体类生成SQL时别漏注解
如果你们团队会基于Java实体类自动生成建表语句,比如用MyBatis-Plus的能力根据实体映射加表结构,那你很快会发现:BaseEntity上也要写全表相关注解,比如@TableId、@TableField、@TableLogic,否则自动化工具识别不到主键或逻辑删除,生成的建表SQL就缺胳膊少腿。很多人只给子类字段加注解,公共字段顺其自然,等表建成以后才发现主键没有自增、逻辑删除字段没生效,再补就麻烦。
第二个我想提醒的点是字段填充问题。假设用MetaObjectHandler自动填充createTime和updateTime,只要你把BaseEntity里的注解写对了,fill = FieldFill.INSERT,那么在插入时自动填充就会生效。但如果图省事把createTime的注解漏了,自动填充不会触发,创建时间就一直是个null,线上排查特别容易忽略。
再补一个和封装有关的建议:BaseEntity里的审计字段,尽量别暴露setter,上一套自定义的字段赋值入口,或者在Service层统一填充。否则继承虽然方便,但任何子类代码都可能随手把createTime改成null,数据一致性就成了一场赌博。
5.3 从异常体系反推继承的设计哲学
说完框架层面的例子,再看JDK异常体系,它是一个天然继承设计说明书:Throwable是顶层,下面是Exception和Error,Exception下面又有RuntimeException和受检异常,IOException、SQLException等各成一脉。之所以把异常设计成树,是因为调用方可以用catch(Exception e)或者catch(IOException e)去捕获一类异常,而不必一个个具体类去判断,靠的正是继承带来的“可替换性”。
这个设计本质就是靠继承实现了“分类捕获”和“传递语义”。如果你自己定义业务异常,应该考虑让它继承合适的父类,比如RuntimeException,这样事务回滚行为和调用方处理方式都会更一致。自定义异常继承错了父类,可能导致catch环节捕获不到、事务回滚逻辑失效,这也是很多团队在代码规范里要求全部业务异常统一继承一个自定义BaseRuntimeException的原因。
6. 常见坑与面试题临场应对
6.1 “什么是继承”和“不同的继承方式”怎么答出层次
如果面试官问什么是继承,我一般建议按三层来答,不要面试官话音未落就背定义。先说定义:继承是面向对象中让一个类拥有另一个类已有属性和行为并加以扩展的机制,同时表达类之间is-a的关系。再讲Java的继承方式:类与类是单继承,接口可以多实现,另外还存在多层继承,所以说“不同的继承方式”在Java里其实是单继承、接口实现、多层继承的组合。最后补一句:继承是多态的基础,有了继承,子类对象才能被父类引用所指向,方法才能动态绑定。
这个答法有定义、有机制、有联系,基本上能把“什么是继承”和“封装继承多态”串起来了。面试官如果追问封装和继承的边界,可以说封装解决的是怎么隐藏变化,继承解决的是怎么复用与类型扩展,二者互补不矛盾。再深一层可以把重写规则、构造器调用链、访问修饰符的限制也带出来,这就涉及到很多Java面试题里喜欢埋的坑了。
6.2 equals、hashCode与继承的爱恨情仇
继承环境下重写equals有个著名坑:你是否在equals里用instanceof判断。如果Dog和Cat都继承Animal,两边都重写equals,都用instanceof判断自己的类型,那么可能出现两个对象“平等”却永远不平等的诡异现象。比如左边是Dog,右边是Cat,两边id相同,但Dog.equals(Cat)返回false,Cat.equals(Dog)也返回false,equals的对称性直接坏了。
解决方案常说用getClass()对比,保持严格类型一致;但这么做的缺陷是Java里经常强调的里氏替换原则会受到破坏,子类对象永远不可能等于父类对象。放在业务系统里,我更建议实体类不要随便重写equals/hashCode,要比较就按id字段比,要放到Set或Map里就保证hashCode和equals使用同一组字段,否则会出现查询时明明存在却remove不掉的现象。
6.3 绕不开的冷坑位清单
我把这几年遇到的零散坑位整理成一个速查列表,对照起来排查会快很多:
- 静态方法是隐藏不是重写。父类和子类同名静态方法互不相干,调用哪个由编译时类型决定。
- private成员不参与继承,父类private字段子类虽然看不到,但对象内存里依然存在。
- 泛型继承可能生成桥接方法。比如父类定义Comparable ,子类继承时会由编译器生成synthetic方法,反射时多出方法名,别被吓到。
- 构造器链默认需要父类无参构造器,不写super(xxx)且父类无无参构造器,编译阶段就会失败。
- instanceof判断和equals要配合,单用instanceof很危险,容易把一个父类引用误判成子类。
这每一个点在我经历的真实项目里都曾带来过至少半个小时以上的排查成本,提前知道能让你的代码稳很多。
7. 工程视角:我的继承使用守则
不是所有地方都适合用继承,这大概是工作越久越多人会信的一句话。我现在写新代码时,凡是我判断为稳定不变的公共抽象,我才会用继承;凡是可有可无的复用需求,优先组合;凡是能力补丁,比如可比较、可序列化、可缓存,一律用接口。这个标准定下来以后,继承带来的修改事故比例大幅下降。
如果非要说具体红线,我现在通常这么做:普通业务类尽量默认final,让别人有意识地去思考“我要不要继承它”;抽象基类控制在两层以内,一个抽象父类加一个普通子类完事;能用abstract定义骨架就绝不把普通类当父类用;实体类的公共字段统一抽到BaseEntity,并且注解要完整,不然MyBatis-Plus这类工具生成SQL时会让你踩不少坑。
最后再分享一个小实践。我写代码的时候会刻意检查每个基类名是否带Base或Abstract前缀,如果发现一个被继承的普通类名字里完全没有这类语义,我就默认设计有问题。继承确实是Java面向对象里很重要的一个特性,但它也是一把需要负责的刀,用之前问自己一句:这里到底是不是真的is-a。这么问多了以后,很多代码能少走不少弯路。