
1. 继承是什么为什么要继承先给一个最直观的类比。你写代码的时候如果每个类都要从零开始定义字段和方法那和每次做饭都从种水稻开始没什么区别。继承做的事情就是把那些“公共部分”抽出来放到一个父类里子类通过extends直接拿到这些能力再在自己的类里补充特有的东西。Java 里的继承核心就是extends关键字一个子类只能有一个直接父类这是 Java 和 C 多继承最大的区别。继承到底解决了什么问题我认为最核心的是两件事代码复用和建立类型之间的层次关系。代码复用很好理解。比如你做一个电商系统用户分普通用户、VIP 用户、管理员这三个角色都有用户名、密码、手机号这些公共字段都有登录、修改密码这些公共方法。如果每个类都写一遍维护起来就是灾难。你把公共部分放到User父类里三个子类各自只写差异部分代码量直接降下来而且后续改公共逻辑只需要改一处。类型层次关系是另一个容易被忽略的价值。有了继承VipUser就是一个User这在 Java 里意味着可以用父类引用指向子类对象也就是多态的基础。比如你写一个方法sendMessage(User user)传入VipUser对象是合法的方法内部不用关心具体是哪种用户。这就是面向对象里“封装、继承、多态”三者的联动关系继承为多态提供了前提多态反过来让继承的代码更具扩展性。继承适合谁去深入掌握如果你是 Java 初学者正在学面向对象三大特性这篇文章可以帮你把继承的原理和细节吃透如果你是有两年左右经验的工程师经常在写业务代码时纠结“到底该不该用继承”“抽象类和接口怎么选”那本文第二、三部分的内容可能更值得看。我会尽量把知识点讲得接地气同时把容易踩坑的地方都标出来。2. 继承的核心机制与底层细节2.1 子类到底从父类那里继承了哪些东西很多人对“继承”的理解停留在“子类拥有父类的成员”但准确说这个说法是不完全对的。Java 里子类能直接访问的成员取决于父类成员的访问修饰符。访问修饰符同类同包子类不同包任意类private✅❌❌❌默认不写✅✅❌❌protected✅✅✅❌public✅✅✅✅这张表请记住。private成员不会被继承更准确的说法是“子类对象的内存中确实有这部分数据但你访问不到”。比如父类有一个private String password子类不能直接写this.password只能通过父类提供的public或protected的 getter/setter 来操作。protected是继承场景里最常用的一个级别它允许不同包下的子类直接访问同时对外部类不可见。构造器不会被继承。这一点必须明确子类不会继承父类的构造方法但子类构造器必须调用父类的某个构造器这是硬性要求。至于为什么下面马上讲。2.2 super 关键字指向父类的那只手super在 Java 继承里承担三件事调用父类构造器、访问父类成员、区分同名成员。先看构造器调用。Java 规定子类构造器的第一行必须是super(...)或this(...)如果都没写编译器会自动加一个无参的super()。这意味着如果父类没有无参构造器子类必须显式调用父类的带参构造器否则编译直接报错。很多初学者在这块卡住其实就是没理解这个规则。public class User { private String username; public User(String username) { this.username username; } } public class VipUser extends User { private int level; public VipUser(String username, int level) { super(username); // 必须显式调用因为父类没有无参构造器 this.level level; } }再说成员访问。如果父类和子类有同名变量super.xxx访问的是父类的变量this.xxx访问的是子类自己的变量。方法同理。但我要多说一句字段尽量别重名重名覆盖隐藏字段不是一个好设计。Java 里字段的访问是看“引用类型”的编译期就确定了不会像方法那样有动态分派很容易写出让人迷惑的代码。public class Parent { String name parent; public void print() { System.out.println(name); } } public class Child extends Parent { String name child; public void printName() { System.out.println(name); // 输出 child System.out.println(super.name); // 输出 parent } }2.3 继承链上的初始化顺序被忽视的细节这是面试高频题也是实际排查 bug 时经常遇到的坑创建子类对象时初始化顺序到底是什么顺序是这样的加载父类执行父类的静态变量初始化和静态代码块按代码顺序加载子类执行子类的静态变量初始化和静态代码块执行父类的实例变量初始化和实例代码块执行父类构造器执行子类的实例变量初始化和实例代码块执行子类构造器静态成员只初始化一次且优先于一切实例相关操作实例初始化的顺序是先父后子。用一个例子辅助记忆public class Parent { static int pStatic initStaticP(); int pInstance initInstanceP(); static { System.out.println(父类静态代码块); } { System.out.println(父类实例代码块); } public Parent() { System.out.println(父类构造器); } static int initStaticP() { System.out.println(父类静态变量); return 1; } int initInstanceP() { System.out.println(父类实例变量); return 1; } } public class Child extends Parent { static int cStatic initStaticC(); int cInstance initInstanceC(); static { System.out.println(子类静态代码块); } { System.out.println(子类实例代码块); } public Child() { System.out.println(子类构造器); } static int initStaticC() { System.out.println(子类静态变量); return 1; } int initInstanceC() { System.out.println(子类实例变量); return 1; } public static void main(String[] args) { new Child(); } }执行结果父类静态变量 父类静态代码块 子类静态变量 子类静态代码块 父类实例变量 父类实例代码块 父类构造器 子类实例变量 子类实例代码块 子类构造器这里有一个实际工程中的教训不要在父类构造器里调用可被重写的方法。原因是当父类构造器执行时子类还没完成初始化如果此时调用了一个被子类重写的方法而这个方法依赖子类的实例字段那么读到的可能是 null 或默认值。public class Parent { public Parent() { init(); } protected void init() { System.out.println(Parent init); } } public class Child extends Parent { private String name child; Override protected void init() { System.out.println(Child init: name); // 输出 Child init: null } }这个例子输出的不是child而是null因为执行new Child()时父类构造器先跑那时候子类的name字段还没有被赋值。这种 bug 非常隐蔽排查起来费时费力最好的做法就是构造器里只做初始化自身字段的事不要调用多态方法。3. 方法重写与重载继承里最容易混淆的两个概念3.1 重写Override的规则细节重写是子类重新实现父类中声明的方法方法签名必须一致。Java 5 之后可以在重写方法上加Override注解强烈建议加。这个注解不是功能必需的但它是一个编译器检查如果你方法签名写错了比如参数类型不一致、方法名拼错编译器会直接报错而不是把方法当成一个新的方法悄悄定义出来。我见过不少线上事故就是漏了Override子类方法名拼错结果父类方法没有被覆盖程序行为完全不符合预期。重写要遵守的规则我按 Java 语言规范整理一下重点方法名、参数列表必须完全相同返回值类型可以相同也可以是父类方法返回值类型的子类型协变返回类型。比如父类方法返回Object子类重写后可以返回String访问修饰符不能比父类更严格。父类是public子类不能改成protected或默认权限父类是protected子类可以改成public不能抛出比父类更宽的受检异常。父类方法声明throws IOException子类重写时可以抛出FileNotFoundException但不能抛Exception这里说一个容易忽略的规则static 方法不能被重写。如果子类定义了一个和父类 static 方法签名相同的方法这叫做“隐藏hide”不是重写。调用哪个方法取决于引用类型而不是对象实际类型。public class Parent { public static void hello() { System.out.println(Parent hello); } } public class Child extends Parent { public static void hello() { System.out.println(Child hello); } } // 调用 Parent p new Child(); p.hello(); // 输出 Parent hello因为静态方法跟随引用类型3.2 重载Overload的误区与调用规则重载是同一个类中定义多个同名方法但参数列表不同。重载与继承没有直接关系但在继承场景下经常一起出现容易混。一个常见误区子类里定义一个和父类方法同名但参数不同的方法会被误认为是重写。实际上这是重载——父类那个方法依然存在只是子类多了一个重载版本而已。另一个需要留意的点是重载的编译期绑定。重载在编译阶段就决定了到底调用哪个方法依据是变量的声明类型而不是运行时的对象类型。public class Parent { public void run(String s) { System.out.println(Parent run String); } } public class Child extends Parent { public void run(Object o) { System.out.println(Child run Object); } } // 调用 Parent p new Child(); p.run(hello); // 输出 Parent run String我来解释一下这个例子p的声明类型是Parent编译期在Parent类中找匹配的方法找到run(String)就绑定到这个方法上了。虽然对象实际是Child但Child.run(Object)是重载不是重写所以不会走虚方法分派。这个知识点在面试中经常被拿来出题很多“看起来没问题”的代码结果出乎意料原因就是没分清编译期绑定和运行期绑定。3.3 final 对继承的限制final关键字在继承体系里有三个作用位置final 类不能被继承。典型例子是StringJDK 里把它设计成 final 是有安全考虑的字符串是高频对象如果允许继承子类可能破坏不可变性final 方法子类不能重写。有些框架会禁用某些方法的重写以保证行为一致final 变量如果是基本类型值不可变如果是引用类型引用不可变但对象内部状态可以变实际开发中我建议你不确定要不要让别人继承某个类就直接把类标成 final后续想开放再改。Java 里默认所有类都是可继承的这其实是很多安全问题的根源之一。相比“先开放后收紧”“先收紧后开放”要安全得多。4. 多态与继承的联动向上转型和动态分派4.1 向上转型用父类类型引用子类对象Parent p new Child()这种写法就是向上转型。编译期p是Parent类型运行期它指向的是Child对象。这带来一个直接效果调用被重写的方法时走的是子类的实现。public class Animal { public void speak() { System.out.println(Animal sound); } } public class Dog extends Animal { Override public void speak() { System.out.println(Woof); } } // 调用 Animal a new Dog(); a.speak(); // 输出 Woof这个机制叫动态分派JVM 在运行期根据对象的实际类型来确定调用哪个方法。这也是继承最大的价值之一你写代码时可以面向父类编程不需要知道具体的子类是谁新增一个子类也不会影响已有代码。4.2 向下转型与 instanceof向下转型就是把父类引用转回子类引用。这种转型不是自动的需要显式用括号强转Animal a new Dog(); if (a instanceof Dog) { Dog d (Dog) a; d.fetch(); // 访问 Dog 特有方法 }instanceof在做强转之前判断一下对象实际类型避免ClassCastException。Java 16 之后引入了instanceof的模式匹配可以少写一行强转代码if (a instanceof Dog d) { d.fetch(); }但还是建议尽量少用向下转型。如果你经常要把父类引用强转成子类说明你的抽象设计可能有问题需要回头检查接口划分是否合理。4.3 继承体系设计中的抽象类和接口选择到了设计层面继承的另一个核心话题就是用抽象类还是接口维度抽象类接口继承数量单继承可实现多个构造器可以有不能有字段可以有实例字段只能是常量方法实现可以有具体方法Java 8 之后可以有 default/static 方法语义“is-a”关系共性代码复用“can-do”能力契约行为规范给一个实战选择思路如果你要复用的是一段具体的、带状态的代码逻辑倾向于抽象类如果你只是定义一组行为规范不关心内部状态选接口。比如做支付系统AbstractPayment抽象类里写好签名、验签、日志、异常处理的公共流程支付宝、微信支付各自继承它去实现具体的请求发送而Refundable这种表示“可以退款”的能力更适合做成接口。Java 8 之后接口可以有default方法这在很多项目里被用来做“接口 默认实现”的轻量模板方法模式。但 default 方法千万别滥用接口本来应该是一个契约往里面塞太多实现逻辑会让接口变得臃肿而且多实现时还可能遇到默认方法冲突。5. 组合 vs 继承什么时候别用继承5.1 继承被滥用的典型症状“继承是好的所以到处用继承”是新手最容易犯的错误。继承关系一旦建立父类的任何变化都会传导到所有子类耦合度很高。出现以下信号时你的继承设计可能有问题子类只用到了父类的一小部分方法其余方法要么空实现、要么抛异常子类重写了父类大量方法父类的方法名和子类的行为对不上号父类和子类的关系不是“is-a”只是“为了省代码才继承”最典型的反面例子想复用Stack的方法就去继承Stack结果Stack继承了Vector暴露了一堆不该有的操作。JDK 早期Stack就是这么设计的现在基本没人推荐直接用Stack了取而代之的是ArrayDeque。5.2 组合优先于继承为什么组合的意思是一个类持有另一个类的引用通过调用被持有对象的方法来复用功能。比如public class Car { private Engine engine; public Car(Engine engine) { this.engine engine; } public void start() { engine.run(); } }组合和继承相比好处是关系更松组合建立的“有一个”关系内部实现变化不会破坏外部接口而且组合可以灵活地在运行时替换对象继承的关系在编译期就固定死了。另一个很现实的好处是 Java 只有单继承你继承了一个父类就失去了继承另一个父类的机会而组合没有这个限制。5.3 什么时候继承反而是正确选择组合优先不等于“永远别用继承”。以下场景继承是合适的子类确实是父类的一种比如Dog extends Animal父类是抽象类或接口定义了子类必须实现的方法比如AbstractList子类需要被当作父类使用也就是向上转型和多态是明确需求判断标准其实很简单把子类换成父类的场景是否顺理成章。如果代码里到处都在用父类型而子类是具体行为继承基本没错。如果只是为了拿几个方法就直接 extends那大概率应该改成组合。6. 常见面试题与实操踩坑记录6.1 高频面试题速查问为什么 Java 不支持多继承Java 不支持多继承主要是为了避免菱形继承问题。菱形继承说的是两个子类继承同一个父类如果一个类又同时继承这两个子类那它继承的父类成员到底算哪一份C 通过虚继承来部分解决但复杂度极高。Java 用单继承加接口的方式既保留了多态能力又绕开了这个问题。问子类可以继承父类的 private 成员吗不能直接访问但数据还在。用反射可以看到这些 private 字段确实存在于子类对象中只是从语言层面无法直接访问。问super 和 this 的区别super是“父类对象的引用”this是“当前对象的引用”。构造器里super(...)必须放第一行this(...)调用本类其他构造器也必须放第一行所以两者不能同时出现。问Overload 和 Override 的区别重载是编译期多态方法签名不同重写是运行期多态方法签名必须相同。重载发生在同一个类中重写发生在父子类之间。问静态方法能被重写吗不能。子类和父类静态方法签名相同叫隐藏调用时看引用类型。6.2 一个真实项目中的继承设计复盘我之前维护过一个订单系统业务上有多种订单类型当时代码里的做法是Order作为父类NormalOrder、GroupOrder、FlashSaleOrder都继承它。最开始很顺利但随着业务复杂化问题接连出现Order里不断加公共方法每加一个方法所有子类都要回归测试有些子类根本不需要某些字段但父类字段全继承下来数据库表也跟着冗余一个订单类型既想做 A 活动逻辑又想做 B 活动逻辑单继承根本表达不了最后我们把继承关系打散改成组合加接口公共字段抽成一个OrderBase实体类业务能力定义成接口Discountable、Expirable、Refundable每种订单用组合方式持有不同的策略对象。改动之后新增一个订单类型只需要组装新的接口组合不需要动老代码。这个案例想说的核心就是继承不是不能用而是要有节制地用。它最适合稳定的、变化不频繁的“父子”结构一旦业务维度复杂组合加接口往往是更好的选择。6.3 避坑清单这些细节我踩过父类构造器里别调可重写方法前面已经详细说过这是最常见的隐蔽 bug 来源之一equals/hashCode 在继承中的坑。父类定义了equals基于某些字段子类扩展了新字段如果不重写equals两个业务上相同的子类对象可能 equals 为 false如果重写了又可能破坏父类里对称性的约定。我的建议是能不用继承就不用需要比较的字段尽量在父类里定义完整序列化与继承。如果父类实现了Serializable子类自动可序列化如果父类没有子类实现了但父类字段不会被序列化。跨进程传输时Redis、MQ 这类场景很容易踩这个坑Lombok 的Data和继承的冲突。Data生成的equals/hashCode默认不包含父类字段除非加EqualsAndHashCode(callSuper true)这个坑在项目里很常见两个不同子类可能被判定为相等。这个细节很多教程不会讲但实际项目里天天遇到构造器里的this逃逸。在构造器里把this传给别的方法或线程可能导致对象还没完全初始化就被使用这在继承场景下风险加倍。必须等对象构造完整后再让他人持有引用7. 结语把继承用好的个人心得写代码这些年我的体会是继承本身并不难难的是判断“该不该用”。很多人不是在写代码时遇到问题而是在维护代码时才痛苦——父类一改所有子类跟着抖那种“牵一发而动全身”的体验用过一次就忘不了。所以我的建议很简单默认组合谨慎继承接口用来定义能力抽象类用来复用稳定的公共逻辑子类只在确定是父类的“一种”时才去继承它。每次想写extends之前先问自己三个问题子类和父类是不是严格意义上的 is-a父类的所有成员子类是不是都用得上父类未来会不会频繁变化只要有一个答案是否定的就再想想别的方案。最后分享一个我在实际排查问题时常用的技巧遇到“奇怪”的继承行为先用-verbose:class参数跑一下程序看看 JVM 实际加载了哪些类、类加载顺序是什么很多时候初始化顺序和类加载顺序就能解释一大半诡异现象。另外现代 IDE 的重构工具里有个 “Push Members Down” 和 “Pull Members Up”重构继承体系时非常顺手改完记得跑全量测试尤其是那些依赖重写方法的单测。