尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

一次讲透Java多态:方法重写、重载、接口与抽象类

发布时间:2026/9/27 0:58:57

资讯中心
01
ARTICLE

一次讲透Java多态:方法重写、重载、接口与抽象类

一次讲透Java多态:方法重写、重载、接口与抽象类
1. 四个概念为何总被放进同一道Java面试题我在给团队做代码评审和面试复盘时发现一个很有意思的现象很多候选人单独问“方法重写是什么”“接口和抽象类有什么区别”都能答出几句但一旦把方法重写、重载、接口、抽象类这四样东西放到同一个场景里让他们设计一个类体系思路立刻开始左右摇摆。这其实不是记性问题而是理解体系的问题。这四个概念表面上都属于Java基础语法但它们背后代表的是两种完全不同的机制重载和重写负责解决“同一个行为在不同形态下如何表现”接口和抽象类负责解决“行为契约怎么定义、公共代码怎么复用”。换句话说前两个是运行机制层面的多态后两个是设计层面把“该做什么”和“怎么做”分离的工具。把它们放在一起学不是为了应付面试题而是因为在实际项目中它们本来就是缠在一起的。1.1 从JVM方法调用看重载与重写的本质要真正理解重载和重写不能只停留在IDE提示的错误上。我习惯先看字节码层面的调用指令这一步能帮很多人瞬间开窍。看一段最简单例子public class Animal { public void makeSound() { System.out.println(Some sound); } } public class Dog extends Animal { Override public void makeSound() { System.out.println(Woof); } }调用时如果写成public static void test(Animal animal) { animal.makeSound(); }这个makeSound()的调用在编译阶段是没法决定到底执行Animal里的方法还是Dog里的方法的因为传入的animal引用运行时到底指向谁编译器不知道。JVM运行时通过对象的实际类型去查虚方法表找到对应的方法入口。这就是重写背后的动态分派。重载则完全不同。比如public class Printer { public void print(String s) { ... } public void print(int i) { ... } }调用printer.print(hello)时编译器在编译时期就能根据参数的静态类型和数量确定该调用哪个方法。这就是静态分派。有时候一个方法调用看起来模糊恰恰是因为参数发生了自动类型提升比如print(1)既可能匹配print(int)也可能匹配print(long)编译器会选最“接近”的那个。接口的调用则走invokeinterface指令抽象类的普通方法走invokevirtual。这些指令级别的差异恰好解释了为什么接口方法天然具备运行期多态能力而不是像重载那样在编译期就锁死。你可以不用背字节码但理解这一层面试时提到“编译期多态和运行期多态”就不会只是背结论。1.2 高频面试场景背后到底在考什么网上搜索“java基础面试题”十个里面至少八个会问到这四个概念。问我为什么面试官这么爱考因为这几个点串联了面向对象三大特性里最难考察的“多态”和“抽象”。面试官真正想看到的不是候选人背出“重写是子类重写父类方法”这句话而是三个层次第一熟练描述规则第二能说出为什么这样设计第三能结合场景做选型。比如“有一个动物体系会不会飞用接口还是抽象类”这种问题没有标准答案但能看出一个人平时设计代码时的思考路径。所以我下面的内容不打算按教科书顺序平铺而是把重写和重载拆开讲清楚运行机制再把接口和抽象类放到一起做设计对比。最后给出一套我自己在选型时用的判断思路。2. 方法重载与重写一个在编译期分工一个在运行期替换很多人把重载和重写搞混是因为它们都发生在方法名相同的情况下。但它们的动机完全不同重载是同门类里“同名方法处理不同类型参数”的分工方式重写是父子关系中“子类替换父类行为”的覆盖方式。2.1 重载是同一类里的“一门多译”重载的规则只有一条核心方法名相同参数列表不同。参数列表不同可以是类型不同、个数不同、顺序不同比如void test(String s)和void test(int i)是重载void test(String s, int i)和void test(int i, String s)也是重载。这里有个最常见的误解返回值不同算不算重载不算。假设有两个方法只有返回值不一样编译器在调用test(1)时根本无法判断你想返回int还是String所以Java直接不允许这种写法。同理访问修饰符不同也不构成重载的依据那两个方法根本不能共存除非参数列表不同。重载的选择在编译期进行但我提醒一个容易被忽略的细节参数自动提升和可变参数。public class OverloadDemo { public void show(int a) { System.out.println(int: a); } public void show(String s) { System.out.println(String: s); } public void show(long a) { System.out.println(long: a); } }调用demo.show(3)时编译器最优先匹配int因为3是int字面量。可如果把show(int a)删掉编译器会选择show(long a)因为int能自动提升为long而不是选择show(String s)——自动类型转换走不通编译器会直接报错。真实项目里经常有人把重载写成一眼看上去能工作实际却在自动提升时悄悄走进了另一个方法所以我建议重载方法不要故意依赖这种隐式匹配宁可显式使用不同类型也不要用Object收尾再在里面做instanceof分支。可变参数是重载匹配链的最末位。show(int... nums)和show(int a)同时存在时调用show(1)会优先匹配单参数版本因为编译器觉得可变参数是“最后的妥协方案”。这条优先级规则在面试里也常被拿来变形提问记住“固定参数优先于可变参数”就够了。2.2 重写是父子之间的“同途殊归”重写发生在继承体系中。子类重新实现父类的实例方法方法是同一个方法但行为被替换了。规则比较多我把最核心的几条列出来方法名、参数列表必须完全一致否则就是重载不是重写。访问修饰符不能比父类更严格。父类是protected子类可以是protected或public但不能是private。返回值类型可以是父类返回类型的子类型这叫协变返回类型。抛出的受检异常不能比父类更广但可以不抛。父类的private方法、static方法、final方法都不能被重写。日常开发中Override注解是必须养的肌肉记忆。它不等于重写本身但编译器会帮你检查签名和访问级别一旦不满足就报错能拦住一大批手误。这里多聊一句协变返回类型很多人不重视。比如父类方法返回Animal子类重写时可以返回Dogclass Animal { } class Dog extends Animal { } class Shelter { public Animal get() { return new Animal(); } } class DogShelter extends Shelter { Override public Dog get() { return new Dog(); } }这个语法在Java 5之后才支持。优点很明显子类能给调用方更具体的类型省去一次向下转型。不过它刚出那几年底层实现是靠“桥方法”完成兼容的原理是在字节码里同时生成一个返回Animal的隐藏方法和一个返回Dog的公开方法由隐藏方法内部调用公开方法。面试里如果能把这一段讲出来会比单纯报规则加分不少。2.3 面试和日常代码里最容易踩的重写坑先说一个项目里真实发生过的线上问题有个告警模块基类里有个protected void sendAlarm(String content)子类想加一个内容格式化的能力写成了public void sendAlarm(String content, String tag)结果业务代码里调用的还是父类的两参数不对是单参数方法。你猜发生了什么子类的格式化逻辑根本不会被执行因为参数列表多了个tag这其实是重载不是重写。调用的地方拿父类引用调sendAlarm(content)走的还是父类原始方法。这种“以为在重写实际在重载”的问题在代码评审里出现频率极高。避免办法只有一个所有打算重写父类方法的地方一律加Override编译器会当场提示你签名对不上。第二个坑和构造器有关。父类构造器里尽量不要调用可被重写的方法原因在于子类对象还没有完整构造出来。比如class Base { Base() { init(); } void init() { System.out.println(Base init); } } class Child extends Base { private String name child; Override void init() { System.out.println(Child init: name); } }执行new Child()时父类构造器先跑它调用的init()实际会分派到子类的init()但此时name还没被赋值输出是null。一旦init()里对子类字段做了非空假设就直接抛异常。这个问题在Spring的PostConstruct语境下也有类似变种所以抽象类模板方法里的公共流程最好明确区分哪些步骤允许子类重写、哪些步骤用final锁死。第三个坑是静态方法。子类里写一个和父类静态方法签名完全相同的方法叫“隐藏”不叫重写。通过父类引用调用时执行的是父类静态方法通过子类引用调用时执行的是子类静态方法。你在方法上写Override编译会直接报错。3. 抽象类为共享骨架而生的中间层3.1 抽象类到底“抽”了什么抽象类在Java里是用abstract修饰的类。它最核心的特点是不能通过new创建实例但可以拥有构造器、字段、具体方法和抽象方法。用生活化一点的类比普通类像一张已经能直接照着生产的完整图纸抽象类则像一套“半成品零件包”里面有一部分零件已经装好了还有一部分只预留了螺丝孔位和接口说明具体装什么得由买家决定。抽象方法只有方法签名没有方法体public abstract class Payment { private String orderId; public Payment(String orderId) { this.orderId orderId; } public String getOrderId() { return orderId; } public final boolean start() { boolean ok validate(); if (ok) { pay(); } return ok; } protected abstract boolean validate(); protected abstract void pay(); }这里的validate()和pay()就是抽象方法子类必须实现除非子类也是抽象类。start()是模板方法用final锁住流程顺序。抽象类的价值就在这里把确定的、公共的逻辑写在父类把不确定的、需要变化的部分暴露给子类。还有一个面试爱问的点“抽象类和普通类的区别”。答案不是简单的“能不能实例化”而是“抽象类是否拥有抽象方法决定了它能不能被打断的部分”。普通类里的方法都应该是完整实现抽象类允许方法只有声明没有实现这就在语言层面强制了子类必须补全。3.2 构造器、初始化顺序和模板方法模式很多初学者会问“抽象类不能实例化为什么还需要构造器”因为子类实例化时会先执行父类构造器。这是Java对象初始化链路的一部分父类构造器负责初始化父类的字段。于是涉及到一个必然动作如果一个抽象类带有有参构造器子类构造器必须通过super(...)调用它否则编译过不去。看这个例子public abstract class ReportGenerator { private String title; public ReportGenerator(String title) { this.title title; } public final void generate() { loadData(); render(); } protected abstract void loadData(); protected abstract void render(); }generate()定义好了步骤先加载数据再渲染。子类不需要关心顺序只需要分别实现loadData()和render()。这就是模板方法模式最典型的落地方式。我在实际项目里经常拿它处理批处理任务、数据同步、报表导出因为整个流程的骨架是稳定的变化的只有中间某几步。使用模板方法时有一个我自己反复强调的规矩父类公共方法里能调用抽象方法没问题但不要在父类构造器里调用抽象方法。前面已经说过构造阶段会给子类字段赋值留下时间差抽象方法的实现一旦依赖尚未初始化的子类字段就极容易出空指针。3.3 抽象类的使用信号什么时候该考虑抽象类我给一个排除法。第一如果多个类之间是强“is-a”关系且共享大量字段和具体逻辑用抽象类。比如Dog、Cat都是Animal都有name和eat()抽象类可以把这些公共部分收拢。第二如果有一个稳定的算法步骤只需要子类填其中几步用抽象类。模板方法模式天然适合。第三如果类之间有共同的非公开状态或受保护字段抽象类比接口合适得多。接口不能有实例字段状态管理全部要靠实现类自己写很容易复制粘贴。抽象类最大的限制是单继承一个子类只能继承一个父类。当碰到“企鹅既是鸟又会游泳还得实现一个飞行动画接口”这种多维度需求时单纯靠抽象类会把关系拉扯得很尴尬。这时候就得把接口请出来。4. 接口能力契约以及Java 8之后的变化4.1 Java 8前后的接口是个分水岭老Java程序员应该记得Java 7及以前的接口只能放抽象方法并且方法默认是public abstract字段默认是public static final。那时候接口承担的角色非常纯粹定义“能干什么”。你实现一个Runnable接口就必须提供run()方法这样才能被丢到线程里执行。Java 8引入default方法和static方法后接口开始承担一部分代码复用工作。为什么要这么干最典型的是集合框架。JDK要给List加一个sort()方法如果直接做成抽象方法所有实现List的第三方类全都要改。用default方法提供一个基于内部数组拷贝的默认实现老实现类就能直接继承这个新能力不需要动一行代码。Java 9又加入了接口的private方法主要解决一个代码复用的尴尬几个default方法之间如果有重复逻辑之前只能复制粘贴现在可以在接口内部提取一个private方法外部依然不可见。这就是接口演进的主线从不许有实现到有默认实现再到内部实现代码可以复用。面试问“接口和抽象类的区别”如果只停留在旧版Java很多结论其实已经过时了。4.2 默认方法、静态方法、私有方法拆开说默认方法的写法public interface Logger { void log(String message); default void warn(String message) { log([WARN] message); } }实现Logger的类只需要实现log()warn()直接用默认行为。如果某个实现类想改警告前缀可以重写warn()。接口静态方法public interface StringUtils { static boolean isBlank(String s) { return s null || s.trim().isEmpty(); } }调用方式是StringUtils.isBlank(s)必须用接口名调用不能通过实现类的实例调用。这个设计给了接口一个放工具方法的位置但要注意你的接口应该是“高内聚”的不要把完全不相关的静态工具方法塞进一个接口里。接口私有方法Java 9public interface Handler { void handle(); default void handleSafely() { logStart(); handle(); } private void logStart() { System.out.println(start); } }这个logStart()不能被子类或外部调用只能被接口内部的其他方法调用。对于多个default方法共享逻辑的场景非常有用。默认方法还有一个高频面试点多接口和父类冲突时的优先级规则。如果一个类实现了两个接口两个接口里有同名同签名的default方法这个类必须重写这个方法否则编译器报错。如果继承的父类和实现的接口里同时有一个方法且接口方法带default父类方法优先这是我们常说的“类优先”规则。这条规则不是随便定的它保证了旧代码在接口新增方法后不会出现行为被意外覆盖的情况。4.3 接口和抽象类放在同一张表里对比做表格对比是面试时最快速的答题结构。我直接把常用的对比项写出来对比维度抽象类接口设计意图定义“是什么”共享骨架和状态定义“能做什么”契约与能力实例化不能直接实例化不能实例化构造器可以有构造器没有构造器字段可以有实例字段只能有常量访问修饰符任意方法默认publicJava 9后可私有具体方法可以有具体方法Java 8后可以有default和static方法抽象方法可以有抽象方法默认是抽象方法继承限制单继承可多实现扩展性改动父类方法容易影响所有子类新增default方法可以平滑扩展典型场景模板方法模式、公共状态管理能力定义、多态、解耦、多技能组合用一句话记忆抽象类强调的是“这个对象本质是什么”接口强调的是“这个对象能不能做某件事”。一个动物既可以是抽象类Animal的实例又可以实现Flyable接口因为它本质是动物同时具备飞行能力。把本质固化在继承链上把能力放在接口里设计上会清晰很多。5. 面对真实需求该怎么选附一个可落地的决策思路5.1 三个常见的Java场景演练我把选型问题放到三个真实场景里比干讲概念好懂。第一个场景设计动物园系统。现在有狗、猫、鸟、鱼。狗和猫都有name、age、eat()。鸟有fly()鱼有swim()。如果非把fly()放进Animal抽象类狗和猫都要被迫实现一个跟自己无关的方法还得抛异常。正确做法是定义抽象类Animal再定义接口Flyable和Swimmablepublic abstract class Animal { protected String name; public abstract void eat(); } public interface Flyable { void fly(); } public interface Swimmable { void swim(); } public class Sparrow extends Animal implements Flyable { Override public void eat() { ... } Override public void fly() { ... } } public class Fish extends Animal implements Swimmable { Override public void eat() { ... } Override public void swim() { ... } }这个模型的好处是以后加入蝙蝠、企鹅能力维度可以自由组合不需要把继承链改得乱七八糟。第二个场景支付处理器。AlipayPayHandler、WechatPayHandler都要做签名校验、金额校验、请求第三方、处理回调。如果这些步骤几乎一样只是签名算法和请求地址不同用抽象类做模板方法最合适把公共字段比如商户ID、密钥放在抽象类里把差异点留给子类。但如果支付系统里存在不同“能力类型”比如支持退款、支持预授权那可以再拆接口让实现类同时继承抽象类并实现接口。第三个场景事件监听器。Java里常见的ActionListener就是接口不是抽象类。为什么因为某个业务类本身可能有自己的父类比如继承了一个BaseController它不能再去继承抽象监听器类。让任何类都能“成为”监听器的最好方式就是定义一个接口谁想监听谁实现。这不影响原有继承链。这三个场景放在一起可以得出一个选型经验只要存在多维度能力组合或者实现类已经有一个确定的父类接口是必然选择只要多个类的高度相似体现在“骨架状态”上抽象类是更节省代码的选择。5.2 组合优于继承接口在这里的真实地位《Effective Java》里有一句名言优先考虑组合而不是继承。很多人误解这句话以为继承该被抛弃。实际上作者批评的是“为了复用代码而不加思考到处继承”不是否定抽象类存在的意义。真正容易出问题的继承结构是这样的为了复用两个方法硬造出一个父类结果子类A和子类B其实只是碰巧都能做那两件事没有本质的“is-a”关系。比如Employee和Department都能printReport()就让两个类都继承一个ReportPrinter时间长了会发现父类里塞满了各种不相干的方法一次改动牵连一片。接口在这里的作用是给“能做”这件事一个轻量级的约束。一个类可以继承抽象类同时实现多个接口。抽象类负责“是”接口负责“能”组合起来正好把继承的复用能力和接口的横向扩展能力都用上。我自己的选择流程通常是四步先问这些类之间到底是不是强“is-a”关系不是优先考虑接口。再问有没有一组公共字段和稳定流程需要共享有用抽象类。三问是否需要让实现类可以继承别的父类需要接口更灵活。最后问会不会有多个能力维度同时出现会接口组合必要时抽象类打底。这个流程在走查和开发中很实用即便拿不定主意至少能让讨论有明确的指向。5.3 面试题怎么答才算过关把“八股文”变成分析链我不反对背八股文面试时间那么短完全临时推导反而容易卡壳。问题在于光背结论没有场景答案听起来就很空。我的建议是准备一套固定的分析链每个概念都按“定义—规则—底层机制—适用场景”四层来组织。拿“重写和重载的区别”举例可以这样答第一句说定义重载是同一个类中方法名相同、参数列表不同编译期确定调用哪个版本重写是子类对父类方法重新实现运行期分派。第二句说规则重写要求参数列表一致、访问权限不能更严、异常不能更宽、返回值支持协变重载只看参数列表与返回值无关。第三句结合底层重载是静态分派javac编译时就确定重写靠虚方法表动态分派。第四句补场景框架里大量重写protected方法做扩展点API设计里常用重载提供不同参数的入口。接口和抽象类同理先说定义再说区别最后必须落到“什么场景选哪个”。面试官一旦追问你一个具体问题比如“为什么Spring的ApplicationContext是接口而不是抽象类”你就顺着这个链说因为不同的容器实现可能在继承体系上各自有父类接口能给它们统一的能力入口同时不影响各自的继承结构。把八股文当成框架往里填自己的理解和实例才是真正的过关方式。我个人还有个习惯画类图时把继承关系用实线三角标出来把接口实现用虚线标出来然后问自己一句——这条实线真的成立吗如果只是因为想共用方法而画了一条继承线通常改成接口实现会让代码松耦合得多。反过来如果一堆子类确实要共享同一套状态和初始化逻辑硬拆成接口反而要在每个实现类里重复写维护代码。绕了一圈方法重写、重载、接口、抽象类并不是四个孤立的考点它们都指向同一个问题把什么行为放在哪一层谁来定义、谁来实现、谁来替换。把这层想明白了面试题基本不会卡你代码设计也会顺很多。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。