1. 为什么“重写”和“多态”总被放在一起讲
我当年学Java那会儿,最困惑的一件事就是:明明代码里new的是子类,赋给父类引用之后,调用的却是子类的方法,这到底是怎么发生的?后来工作了几年,看过Spring、MyBatis的源码,又面试过不少人,才发现大多数人其实只记住了“重写要加@Override注解”这个皮毛,对背后的机制理解得很浅。
先说一个反直觉的结论:方法重写(Override)本身不是语法糖,它是Java实现运行时多态的地基。 你可以在不写一个接口、不搞任何设计模式的情况下,只用继承加方法重写,就能让一段代码在运行时根据对象的真实类型表现出不同的行为——这就是多态。面试中常说的“封装、继承、多态”三大特性,多态是最终目的,封装和继承都在为它服务。你去看Spring的ApplicationContext、MyBatis的SqlSession,最核心的机制里都有多态的影子里。
这篇文章适合谁?一个是准备Java面试的人(尤其是那些正在背八股文、想真正理解“重写与多态”的),另一个是写了两三年业务代码、想搞明白框架里那些“看似魔法”的机制的人,还有刚学完Java基础、想知道继承到底有什么用的人。我会从最基础的规则讲到JVM层面的分派机制,再讲到实际项目里怎么用多态写出可扩展的代码,最后附上我自己面试别人和整理八股文时碰到的高频变形题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方法重写的核心规则以及最容易忽略的边界条件
2.1 到底什么才叫“重写”
方法重写的官方定义是:子类中声明一个方法名、参数列表、返回值类型都与父类方法一致的方法,并且该方法不是静态方法、不是final方法。注意,这里说的是“签名一致”,但返回值的要求有特例——协变返回类型(Covariant Return Type),JDK 5之后,子类可以让返回值变成父类返回类型的子类型。比如父类返回Animal,子类重写后可以返回Dog,只要Dog继承自Animal。
这个点很多人第一次听到会觉得奇怪,但在实际代码里非常有用。看个例子:
java复制public class Animal {
public Animal self() {
return this;
}
}
public class Dog extends Animal {
@Override
public Dog self() { // 返回类型从 Animal 变成 Dog,合法
return this;
}
}
如果Java不允许协变返回类型,你子类想返回更具体的类型就必须写一个全新的方法,没法通过@Override注解告诉编译器“我是来重写的”,代码的连贯性和类型信息都差很多。
2.2 访问修饰符的“向下兼容”问题
重写还有一个经常被忽略的规则:子类重写方法的访问权限不能低于父类方法的访问权限。父类是public,子类就不能是protected,更不能是private或包私有。为什么要有这个限制?道理很简单,既然外部代码能通过父类引用animal.self()调用到你的方法,那子类重写后也必须保证这个调用依然成立,否则多态就崩了。
我见过一个真实案例:有人重写equals方法时,因为子类里的代码逻辑不需要对外暴露,就把权限从public改成了protected,结果编译直接报错。这个错不是编译器的刁难,而是语言规范的一部分——你破坏了一个约定:父类能做的,子类必须也能做。
还有个地方经常踩坑:静态方法不参与重写。子类里写一个和父类静态方法签名一样的方法,叫“隐藏(Hide)”,不是重写。调用哪个方法取决于你用的是哪个类型的引用,而不是对象真实类型。比如:
java复制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
如果你试着在子类的静态方法上加@Override注解,编译器会直接报错。这个特性和下面的“多态查找”机制形成鲜明对比,面试官相当喜欢拿来挖坑。
2.3 private、final方法真的一点机会都没有吗
private方法在子类里写一个同名同参的方法,编译器不会报错,但它就是在子类里新定义了一个方法,跟父类那个方法没有任何关系。final方法在子类里直接无法重写。这两个规则是硬性的,任何情况下都无法突破。
不过在跟进继承相关的设计时,有个实用的替代方案:父类提供非final的“模板方法”,把要定制化的步骤设计成protected或public的方法,留给子类重写。这就是模板方法模式的雏形,后面会详细讲。你只要记住一个判断标准:一个方法能不能被子类重写,就看它是不是final、是不是private、是不是static,三种情况都不行。
2.4 重写的异常声明契约
子类重写方法时,throws声明的异常必须是父类异常或它的子类,而且不能抛父类方法没声明的新检查型异常。举个例子:
java复制public class Parent {
public void process() throws IOException {
}
}
public class Child extends Parent {
@Override
public void process() throws FileNotFoundException { // 合法,FileNotFoundException 是 IOException 的子类
}
}
反过来,如果父类没声明任何检查型异常,子类重写时也不能新增。这个规则的本质是:既然调用方是按照父类的契约来写代码的,比如只catch了IOException,你子类突然抛出一个SQLException,调用方根本没有资源去处理。这跟访问权限的“向下兼容”如出一辙,都是为了保护多态调用的安全性。
3. 从重写延伸到多态:动态绑定到底在做什么
3.1 编译期类型与运行期类型
理解了重写的规则,接下来要理解多态的核心机制:方法调用的目标是在运行期确定的,不是在编译期确定的。一个引用变量的“编译期类型”是它声明的类型,“运行期类型”是它实际指向的对象类型。Java的多态就是根据运行期类型来决定调用哪个重写方法。
比如:
java复制Animal animal = new Dog();
animal.sound(); // 编译期类型是 Animal,但实际执行的是 Dog.sound()
编译时,编译器检查Animal里有没有sound()方法,没有就报错;运行时,JVM根据animal实际引用的对象类型Dog,找到Dog类里重写的sound()去执行。这一整套过程叫动态绑定,也叫后期绑定(Late Binding)。
相反的是静态绑定,发生在编译期。static方法的调用、private方法的调用、final方法的调用,都是静态绑定,因为编译期就能确定该调谁。这就是为什么静态方法不能多态——JVM根本不需要在运行时做选择,编译器就做完了。
3.2 向上转型:多态的前提
多态能发生,前提是有继承或接口实现,且子类对象能被当成父类类型来用。这个操作叫向上转型(Upcasting)。向上转型是安全的,因为子类对象必然包含父类所有的成员,你把它“看成”父类时不会丢东西。
java复制Animal animal = new Dog(); // 向上转型
animal.eat(); // 调用的是 Dog 重写后的 eat
但注意,通过animal这个引用,你只能调用Animal里声明过的方法,Dog里新增的方法(比如fetchBall())是无法通过这个引用调到的。除非你向下转型:
java复制if (animal instanceof Dog) {
Dog dog = (Dog) animal;
dog.fetchBall();
}
这里有个非常经典的问题:为什么不直接Dog dog = new Dog(),非要绕一圈做向上转型? 答案是多态的价值不在单对象创建那一刻,而在方法的入参和返回值上。比如:
java复制public void makeSound(Animal animal) {
animal.sound();
}
这个方法接收任何一个Animal的子类对象,运行时自动执行对应的sound()实现。如果你把参数类型写死成Dog,那来了Cat、Bird就得再写一个方法。多态让代码不必为每一个子类写重复的调用逻辑,这就是“面向父类编程”的底层原因。
3.3 instanceof与类型检查的最佳实践
很多人写类型判断时习惯一上来就if (obj instanceof Dog),这在简单场景下没问题,但一旦类层级变深就容易出bug。我推荐两个实践:
-
优先用多态替代is-a判断。如果一个行为在不同子类里有不同实现,应该由子类各自负责,而不是用if-else顺着继承链去判断。写if-else判断类型本身,往往意味着你的设计没把多态用到位。
-
用Pattern Matching for instanceof,JDK 16之后这个语法可以少写一次强制转换:
java复制if (animal instanceof Dog dog) {
dog.fetchBall();
}
这个语法在面试里不一定考,但实际写起来很舒服,推荐升级到JDK 17起步的版本后使用。
4. 重写、重载、隐藏三者之间的边界,以及常见的乱用场面
4.1 重载与重写最本质的区别
重载(Overload)发生在同一个类里,方法名相同、参数列表不同,跟继承没有必然关系;重写(Override)发生在父子类之间(或接口实现类里),方法签名必须一致。判定的第一眼就是看它们在哪个类里,再看签名。
重载的绑定是编译期的。调用哪一个重载方法,编译器根据“引用类型 + 实参类型”来选:
java复制public class Printer {
public void print(String s) {
System.out.println("string: " + s);
}
public void print(int i) {
System.out.println("int: " + i);
}
}
Object obj = "hello";
// 编译期类型是 Object,但重载方法里没有 print(Object)
// 所以这一步就报错,而不是跑到 String 那个方法里
很多人混淆重载和多态,就是因为在子类里重载了父类的方法名,比如父类有void eat(),子类写了个void eat(String food)。这压根不是重写,而是新加了一个方法,虽然方法名撞了。面试里问“下面这段代码输出什么”的暗坑,多半就在这儿。
4.2 隐藏(Hide):静态方法的另一个“重写错觉”
前面说过静态方法叫“隐藏”。隐藏有个冷门知识点:通过子类名调用隐藏的静态方法,实际调用的是编译器根据引用类型判断的那个版本。所以为了可读性和安全,避免在子类中声明与父类静态方法同名的静态方法,尤其是别用实例引用去调静态方法。如果确实要调用父类的静态方法,用父类名.方法()显式指出,能让读代码的人少看你一行去“猜”。
“踩进去再看一眼”的经典案例:
java复制public class A {
public static void run() {
System.out.println("A run");
}
}
public class B extends A {
public static void run() {
System.out.println("B run");
}
}
A obj = new B();
obj.run(); // A run,因为静态绑定,跟对象实际类型无关
如果这里把run()改成实例方法,输出就是“B run”。同样一行代码,静态方法跟实例方法的绑定逻辑完全相反。这就是面试官最爱出的“代码判断”题。
4.3 泛型擦除与方法重写的碰撞
泛型和重写一起出现时,有个隐蔽的坑:桥接方法(Bridge Method)。假设父类定义了泛型方法void setValue(T value),子类实现了void setValue(String value),编译器在生成子类字节码时,会额外生成一个泛型版本的桥接方法,来保证父类泛型擦除后,多态调用依然成立。
java复制public class Parent<T> {
public void setValue(T value) {
}
}
public class Child extends Parent<String> {
@Override
public void setValue(String value) {
}
}
你看源码觉得只是重写了一个方法,但实际字节码里子类有两个setValue方法。用反射去getDeclaredMethods()能看到这个桥接方法的存在。这个知识点不是每家面试都考,但理解它有助于理解Java泛型的“假泛型”本质:Java泛型是编译期检查,运行期擦除,多态机制通过编译器生成的桥接方法作补偿。
5. JVM视角下的重写与多态:方法表、动态分派和invokevirtual
5.1 方法解析的静态部分与动态部分
JVM执行一个方法调用指令,比如invokevirtual,会经历两个阶段:解析(Resolution)和分派(Dispatch)。
解析阶段做的事,是把符号引用转换成实际引用。对于invokestatic、invokespecial(构造方法、private方法)这类指令,符号引用的解析结果在编译期就确定了,所以是静态分派;对于invokevirtual和invokeinterface,解析完成的是“确认这个方法在某个类/接口中已声明”,但具体调用哪个实现类的方法,要在运行时动态决定。
这段逻辑对应到代码层面,就是前面提到的“编译期类型决定调用合法性,运行期类型决定目标方法”。JVM规范和Java语言层面的设计是一一对应的。
5.2 方法表:动态分派的高效实现
JVM为了加速动态分派,在类加载完成后会为每个类生成一张方法表(Method Table / vtable),里面排列了这个类所有可被动态分派的实例方法。子类继承父类时,方法表“复制”父类的方法条目;子类重写了某个方法,方法表里对应位置直接指向子类实现;子类新增的方法,在表尾追加。
当JVM执行animal.sound()时,先拿到animal指向的对象的运行期类型(对象头的类型指针),再定位到对应类的方法表,按方法在表中的索引直接找到目标方法入口。整个过程不需要遍历父类层级逐层找,效率很高,这也是为什么动态分派可以安全地用在热路径上,比如循环里调用多态方法。
虚拟机规范允许不同的JVM实现有不同的方法表细节,但HotSpot普遍采用这种基于方法表和虚方法分派(vtable dispatch)的策略。如果你后面去研究JIT编译,还会看到内联缓存(Inline Cache)等优化手段,本质都是为了加快这里的动态分派。
5.3 经典的一道“多态输出题”,逐行拆解
面试里经常出现这类题:
java复制class A {
public String show(A obj) {
return "A and A";
}
public String show(D obj) {
return "A and D";
}
}
class D extends A {
}
class B extends A {
public String show(B obj) {
return "B and B";
}
public String show(A obj) {
return "B and A";
}
}
class C extends B {
}
public class Test {
public static void main(String[] args) {
A a1 = new A();
A a2 = new B();
B b = new B();
C c = new C();
D d = new D();
System.out.println(a1.show(b)); // 1. A and A
System.out.println(a1.show(c)); // 2. A and A
System.out.println(a1.show(d)); // 3. A and D
System.out.println(a2.show(b)); // 4. B and A
System.out.println(a2.show(c)); // 5. B and A
System.out.println(a2.show(d)); // 6. A and D
System.out.println(b.show(b)); // 7. B and B
System.out.println(b.show(c)); // 8. B and B
System.out.println(b.show(d)); // 9. A and D
}
}
重点是第4和第5句。a2的编译期类型是A,所以它调用show(...)时,编译器会从A类中找可用于匹配的方法。b和c的编译期类型分别是B和C,但都继承自A。在A类里,只有一个show(A obj)能匹配上b和c(show(D obj)不能匹配B类型)。所以编译期锁定的候选方法是A.show(A)。
到了运行期,a2实际是个B对象。B类重写了A.show(A),因此最终执行的是B.show(A),返回“B and A”。
这就是编译期选签名、运行期选实现的双重过程。 如果你把a2.show(b)里的b声明成Object类型,编译直接失败,因为A类里没有任何能接收Object的show方法。记住这个思路,碰到类似题目就不会被绕晕。
6. 实际项目中的多态设计:从模板方法到策略模式
6.1 模板方法模式:把骨架留父类,把变化留给子类
方法重写最常见的实践之一,就是模板方法模式(Template Method)。父类定义一个算法的骨架,其中某些步骤延迟到子类实现或重写。比如后端常见的“数据导入”流程:
java复制public abstract class AbstractDataImporter {
// 模板方法,定义了整体流程
public final void importData(String source) {
validate(source);
List<String> data = read(source);
List<Entity> entities = convert(data);
save(entities);
notifyFinished();
}
protected abstract void validate(String source);
protected abstract List<String> read(String source);
protected abstract List<Entity> convert(List<String> data);
protected void notifyFinished() {
// 默认空实现,子类可选重写
}
private void save(List<Entity> entities) {
// 通用的保存逻辑
}
}
父类把稳定流程固定下来(importData用final防止子类改骨架),把容易变化的步骤留给子类重写(validate、read、convert)。有一段逻辑大部分场景不用通知,就留一个空实现,子类想用再重写。
这个模式在框架源码里极常见——Spring的JdbcTemplate、RestTemplate,以及各种*Template类,全都是模板方法的思想。你理解重写,就能理解为什么一个钩子方法(Hook)可以什么都不写,却能被框架调用。
6.2 策略模式:从“堆if-else”到“接口加实现”
策略模式是多态的另一种典型应用:定义一组算法,把它们封装成实现同一接口的不同类,调用方在运行时选择要用的那个实现。
java复制public interface PaymentStrategy {
void pay(BigDecimal amount);
}
public class AlipayStrategy implements PaymentStrategy {
@Override
public void pay(BigDecimal amount) {
// 支付宝支付逻辑
}
}
public class WechatPayStrategy implements PaymentStrategy {
@Override
public void pay(BigDecimal amount) {
// 微信支付逻辑
}
}
public class PaymentService {
private final Map<String, PaymentStrategy> strategyMap = new HashMap<>();
public PaymentService(List<PaymentStrategy> strategies) {
for (PaymentStrategy strategy : strategies) {
strategyMap.put(strategy.getType(), strategy);
}
}
public void pay(String type, BigDecimal amount) {
PaymentStrategy strategy = strategyMap.get(type);
if (strategy == null) {
throw new IllegalArgumentException("Unsupported payment type: " + type);
}
strategy.pay(amount);
}
}
注意,这里用了一个Map<String, PaymentStrategy>。它比switch-case的扩展性好得多:新增支付方式时,只需要新增一个实现类并注册进容器,不用改PaymentService里的任何代码。这正好落到SOLID原则里的开放封闭原则——对扩展开放,对修改封闭。
面试时如果被问“如何在业务代码里减少if-else”,策略模式是最自然的答案之一。但我说句实话,不是所有if-else都该消灭。分支数量少、逻辑稳定、不会频繁变化的场景,写if-else反而更直观。过度设计是比少用一个模式更常见的问题。
6.3 钩子方法与回调:框架怎么用重写来“通知”你
很多框架里你会发现:定义一个类继承框架的某个基类,然后重写几个方法,你的逻辑就能被框架在合适的时机调用。这类方法有个专门的名字:钩子方法(Hook Method)。
比如JUnit 5的@BeforeEach、@AfterEach,以及Servlet中的init()、destroy(),看起来像是“我们自己写的方法”,实际上都是框架定义好调用时机、由我们在子类里重写实现的钩子。Spring的InitializingBean#afterPropertiesSet()也是这个路子。
理解钩子方法的核心是一个视角转换:不是你的代码主动调用框架,而是框架在某个时机回调你的重写方法。 Java的Thread类里的run()是最直白的例子——你调用start(),JVM创建新线程后自动执行子类重写后的run()。这也是“控制反转(IoC)”思想的一种微观体现。
6.4 不要滥用继承:组合优先于继承
方法重写依赖继承,但设计上我强烈建议克制一点,能用组合优先用组合。为什么?
继承会把父类所有的方法契约强加给子类,哪怕子类只用其中一个。更麻烦的是,父类一旦改了一个方法的实现,所有子类都受影响——这就是“脆弱的基类问题”。还有一个Java的单继承限制:一个类只能有一个父类,扩展性不够灵活。
组合的典型做法是“接口 + 字段”:
java复制public class OrderService {
private final DiscountCalculator calculator;
public OrderService(DiscountCalculator calculator) {
this.calculator = calculator;
}
// 使用 calculator 计算折扣
}
OrderService不关心DiscountCalculator具体是谁,只要它实现了接口即可。你后期替换实现时,不用改OrderService的代码,这就是依赖注入的雏形。Spring的核心思想说白了就是管理“接口和实现之间的绑定关系”。
7. 重写相关的面试题变形与八股文补充解析
7.1 equals与hashCode的重写约定
这是方法重写里最经典也最容易出问题的场景。Object.equals()默认比较引用地址,业务上要比较内容就必须重写。但只重写equals不重写hashCode,HashMap、HashSet等散列容器就会出问题。约定是:equals相等的两个对象,hashCode必须相等。
两个对象equals相等但hashCode不同,会导致它们在HashSet里被当成两个不同元素,在HashMap上则可能出现“明明equals相等,却get不到”的诡异问题。这个坑每年面试必考,实际代码里也经常由Code Review发现,所以整理一下注意事项:
几个实用建议:
- 用
Objects.equals(a, b)处理null安全比较 - hashCode用
Objects.hash(field1, field2)生成,简单可靠,不要自己造轮子 - 如果类会被用作
Entity且未来可能被继承,考虑equals的判断是否要考虑getClass()而不是instanceof - 协变返回类型在这里也有用:JDK 16之后,子类重写
clone()、getClass()等返回类型可以更具体,不过clone()本身涉及浅拷贝、深拷贝,又是一个大话题,一篇文章讲不完
7.2 “重写构造方法”为什么是伪命题
构造方法不能被重写。子类创建对象时,会隐式调用父类的无参构造方法;如果父类没有无参构造方法,子类构造方法第一行必须显式用super(...)调用父类有参构造方法。否则编译直接报错。
这一步的底层逻辑是:子类对象的内存布局里首先包含父类的实例字段,父类不初始化好,子类就没法用这些字段。这就是为什么super()必须放在构造方法第一行——得先“组装”父类部分,再初始化子类自己的字段。
面试题里常考一个问法:“父类构造方法里调用了子类重写的方法,会执行哪个?”答案是:会执行子类重写后的方法。因为即使父类构造方法正在执行,对象真实的运行期类型已经确定是子类了,动态绑定照常生效。最典型的安全反例:
java复制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=" + name);
}
}
// new Child() 输出:child init, name=null
因为name字段还没初始化,init()就被动态绑定到子类方法上去了,字段是默认值null。这就是为什么要在构造方法里避免调用可重写方法,这是Effective Java里的明确建议。很多人背了结论但不知道为什么,这里我把原因和现象都摆出来了。
7.3 为什么方法重写必须搭配“面向接口/父类编程”
总有人问:多态有什么用?我直接new子类不是一样能调用吗?
区别体现在一个核心场景:当调用方依赖的是一个抽象类型,而不是具体类型时,扩展空间就打开了。 比如上面的PaymentService,它不关心你传入的是什么支付策略,只认PaymentStrategy接口。将来要加“云闪付”“数字人民币”,无非是新增一个实现类,丢到容器里,万事大吉。如果你一开始就new具体的AlipayStrategy,每次加支付方式都要改PaymentService,改着改着就变成“面条代码”。
所以多态在项目里的真实价值是解耦:让上层调用方与下层实现方不直接绑定。这种解耦也是设计模式能运行的基础——模板方法、策略、观察者、代理,全都在利用多态把“变的部分”抽出来。
7.4 方法重写与反射、动态代理的联系
很多人学到反射和动态代理时,会觉得很突兀:为什么Proxy.newProxyInstance()里要传一个InvocationHandler?为什么实现类的方法调用会被拦下来?
核心机制就是通过方法表和动态分派,动态代理生成的代理类重写了接口方法,并在里面回调InvocationHandler.invoke()。所以MyBatis的Mapper接口、Spring AOP的切面,本质都是“利用多态 + 动态代理”在运行时插入额外逻辑。
看一个Spring AOP最简单的理解方式:你声明一个@Transactional注解的方法,Spring容器启动时会给这个Bean创建代理对象,代理对象重写了对应方法,在方法执行前后加上了事务管理逻辑。调用方拿到的其实是代理对象,但调用方完全无感知——因为从多态的角度,代理对象“is-a”原Bean的类型。proxy instanceof OriginalClass为true。这就是多态在框架层最宏大的应用。
8. 实战排查:重写没生效的几种常见场景
8.1 重写方法没有被调用,先查这四处
我自己的工作流里,遇到“明明重写了,却没按预期执行”的问题,一般按这个顺序排查:
- 检查方法签名:参数类型、方法名是否完全一致。尤其注意基本类型和包装类型的差异,比如父类
void set(int),子类写void set(Integer),编译器把这当重载而不是重写,运行时根本不走你的逻辑。 - 检查访问修饰符:子类的访问权限不能更低。
protected重写public是不行的。这里容易犯的错是默认包私有。 - 检查是否有final、static修饰:前面说过,这两类方法不走多态。
- 检查调用方式:如果是用父类引用调用的,动态绑定会走子类实现;如果你直接强转成子类再调用,那走的肯定是子类的,定位不到问题。更多时候是代码里通过
super.显式调用了父类实现。
8.2 一个真实踩坑记录:Jackson反序列化与重写
曾经有个同事做接口联调,返回的JSON里总有一个字段多出来。排查后发现,子类重写了父类的getType()方法,但@JsonProperty("type")注解加在了父类字段上,Jackson序列化时通过getter方法名来生成属性名,而子类的getType()被重写后也被识别成type,让字段重复出现。
这类问题不是因为重写本身有错,而是重写后行为发生改变,但调用方(这里是Jackson)仍然基于方法名做反射调用,于是出现了意料之外的结果。多态不只是给你用的,框架也在悄悄用。排查思路很简单:把@JsonProperty从字段挪到getter上,或者在子类重写的方法上补充注解说明。
8.3 IDE与编译器的辅助手段
现代IDE提供了非常可靠的重写检查。IntelliJ IDEA里,重写的方法左侧会有个向上的箭头图标,你把鼠标悬停上去能直接跳到父类方法原文;写一个方法想快速生成重写版本,可以按Ctrl + O(macOS是Cmd + O),弹出的窗口会列出当前类所有可重写的方法。
代码分析工具(如SonarQube)也会提示“重写方法缺少@Override注解”“可重写方法不应在构造方法中被调用”等警告,这些警告后面都是真实生产事故的教训。我建议把这类警告当成硬性规范对待,而不是“建议改进”。
9. 我对Java方法重写和多态的一个重要认知更新
聊了这么多规则和案例,我想说点个人的体会。
早几年我把“方法重写”和“多态”分开学,重写是语法,多态是思想。后来写代码多了才意识到,这两个词是在描述同一个事物的两个侧面:重写是“子类如何定义自己的行为”,多态是“调用方如何无感知地适配不同子类的行为”。没有重写,多态就是空中楼阁;没有多态,重写就只是“子类做了一个新方法”而已,价值大打折扣。
还有一件事,就是前面反复提到的:多态不仅服务你的业务代码,也在服务所有框架。当你使用Spring、Hibernate、MyBatis时,你的类被代理、被增强、被拦截,全都在利用Java的运行时动态分派机制。理解了方法表和invokevirtual的语义,你再看框架源码里那些“看似魔法”的地方,其实是语言基础最自然的外延。
参考一个实际体会:我会在评审代码时格外关注“子类在重写方法里有没有调用super”。有的人习惯重写后先调super.method(),但父类方法里有额外副作用时,这种行为可能导致副作用被重复执行或被迫执行。重写方法的语义应该定义清楚,要么替代父类实现,要么扩展父类实现,不能两边都想占。
10. 一套从“学着写”到“写得好”的自查清单
到这里,给一套我平时自己写代码和帮别人Review时用的检查清单。把它保存下来,每次写完重写的方法,快速过一遍,能挡掉绝大多数低级错误。
第一,签名一致性。方法名、参数列表和父类完全一致吗?返回值是父类返回类型的子类型吗?访问权限是否不弱于父类?
第二,注解检查。所有重写方法是否都加了@Override?这个注解不强制,但它能让编译器帮你检查签名是否写错。签名对不上,编译器会因为@Override的存在直接报错,而不是把方法悄悄当成新方法。
第三,异常契约。重写方法抛出的检查型异常是否在父类声明的异常范围内?
第四,final和static。有没有在静态方法上误写@Override?有没有试图重写final方法?
第五,初始化顺序。方法是否会在构造方法中被调用?如果是,而且方法可以被重写,建议改设计。
第六,hashCode与equals。重写了equals时,是否同步重写了hashCode?两者的判断逻辑是否一致?
第七,设计层面的替代方案。这个类是否真的需要用继承和重写?能否用接口加组合来表达?
每次写回调钩子、框架扩展点、策略实现时,把这份清单在脑子里过一遍并不费多少时间,但能省下很多“为什么没生效”的排查时间。希望这篇文章能帮你把方法和多态这两块地基打扎实,面试笔试能拿分,实际代码也能少踩坑。
