拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java多态从原理到工程:动态绑定、虚方法表与重写陷阱

Java多态从原理到工程:动态绑定、虚方法表与重写陷阱 作为常年面试 Java 候选人的老手我遇到最多的情况是多态这个概念大家都能背——父类引用指向子类对象调用同一个方法表现出不同行为但再追问一句JVM 是怎么知道该调哪个方法的为什么静态方法不能写成多态父类构造器里调用重写方法会出什么幺蛾子对话就开始沉默了。今天我不打算背诵教科书而是从我实际写代码、做代码评审的经历出发把 Java 多态从是什么讲到底层怎么跑再到工程里怎么用、有哪些坑一次讲透。这篇文章适合三类人刚学完 Java 语法、准备面试题的基础学习者写了几年 CRUD、想弄明白框架为什么那样设计的开发以及准备深入 JVM 字节码层面的进阶者。1. 没有多态的世界长什么样一段长满 if-else 的代码1.1 先从一个会叫的动物理解多态的最直观表现很多入门教程都喜欢用动物来举例子我也不例外因为这个例子最能暴露问题。先看一段最基础的代码public class Animal { public void speak() { System.out.println(动物发出声音); } } public class Dog extends Animal { Override public void speak() { System.out.println(汪汪汪); } } public class Cat extends Animal { Override public void speak() { System.out.println(喵喵喵); } }然后写一个测试方法Animal a1 new Dog(); Animal a2 new Cat(); a1.speak(); a2.speak();这段代码里a1和a2的声明类型都是Animal但运行时一个指向Dog对象一个指向Cat对象。调用speak()的时候实际执行的是各自真实类型里的方法。如果用一句话概括多态最直接的表现就是同一个类型的引用指向不同的对象时调用同一个方法会得到不同的行为。很多初学者觉得这不就是理所当然的吗但恰恰是这个理所当然掩盖了它背后的复杂性。声明类型是Animal为什么最终却能调到Dog的方法这种运行期才确定调用谁的机制才是多态真正的核心。1.2 如果没有多态业务逻辑会退化到什么程度理解了上面的例子我们再看看没有多态时代码会变成什么样。假设我们要实现一个动物园饲养员不同动物吃不同的食物很多初级开发会写出这样的代码public class ZooKeeper { public void feed(String animalType) { if (dog.equals(animalType)) { System.out.println(给狗喂骨头); } else if (cat.equals(animalType)) { System.out.println(给猫喂鱼); } else if (elephant.equals(animalType)) { System.out.println(给大象喂水果); } else { System.out.println(不知道喂什么); } } }这段代码的问题不用我说大家也能看出来每增加一种动物就要在feed方法里加一个else if。这个方法的长度会随动物种类线性增长而且属于改一处就要重新测试全部的典型坏味道。更麻烦的是如果饲养员除了feed还有healthCheck、cleanUp等方法每个方法里都要复制一份这种 if-else 逻辑代码规模直接爆炸。我在代码评审里见过很多这样的 Service 方法一个方法四五十行全是各种if判断类型然后做不同处理。这种代码初期没问题项目迭代三个月后谁碰谁头疼。1.3 多态真正的价值把分支判断变成对象自我描述如果用多态来重构上面的饲养员代码你会发现整个结构完全变了public abstract class Animal { public abstract void eat(); } public class Dog extends Animal { Override public void eat() { System.out.println(给狗喂骨头); } } public class Cat extends Animal { Override public void eat() { System.out.println(给猫喂鱼); } } public class ZooKeeper { public void feed(Animal animal) { animal.eat(); } }调用方变成这样Animal dog new Dog(); Animal cat new Cat(); ZooKeeper keeper new ZooKeeper(); keeper.feed(dog); keeper.feed(cat);注意这里的核心转变ZooKeeper不再需要知道当前是什么动物它只面向Animal这个抽象层编程。具体吃什么由传入对象的真实类型自己决定。以后要加一个Elephant你只需要新增一个类ZooKeeper一行代码都不用改。这就是多态配合开闭原则带来的直接收益——对扩展开放对修改关闭。你可以把多态理解为电视机遥控器上的电源键这个按键的触发逻辑是固定的但按下之后是电视开机、投影仪启动还是机顶盒亮灯取决于你当时接入的是哪台设备。调用方不需要关心设备内部怎么响应设备自己知道该怎么做。Java 多态的优雅之处就在这里它让差异从调用方内部转移到了被调用对象内部代码的可扩展性和可维护性完全不在一个量级。2. 多态背后那台运行期引擎动态绑定与虚方法表2.1 三个前提条件缺一不可Java 的多态要成立有三个前提条件缺一个都不行。我把它们整理成一张表方便对照前提条件说明违反时的后果继承或实现关系子类继承父类或实现类实现接口没有关系就无法向上转型引用类型对不上方法重写子类必须重写父类/接口的方法调用父类版本行为不会有差异父类引用指向子类对象声明类型是父类/接口实际对象是子类直接 new 子类并赋值给子类引用多态无从谈起很多人在写代码时会忽略第一个前提或者把继承和多态混为一谈。继承只是提供了一种可能真正让多态成立的是重写这个动作。如果子类没有重写父类方法那父类引用调用到的永远是父类自己的实现谈不上不同行为。2.2 编译看左边运行看右边在学习多态的时候有一句口诀非常实用编译看左边运行看右边。左边指的是引用变量的声明类型右边指的是实际 new 出来的对象类型。Java 编译器在编译阶段只会根据引用变量的声明类型来检查方法是否存在、参数是否匹配而到了运行阶段JVM 才会根据对象的真实类型来决定到底调用哪个方法。举个例子假设Animal只有一个speak()方法Dog新增了一个fetch()方法Animal a new Dog(); a.speak(); // 编译通过运行时执行 Dog.speak() a.fetch(); // 编译报错Animal 类型中找不到 fetch()第二行注释里的那个编译错误很多初学者不理解我明明 new 的是 DogDog 明明有 fetch 方法为什么不让我调这就是编译看左边的规则编译器只认a的声明类型AnimalAnimal没有fetch直接拒绝。那运行时又怎么做到看右边呢这就是动态绑定的功劳。方法调用指令在字节码层面并不会写死调用哪个具体类的方法而是留下一个符号引用到了运行时再解析。2.3 虚方法表与 invokevirtual一次多态方法调用的完整旅程要理解运行期的动态绑定就必须引入 JVM 里一个重要的数据结构——虚方法表。Java 中的大部分非私有、非 final、非 static 方法在 JVM 里都被归类为虚方法。每个类在类加载阶段都会生成一张虚方法表这张表里记录了类自身声明的方法以及从父类继承下来的方法每一项都指向方法的实际入口地址。还是拿前面的Animal、Dog、Cat举例三个类的方法表大概长这样Animal 的方法表 speak() - Animal.speak() eat() - Animal.eat() Dog 的方法表 speak() - Animal.speak()未重写指向父类实现 eat() - Dog.eat()已重写指向子类实现 Cat 的方法表 speak() - Animal.speak()未重写指向父类实现 eat() - Cat.eat()已重写指向子类实现当 JVM 执行a.eat()这条字节码时用的指令是invokevirtual。完整的执行流程大概是从操作数栈拿到a这个引用顺着引用找到堆上的实际对象。读取对象的类型信息确定它属于Dog类而不是Animal类。去Dog类的虚方法表里查找eat()对应的入口地址。找到后跳转执行所以最终跑的是Dog自己的eat()。这就是为什么代码里写的是Animal a运行起来却调用到了Dog的方法。虚方法表是 JVM 实现动态绑定的关键数据结构理解了这个你就不会再觉得多态是什么玄学。提示方法表查找是 JVM 的一种经典实现方式HotSpot 实际还做了内联缓存等优化这里讲的是最核心的原理。对于理解多态来说虚方法表这个模型已经完全够用不管是面试还是日常排查问题。3. 重写与重载它们共享一个方法名走的是两条完全不同的路线3.1 重写是运行时行为重载是编译期行为很多教材把重写Override和重载Overload都归入多态的范畴这个说法在学术上有争论但面试时如果你把两者混为一谈通常会被追问得很惨。准确一点讲重写是运行时多态重载是编译期多态也叫静态分派两者机制完全不同。重写的核心特征是方法签名完全一致子类重新实现父类方法运行期由 JVM 根据对象真实类型动态绑定。Java 5 之后还支持协变返回类型子类重写时可以返回父类方法返回类型的子类型这本身也是对多态的一种延伸。重载则是同一个类里有多个同名方法参数列表不同。编译器在编译阶段根据方法调用时传入的参数类型、数量、顺序静态地确定要调用的是哪一个重载版本。一旦编译完成这个选择就固定了运行期不会再变化。我用表格帮大家快速区分对比维度重写Override重载Overload发生位置父子类之间同一个类内部方法签名必须完全一致参数列表必须不同返回类型可以协变可以不同仅靠返回类型区分不行关键词Override无强制注解绑定时机运行期动态绑定编译期静态分派实际决定者对象的真实类型引用变量的编译期类型3.2 重载版的编译期绑定一个容易翻车的面试题重载的一个经典面试场景能直接检验你有没有真正理解编译期选择的含义。看看这段代码public class Printer { public void print(Animal animal) { System.out.println(animal); } public void print(Dog dog) { System.out.println(dog); } }然后这样调用Animal animal new Dog(); Printer printer new Printer(); printer.print(animal);很多人会脱口而出应该输出dog理由很朴素animal 真实类型不是 Dog 吗但实际输出是animal。原因在于重载的方法选择发生在编译期。编译器看到printer.print(animal)时animal的编译期类型也就是声明类型是Animal所以它只在参数类型是Animal的那个重载版本里做匹配最终选择了print(Animal)。到了运行期虽然invokevirtual会根据真实类型做动态绑定但它绑定的只是已经被编译期选好的那个方法签名——print(Animal)而不是在print(Animal)和print(Dog)之间重新选择。这个例子非常值得自己敲一遍。它能帮你厘清一个关键认知重载选择的是方法签名重写决定的是具体实现。签名在编译期锁定实现可以在运行期动态切换。3.3 父类构造器里调用重写方法一生中最容易翻车的时刻前面讲的都是正常的多态行为接下来这个坑是连很多写了多年 Java 的人都会中招的。看代码public class Parent { public Parent() { print(); } public void print() { System.out.println(Parent print); } } public class Child extends Parent { private String value child value; public Child() { } Override public void print() { System.out.println(value); } }现在执行new Child()你猜输出什么很多人会猜child value因为动态绑定会让Parent构造器里调用的print()最终走到Child.print()。但实际输出是null。原因要结合对象初始化顺序来理解。new Child()的执行顺序是这样的在堆上分配内存初始化对象头此时虚方法表已经就绪。调用Child构造器。Child构造器首行隐式调用super()进入Parent构造器。Parent构造器执行print()动态绑定生效实际调用的是Child.print()。但此刻Child的value字段还处于默认值阶段null因为子类实例变量的赋值语句要到父类构造器返回之后才轮到。所以Child.print()把null打印了出来。这个现象在不少正式项目里引发过严重的空指针问题。我的建议是构造器里不要调用任何可能被重写的方法。如果确实有初始化逻辑需要可以把调用的方法声明为final或private切断动态绑定的可能或者把初始化逻辑放到子类构造器里去完成。4. 面向接口、模板方法和策略表多态在工程中的三种漂亮用法4.1 从硬编码 if-else到渠道注册表重构理解了原理我们聊聊真正在工程里怎么用。最典型的一个场景就是处理消息通知。早期项目里最常见的写法是public void notify(String channel, String message) { if (email.equals(channel)) { // 发送邮件 } else if (sms.equals(channel)) { // 发送短信 } else if (dingtalk.equals(channel)) { // 发送钉钉消息 } }这个方法和第 1 节的feed方法如出一辙。新增一个通知渠道就要改一遍这个方法的代码。如果通知渠道有十几个这个方法的复杂度会让人崩溃。用多态重构后先定义统一接口public interface Notifier { void send(String message); }再让各个渠道实现这个接口public class EmailNotifier implements Notifier { Override public void send(String message) { // 调用邮件网关 } } public class SmsNotifier implements Notifier { Override public void send(String message) { // 调用短信网关 } }然后在 Spring 项目里把所有Notifier按渠道名组装成一个 Mapprivate final MapString, Notifier notifierMap; public NotificationService(MapString, Notifier notifierMap) { this.notifierMap notifierMap; } public void notify(String channel, String message) { Notifier notifier notifierMap.get(channel); if (notifier ! null) { notifier.send(message); } }从这里开始新增一个渠道只需两步写一个实现类在 Spring 容器里注册为 Bean。NotificationService和notify方法的代码一行都不用改。这就是多态在实际工程里最重要的用法之一用注册表 策略对象替代不断膨胀的条件分支。在面向对象设计里这种模式叫策略模式它的底层支柱就是多态。4.2 模板方法父类定流程骨架子类填血肉多态的另一个经典应用是模板方法模式。它解决的是这样一个问题一批业务操作的流程骨架完全一致但细节步骤各有差异。拿数据导出举例不管导出什么格式流程都是准备数据、生成表头、写文件三步。于是我们可以把骨架写在父类里public abstract class DataExporter { // 模板方法定义流程骨架 public final void export() { ListString data buildData(); String header buildHeader(); writeFile(header, data); } protected abstract ListString buildData(); protected abstract String buildHeader(); protected abstract void writeFile(String header, ListString data); }子类各自实现差异部分public class ExcelExporter extends DataExporter { Override protected ListString buildData() { // 组装 Excel 数据 } Override protected String buildHeader() { return 列1,列2,列3; } Override protected void writeFile(String header, ListString data) { // 写 .xlsx 文件 } }调用方永远只依赖DataExporter这个抽象类型完全不用关心具体导出逻辑。以后加 PDF 导出、CSV 导出只需要新增子类。这种父类指挥、子类执行的协作方式正是多态在复杂度管理上的价值体现。4.3 多态在企业级框架里的身影依赖倒置与接口抽象如果你用过 Spring、MyBatis 这类框架会发现它们的设计里到处都有多态的痕迹。Spring 的BeanFactory返回的是Object真正使用的时候要靠多态向下转型成具体类型MyBatis 的SqlSession提供一堆接口底层的DefaultSqlSession是对接口的实现。这些框架之所以能做到面向接口编程本质都是多态在起作用。再往深一层说多态还是依赖倒置原则的前提。依赖倒置原则要求高层模块不应该依赖低层模块两者都应该依赖抽象。换句话说业务层应该依赖一个抽象接口而不是直接依赖某个具体的数据库实现类。这样才能在不改动业务层代码的情况下替换底层实现。举个例子public interface UserRepository { User findById(Long id); } public class MysqlUserRepository implements UserRepository { // MySQL 实现 } public class RedisUserRepository implements UserRepository { // Redis 缓存实现 }业务层写UserRepository运行期注入MysqlUserRepository或RedisUserRepository。哪天要从 MySQL 换成 PostgreSQL只需要新增一个PostgresUserRepository业务层代码不动。这种接口负责定义能力、实现类负责具体细节的分工没有多态做不到。5. 多态的闸门与暗礁静态方法、成员变量和向下转型5.1 静态方法没有多态只有隐藏很多人在理解了多态之后会误以为所有方法都有动态绑定行为这是很大的一个误区。Java 的静态方法是属于类的不属于对象它不具备多态特性。看这段代码public class Parent { public static void whoami() { System.out.println(Parent); } } public class Child extends Parent { public static void whoami() { System.out.println(Child); } }调用Parent p new Child(); p.whoami();实际输出是Parent。即使p的真实类型是Child静态方法的调用在编译期就已经确定了——编译器根据引用变量的声明类型Parent直接把调用解析为Parent.whoami()。这在 Java 术语里叫隐藏hiding和重写override有本质区别。写代码时如果子类定义了和父类签名相同的静态方法建议不要这么做这会让阅读代码的人产生歧义。类似的还有私有方法因为子类无法访问和重写父类的私有方法所以它天然就是静态绑定。5.2 成员变量不看对象只看引用类型这个坑比静态方法更隐蔽。方法的调用走了动态绑定但成员变量的访问走的是静态绑定。看这段代码public class Parent { public int count 1; } public class Child extends Parent { public int count 2; }执行Parent p new Child(); System.out.println(p.count); // 输出 1 System.out.println(((Child) p).count); // 输出 2声明类型是Parent时p.count访问的是Parent里的count转成Child后访问的才是Child里的count。这和方法的动态绑定完全不同。原因很简单成员变量的解析在编译期完成Class 文件里的字段访问指令getfield指向的是具体类里的字段运行期不会因为对象真实类型而重新选择。所以实际开发中我强烈不建议在子类里声明和父类同名的成员变量。这种遮蔽shadowing行为几乎不会带来任何好处只会制造混乱。5.3 向下转型一定要配 instanceof但别滥用前面讲了向上转型是安全的因为子类必然是父类的一种。但反过来把父类引用强制转成子类引用向下转型就可能出现ClassCastException。Animal a new Dog(); Cat c (Cat) a; // 运行期直接报 ClassCastException因为a的真实类型是Dog把它当成Cat来用JVM 会毫不留情地拒绝。安全的写法是在转型前判断if (a instanceof Cat cat) { cat.catchMouse(); }Java 16 之后instanceof支持模式匹配判断的同时就能直接拿到转型后的变量代码简洁了很多。但我还想多提醒一句**向下转型使用频繁往往意味着设计上有问题。**如果你发现自己经常要把接口类型往下转成具体子类才能调用某个方法那大概率是接口抽象得不够。正确做法是把那个方法也抽象到接口里让多态去处理而不是在调用方反复做类型判断。5.4 泛型擦除与桥方法多态在编译器里打过的一场暗战最后一个进阶话题和泛型结合起来看。Java 的泛型是伪泛型类型信息在编译后会被擦除。这带来一个有意思的问题当子类重写泛型父类的方法时擦除前后的方法签名对不上多态还成立吗看代码public class ParentT { public T get() { return null; } } public class Child extends ParentString { Override public String get() { return abc; } }父类擦除后Parent.get()的签名是Object get()而子类重写后的签名是String get()。从虚拟机的角度看这两个方法签名并不一致按理说子类没有真正重写父类方法。为了解决这个问题编译器在生成Child类时自动添加了一个桥方法bridge method签名为Object get()方法体里调用真正的那版String get()。这样从父类视角看多态依然成立——Parent p new Child()后调用p.get()最终会通过桥方法转到子类真正的实现。这个细节平时根本不会遇到因为一切都是编译器在暗中处理。但如果你去深入阅读某些框架源码或者做字节码分析就会看到这些桥方法的身影。这也是多态在 Java 语言层面表里不一的一个有趣角落。写到这里多态的方方面面基本都过了一遍。我个人在实际工作中最大的体会是多态不是一种可以显式调用的语法而是一种设计思想的落地。它把变化封装在对象内部让调用方可以稳定地依赖抽象也让我在维护一段老代码时能通过策略表和接口抽象快速定位问题所在。如果你现在还在写大段 if-else 分类型的逻辑不妨停下来想想这些分支能不能抽象成接口交给多态去处理很多时候代码质量的提升就是从这样一个小的重构开始的。
返回列表