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

Java多态、重载与重写:编译期与运行期分派及字节码解析

发布时间:2026/9/29 4:46:53

资讯中心
01
ARTICLE

Java多态、重载与重写:编译期与运行期分派及字节码解析

Java多态、重载与重写:编译期与运行期分派及字节码解析
写 Java 写了十来年多态、重载、重写这三个词几乎贯穿了我的每一场技术面试、每一次代码评审、每一个线上事故的复盘。多态是面向对象三大特性里最虚的一个封装和继承你能摸到实体多态却更像一种约定——你调用同一个方法名运行时却跑进了不同的实现。而重载和重写这对名字只差一个字的兄弟恰恰是多态得以成立的两根支柱重载让同一个方法名在编译期找到不同的入口重写让同一个方法签名在运行期落到不同的实现。这篇文章我打算把这三个概念从源码写到字节码、从语法规则写到线上踩坑配结构图和对比表格讲透读完你应该能做到看到一个方法调用脑子里自动浮现它是在编译期定的还是运行期定的写出重载时知道编译器会怎么挑方法写出重写时知道哪些情况下看着像重写其实没生效。适合刚学完 Java 基础的同学也适合准备面试、想把八股文背成真理解的同行。1. 多态到底解决了什么工程问题1.1 从表彰优秀学生式的场景切入先不背定义。设想一个很常见的业务系统里有一批人员学生、老师、教务主任都需要打印一条个人简介。最笨的写法是写三个方法printStudent(Student s)、printTeacher(Teacher t)、printDirector(Director d)调用方还得先判断类型再决定调哪个。这个写法在人员类型只有三种的时候还能忍一旦扩展到三十种调用方的if-else就会长到没法看而且每次新增一种人员所有调用点都得改一遍。多态做的事情就是把这堆分支收回去定义一个父类型Person声明一个String intro()方法让三个子类各自实现。调用方只写printIntro(Person p)传进去的是什么运行时自然就跑谁的实现。调用方从我必须知道具体类型变成我只关心你是不是一个 Person。这就是多态最核心的价值把类型判断的责任从调用方转移到运行时让调用方针对抽象编程而不是针对具体实现编程。这个转变带来的连锁收益是很实在的。新增一种人员类型时调用方一行代码都不用改只需要新增一个类测试的时候可以用一个 Mock 实现替换掉真实实现因为它同样是父类型框架层面可以约定你实现我的接口我来负责调用你这就是各类回调、监听器、插件机制的基础。所以多态不是一个炫技的语法点它是解耦这件事在语言层面的基础设施。你写过的每一个ListString list new ArrayList()背后用到的就是多态——左边是接口类型抽象右边是具体实现。1.2 多态成立的三个必要条件多态不是随便写写就有的它有硬性门槛我一般总结成三条缺一条就退化成普通的方法调用存在继承或接口实现关系也就是必须有一个父类型可以作为引用的声明类型子类重写了父类的方法注意是重写不是重载参数列表必须一致父类引用指向子类对象也就是向上转型形如Person p new Student();三条同时满足p.intro()才会在运行时跑到Student.intro()。少第三条比如写Student s new Student(); s.intro()这当然也能跑但那是普通调用跟多态没关系。少第二条子类没重写那调用的时候自然只能跑父类的实现。这里有个很多人第一次听会懵的点字段不参与多态。看下面这段代码class Father { String name father; String getName() { return name; } } class Son extends Father { String name son; Override String getName() { return name; } } Father f new Son(); System.out.println(f.name); // 输出 father System.out.println(f.getName()); // 输出 son同一个对象直接访问字段拿到的是父类型的值调用方法拿到的却是子类的值。原因在于字段的访问在编译期就按引用的静态类型定死了而方法调用走的是运行期的动态分派。这个差异在真实项目里挺危险——如果你习惯把可变的配置项写成 public 字段子类覆盖了一个同名字段用父类型引用访问时会读到父类的旧值排查起来非常费劲。所以我的习惯是字段一律私有需要暴露就通过 getter 方法这样访问路径统一走方法多态行为才是一致的。1.3 运行时到底发生了什么虚方法表与动态分派理解了表面的规则再往下钻一层看看 JVM 是怎么实现同一个调用点跑出不同实现的。在类加载的准备和解析阶段每个类会在方法区JDK 8 之后是元空间里维护一张虚方法表vtable表里按固定顺序存放着这个类所有可以被动态分派的方法入口。子类如果重写了父类的某个方法它自己的 vtable 里那个槽位指向的就是子类的方法入口没重写的槽位继续指向父类的实现。Father 的 vtable Son 的 vtable ---------------------- ---------------------- | slot 0: Object.hashCode | | slot 0: Object.hashCode | | slot 1: Father.getName | -- | slot 1: Son.getName | (被重写指向子类) | slot 2: Father.work | | slot 2: Father.work | (未重写复用父类) ---------------------- ----------------------调用f.getName()的时候字节码里生成的是invokevirtual指令JVM 拿到接收者对象f从对象头里找到它的实际类型Son的 vtable按固定的槽位索引取出方法入口去执行。整个过程不需要遍历、不需要字符串比较是一次数组下标访问所以动态分派的开销其实很小。对比一下其他几个调用指令就更清楚了invokestatic调静态方法invokespecial调构造器、私有方法和super调用invokeinterface调接口方法。前两个是编译期就能定死的invokevirtual和invokeinterface才是运行期才能确定的。这也是为什么静态方法不可能被重写——它压根就不走 vtable 这条路径。2. 重载与重写一个编译期定一个运行期定2.1 重载同名方法的不同入口重载Overload的定义很朴素在同一个类中继承来的也算方法名相同但参数列表不同的一组方法。参数列表不同包括参数个数不同、参数类型不同、参数顺序不同。注意返回类型不同不构成重载只改返回类型编译器会直接报错因为调用方在编译期选择方法时只看方法名和实参类型返回类型无法参与重载决议。重载的决议发生在编译期术语叫静态分派。编译器看着实参的静态类型在候选方法里挑一个最合适的。这个挑选过程 JLS 规定了三个阶段我认为这是理解重载所有坑的钥匙阶段是否允许装箱/拆箱是否允许可变参数说明第一阶段不允许不允许最严格匹配只有类型完全相同才能命中第二阶段允许不允许允许基本类型与包装类型之间的转换第三阶段允许允许兜底阶段允许匹配可变参数方法编译器永远是从第一阶段开始试只要第一阶段能找到唯一最匹配的方法后面的阶段就不看了。这个顺序解释了很多看起来诡异的现象。举个我实际遇到过的例子public class OverloadDemo { public static void print(int x) { System.out.println(int: x); } public static void print(Integer x) { System.out.println(Integer: x); } public static void print(int... x) { System.out.println(varargs: x.length); } public static void main(String[] args) { print(10); // int: 10第一阶段直接命中 print(Integer.valueOf(10)); // Integer: 10第一阶段命中包装类型 print(10, 20); // varargs: 2只有第三阶段能匹配 } }把print(int)删掉之后print(10)会输出Integer: 10因为第一阶段找不到进入第二阶段允许装箱。再把print(Integer)删掉就只剩变参能接输出varargs: 1。这个渐进过程非常直观地体现了三阶段策略。另外两个高频坑是null 实参和可变参数歧义。传null给重载方法时编译器会选最具体的那个类型比如同时有print(String)和print(Object)会选print(String)因为 String 是 Object 的子类更具体如果同时有print(String)和print(Integer)两个之间没有继承关系编译器无法判断谁更具体直接报 ambiguous。所以我个人的经验是不要在同一个类里定义参数数量相同的重载方法只是类型不同尤其是类型之间存在父子关系的时候。企业内部规范里常见的一条就是避免用可变参数定义重载方法因为变参的匹配优先级最低行为容易出乎意料。2.2 重写子类替换父类行为的契约重写Override说的是子类重新实现了父类的一个方法。它的判定标准比重载严格得多Java 语言规范里叫子签名关系我整理成一张检查清单检查项要求违反后果方法名必须完全相同变成新方法不是重写参数列表必须完全相同含顺序和泛型擦除后类型变成重载不是重写返回类型相同或是父类返回类型的子类型协变返回Java 5编译错误访问修饰符不能比父类更严格可以更宽松编译错误抛出的异常不能抛出更宽的受检异常可以更窄或不抛编译错误static 方法不能被重写只能被隐藏行为按引用类型走final 方法不能被重写编译错误private 方法不参与重写子类同名方法是全新的方法看着像重写实际不生效关于协变返回类型举个例子父类方法返回Person子类重写时可以返回Student因为 Student 是 Person 的子类型。这个特性带来一个很实用的好处——子类引用调用重写方法时拿到的是子类型不用再强转。像clone()方法在很多类里就是这么重写的直接把返回类型收窄到本类。static方法的隐藏值得单独说一句。子类定义了一个和父类同签名的静态方法这不算重写叫方法隐藏。此时通过父类型引用调用该静态方法走的是父类的实现因为静态方法的调用在编译期就按引用类型定死了。这种代码在 IDE 里通常会被标黄提示我的建议很直接不要在业务代码里通过实例引用去调静态方法写类名一眼就能看出这是静态调用。至于final、private、构造器这三类本质上都不参与动态分派。私有方法尤其阴——子类写了一个和父类私有方法同名同参数的方法编译器不会报错Override注解才会报错。很多人不加Override结果以为自己重写了运行起来却跑的是父类逻辑。这就是我为什么坚持一条习惯只要本意是重写就一定加上Override。这个注解在编译期会帮你做一次签名比对能挡掉绝大部分低级错误成本是零。2.3 十个维度看透两者差异把上面的内容压缩成一张对比表我面试别人的时候经常直接让对方看这张表来解释维度重载 Overload重写 Override发生位置同一个类中含继承来的方法子类与父类之间方法名必须相同必须相同参数列表必须不同必须相同返回类型可以任意不同相同或协变子类型访问权限无限制不能比父类更严格受检异常无限制不能更宽绑定时期编译期静态分派运行期动态分派是否多态表现不是多态指的就是重写是static 方法可以重载不能被重写只能被隐藏性能开销编译期已定无额外开销一次 vtable 下标查找可忽略一句话总结两者的关系重载是编译期的选谁重写是运行期的跑谁。它们名字像但解决的是完全不同层面的问题一个关于方法签名的复用一个关于行为的多态替换。3. 手把手写一套能跑起来的多态代码3.1 场景设计与类结构光看规则容易飘我搭一个完整的、可以直接复制运行的小项目把重载和重写都用上。场景是图形面积统计有一个抽象类Shape下面挂Circle、RectangleSquare再继承Rectangle来演示重写链另外写一个工具类AreaTool通过重载提供多种调用方式。--------------------- | Shape (abstract) | |---------------------| | name(): String | | area(): double | | describe(): String| -------------------- | ----------------------------- | | -------v------- ---------v-------- | Circle | | Rectangle | |---------------| |------------------| | - radius | | - width, height | | area() | | area() | --------------- ----------------- | --------v-------- | Square | |-----------------| | area() | -----------------3.2 完整代码实现abstract class Shape { private final String name; protected Shape(String name) { this.name name; } public String name() { return name; } // 抽象方法强制子类重写 public abstract double area(); // 普通方法子类可选择重写 public String describe() { return name() 的面积是 String.format(%.2f, area()); } } class Circle extends Shape { private final double radius; Circle(double radius) { super(圆形); this.radius radius; } Override public double area() { return Math.PI * radius * radius; } } class Rectangle extends Shape { protected final double width; protected final double height; Rectangle(double width, double height) { super(矩形); this.width width; this.height height; } Override public double area() { return width * height; } } class Square extends Rectangle { Square(double side) { super(side, side); } // 重写 name()注意父类 Rectangle 没重写它上去是 Shape.name() Override public String name() { return 正方形; } } class AreaTool { // 重载一单个图形 public static double sum(Shape s) { return s.area(); } // 重载二多个图形变参 public static double sum(Shape... shapes) { double total 0; for (Shape s : shapes) { total s.area(); } return total; } // 重载三额外的缩放因子 public static double sum(Shape s, double scale) { return s.area() * scale; } } public class PolyDemo { public static void main(String[] args) { // 父类型数组装不同子类对象这是多态最经典的用法 Shape[] shapes { new Circle(2), new Rectangle(3, 4), new Square(5) }; for (Shape s : shapes) { System.out.println(s.describe()); } // 重载决议编译期根据实参数量和类型选方法 System.out.println(单个面积: AreaTool.sum(shapes[0])); System.out.println(全部面积: AreaTool.sum(shapes)); System.out.println(缩放后 : AreaTool.sum(shapes[0], 2.0)); } }跑出来的结果是圆形 的面积是 12.57 矩形 的面积是 12.00 正方形 的面积是 25.00 单个面积: 12.566370614359172 全部面积: 49.56637061435918 缩放后 : 25.1327412287183453.3 从执行结果反推调用过程看第一段循环。shapes声明成Shape[]但里面装的三个对象的实际类型各不相同。循环里调s.describe()describe()方法在Shape里只有一份实现它内部调了name()和area()。这两个调用才是多态的现场s.area()是抽象方法三个子类都重写了运行时按实际类型分派所以分别算出了圆、矩形、正方形的面积s.name()在Shape里有实现Circle和Rectangle都没重写只有Square重写了。所以前两个走父类实现拿到圆形矩形第三个走Square的实现拿到正方形第三行输出正方形 的面积是 25.00这个结果非常直观地展示了方法链上的每一跳都是独立分派的。父类的方法体里调用的方法同样会按运行时的实际类型去找不会因为写在父类里就固定跑父类版本——这就是所谓的回调式多态模板方法模式就建立在这个特性上。再看重载那三个sum调用。编译器在编译AreaTool.sum(shapes[0])时看实参静态类型是Shape候选里有sum(Shape)、sum(Shape...)、sum(Shape, double)参数个数为 1第一阶段就能匹配到sum(Shape)直接定死。AreaTool.sum(shapes)传的是数组Shape[]可以整体匹配到变参方法所以走的是累加逻辑49.56 就是三个面积之和。第三个调用有两个实参匹配sum(Shape, double)。整个选择过程全在编译期完成字节码里已经是明确的invokestatic目标。这里我还想强调一个经常被忽略的点AreaTool.sum(shapes)如果只传一个Shape对象它会匹配到变参方法而不是单个参数的那个因为变参方法在编译器眼里是一堆零散参数的最佳适配而精确的sum(Shape)在第一阶段优先命中。这种看起来一样实际走了不同分支的情况正是重载最容易被误读的地方。3.4 用 javap 看字节码把结论坐实讲到这里如果还觉得抽象最靠谱的办法是直接看字节码。编译之后执行javac PolyDemo.java javap -c -p PolyDemo.class在 main 方法的输出里你能看到两类指令同时存在invokestatic #xx // Method AreaTool.sum:(LShape;)D invokestatic #xx // Method AreaTool.sum:([LShape;)D invokevirtual #xx // Method Shape.describe:()Ljava/lang/String; invokevirtual #xx // Method Shape.area:()D两个sum调用是invokestatic而且常量池里已经写明了具体是哪个签名一个是(LShape;)D一个是([LShape;)D——重载在编译期就被固化了铁证。而describe()和area()是invokevirtual常量池里只记录了Shape.area具体跑谁的实现要等运行期。这两行字节码放在一起看重载和重写的区别就不需要再解释了。4. 踩坑实录重载与重写的典型翻车现场4.1 List.remove 那个经典陷阱如果要选一个最能说明重载有多坑的例子我投List.remove。看代码ListInteger list new ArrayList(Arrays.asList(10, 20, 30)); list.remove(1); // 误以为删元素 1实际删的是下标 1 System.out.println(list); // [10, 30] ListInteger list2 new ArrayList(Arrays.asList(10, 20, 30)); list2.remove(Integer.valueOf(1)); // 删的是元素 1但 1 不在列表里什么也没删 System.out.println(list2); // [10, 20, 30]List接口有两个remove重载remove(int index)和remove(Object o)。传字面量1第一阶段匹配int走的是按下标删除想删元素必须显式装箱成Integer。这个坑我第一次遇到的时候排查了半个多小时因为代码看起来完全合理明明是要移除 1。避坑建议很明确在ListInteger上做删除操作时永远显式写清楚意图删索引就用remove(index)并把变量名起成idx删元素就写成remove(Integer.valueOf(target))或者干脆用removeIf(x - x.equals(target))。多敲几个字符省下半小时排查时间。4.2 父类构造器里调用可重写方法这是个运行期才炸的坑而且炸得很隐蔽class Parent { Parent() { init(); // 危险调用了一个可被重写的方法 } protected void init() { System.out.println(parent init); } } class Child extends Parent { private final int value 100; // 字段初始化在父类构造器之后执行 Override protected void init() { System.out.println(child init, value value); } } new Child();输出是child init, value 0。有没有觉得不对劲value明明写了 100实际却是 0。原因在于对象初始化的顺序父类构造器先执行此时子类对象的字段还没被赋值虽然数值类型的默认值 0 已经就位但100那一步还没走到。而父类构造器里调用的init()因为多态分派到了子类的实现子类实现里去读value读到的是还没赋值的默认值。如果value是个引用类型这里读出来直接就是null然后 NPE。这条规则我总结成一句话构造器里只调用 private 方法或 static 方法。这两种都不参与动态分派调用的目标在编译期就是确定的不会出现子类状态还没准备好就被调用的情况。这是一个很多资深开发者都会疏忽的点代码评审时看到构造器里有方法调用我会多看一眼那个方法的修饰符。4.3 重写失效的几种隐蔽情况有一类问题特别让人抓狂代码看着是重写了日志却跑的是父类逻辑。我整理了一张速查表覆盖我实际遇到过的几种现象可能原因排查方法加了Override编译报错参数列表不一致或父类方法是 private用 IDE 的跳转到父类方法确认签名没报错但走了父类逻辑忘了加注解写成了重载给所有重写方法补上Override静态方法调不到子类实现static 只能隐藏不能重写用javap看是invokestatic还是invokevirtual泛型接口实现后行为怪编译器生成了桥接方法javap -p查看是否有 bridge 标记的方法断点打父类方法没停实际执行的是子类重写版本在子类方法上也打一个断点对比子类字段读到 null 或 0父类构造器提前调用了可重写方法检查父类构造器里的方法调用桥接方法这个点值得展开说一句。假设你写了class MyComp implements ComparableMyComp并实现了compareTo(MyComp o)由于泛型擦除接口里实际的方法签名是compareTo(Object)。编译器会自动给你生成一个桥接方法compareTo(Object)内部强转后调用你写的那个版本。所以你在反射里看到的方法数量会比源码里多getDeclaredMethods()可能会返回两个compareTo。理解这一点排查泛型相关的方法找不到问题就有方向了。4.4 一个给新手的自检清单踩坑踩多了我给自己总结了一套写重写和重载时的检查动作基本能覆盖九成以上的问题写重写先敲Override让编译器帮你验证签名返回类型优先考虑协变收窄访问权限只放宽不收紧异常只收窄不放大写重载优先保证参数个数不同尽量避免只靠类型区分不要把可变参数作为重载的一员基本类型和包装类型不要在同一个重载族里混用重写equals必须同时重写hashCode否则放进HashMap、HashSet会出现明明相等却查不到的问题重写方法时读一遍父类的注释和契约说明特别是Object那几个方法契约自反、对称、传递、一致性违反了不会报错但会在集合类里出诡异 Bug上线前用javap -c抽查关键类确认调用指令类型符合预期这个动作花不了一分钟5. 多态在真实项目里的三种落地姿势5.1 策略模式把一长串 if-else 收掉业务代码里最典型的多态应用就是策略模式。比如订单折扣会员折扣、满减、新客立减很多人第一版会写成if (VIP.equals(type)) { // VIP 逻辑 } else if (FULL_REDUCE.equals(type)) { // 满减逻辑 } else if (NEW_USER.equals(type)) { // 新客逻辑 }这个写法的坏处在于每加一种折扣都要动这个方法方法会越来越长改动风险越来越高。多态的写法是定义一个DiscountStrategy接口声明一个BigDecimal apply(BigDecimal amount)方法每种折扣一个实现类用一个MapString, DiscountStrategy注册起来调用时map.get(type).apply(amount)。新增折扣只新增一个类主流程一行不改。这个改造真正的收益不只是代码好看。它让每种折扣的实现可以独立测试——单独new VipDiscount()就能测不需要构造整个订单上下文也让不同的开发者可以并行开发不同策略类减少冲突。这才是多态在设计层面带来的价值。5.2 模板方法父类定骨架子类填细节模板方法是继承加多态的组合技抽象类里定义好流程的每一步其中若干步留给子类实现。前面那个Shape.describe()就是微缩版的模板方法骨架是名字 面积name()和area()由子类提供。在真实项目里数据导入功能特别适合这个模式抽象类AbstractImporter里的importData()定义好读取文件 → 解析 → 校验 → 落库 → 记录日志五步parse()和validate()声明成抽象方法CSV 导入器和 Excel 导入器各实现一份。这样新增文件格式时流程一致性由父类保证格式差异由子类处理不会出现CSV 导入记了日志Excel 导入忘了记这种低级不一致。用这个模式的时候有个注意事项抽象方法声明成 protected 而不是 public因为它们是给子类用的扩展点不应该暴露给外部调用方同时那些不允许子类改动的步骤用final修饰防止子类把流程骨架改坏。5.3 扩展点设计接口加多态撑起插件化再往上走一层就是各种框架级的扩展点。JDK 自带的ServiceLoader就是靠多态实现的你提供一个接口各个实现在META-INF/services下注册框架加载时统一按接口类型拿到一堆实现遍历调用。各种拦截器、过滤器、监听器、序列化器、日志实现全是同一个套路。踩过几次坑之后我体会到用多态做扩展点时最需要克制的两件事一是不要在接口里塞太多方法接口一胖实现类就得写一堆空方法后续想加方法还会破坏已有实现这时候可以配合 default 方法过渡二是要明确回调的执行顺序和异常策略一个实现抛异常是中断整条链路还是记录后继续这个必须在接口文档里写清楚否则接入方各写各的线上出问题很难定位。这两点跟多态语法无关但决定了多态能不能在系统里真正落地。5.4 性能上的一个小结论顺便回应一个我常被问到的问题多态是不是比直接调用慢理论上是动态分派需要多一次 vtable 查找而且会阻断一部分内联优化。但在 HotSpot 上如果某个调用点在运行时的实际类型长期只有一两种JIT 会做**类型画像type profile**和内联缓存把这个虚调用直接优化成快速的类型判断加上内联代码开销基本被抹平。我在自己压测过的百万级 QPS 场景里从来没有因为用了多态成为瓶颈真正的瓶颈几乎总是数据库交互、序列化、锁竞争这些。所以我的态度很明确先保证结构正确性能有问题再用数据说话不要为了想象中的开销牺牲可维护性。
02
RELATED NEWS

相关资讯

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

03
WHY YAOTU

想打造同款高转化官网?

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

◈

场景化定制

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

◐

营销型架构

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

▲

全周期服务

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

免费获取你的建站方案

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