理解父类引用指向子类对象的执行机制,首先要放弃一个直觉:方法调用并不是在编译期就完全确定到具体代码地址。编写代码时,我们看到的引用类型只是编译器用来做合法性检查的依据,真正决定执行哪一段方法体的信息,保存在对象自己身上。以Java为例,Animal animal = new Dog(); 中,animal 的静态类型是 Animal,实际类型是 Dog。当代码执行到 animal.makeSound(); 时,编译阶段只确认 Animal 中有 makeSound 方法,而不会直接把调用绑定到 Animal.makeSound 的实现。

这个延迟到运行期才确定目标方法的过程,就是动态绑定。它不是语法层面的特殊处理,而是虚拟机指令分派的结果。下面从编译期规则、虚拟机分派过程和常见混淆点三个角度展开。
一、编译期检查与运行期绑定的分工
编译器面对 animal.makeSound() 时,只使用引用变量的静态类型 Animal。如果 Animal 类没有声明 makeSound 方法,即便实际对象 Dog 中定义了该方法,编译也会直接报错。这个机制保证了类型安全,也解释了为什么父类引用只能调用父类中已经声明过的成员。
运行期的情况则不同。JVM在执行方法调用指令时,首先会从操作数栈中取出调用方法的对象引用,而不是从常量池中直接解析出固定方法。对象的堆内存头部保存着指向其实际类元数据的指针,虚拟机通过这个指针找到 Dog 类的方法表。方法表里记录了该类重写后的方法入口。如果 Dog 重写了 makeSound,那么方法表中对应槽位指向的就是 Dog 的版本;如果没有重写,该槽位会沿用父类 Animal 的方法入口。
用一段简单代码可以验证动态绑定。父类 Animal 定义 makeSound,子类 Dog 和 Cat 分别重写。通过同一个父类引用数组遍历调用,输出的行为会随实际类型变化。
class Animal {
public void makeSound() {
System.out.println("Animal makes sound");
}
}
class Dog extends Animal {
@Override
public void makeSound() {
System.out.println("Dog barks");
}
}
class Cat extends Animal {
@Override
public void makeSound() {
System.out.println("Cat meows");
}
}
public class PolymorphismDemo {
public static void main(String[] args) {
Animal[] animals = {new Dog(), new Cat(), new Animal()};
for (Animal animal : animals) {
animal.makeSound();
}
}
}
运行结果是 Dog barks、Cat meows 和 Animal makes sound。循环变量 animal 的类型始终是 Animal,但每次调用都进入了实际对象对应的实现。如果方法调用在编译期就固定到 Animal,结果会全部输出 Animal makes sound。这正是父类引用指向子类对象时最核心的执行差异。
二、虚拟机如何完成动态分派
Java虚拟机规范对方法调用定义了多条指令,其中 invokevirtual 是实例方法动态分派的关键。它的执行逻辑比静态调用复杂一些:先弹出操作数栈顶的对象引用,判断该引用是否为 null;如果是空引用则抛出 NullPointerException。接着根据对象的实际类型查找方法。查找过程不是扫描整个类,而是使用类加载阶段就已经构建好的方法表。方法表是一个按方法签名排序的数组,子类重写父类方法时,会替换同一槽位中的方法指针。
如果实际类型的方法表中没有找到目标方法,虚拟机会沿着继承链向父类查找,直到找到并调用。如果一直到 Object 都没有匹配的方法,会抛出 AbstractMethodError,不过这种情况通常只出现在类版本不兼容或字节码被篡改时。
与 invokevirtual 不同,静态方法、私有方法和构造方法使用 invokestatic 或 invokespecial 指令,它们在编译期就能确定目标符号引用,不参与运行期动态分派。字段访问也遵循这条静态规则。下面代码演示字段隐藏:
class Parent {
public String value = "parent";
}
class Child extends Parent {
public String value = "child";
}
public class FieldHiddenDemo {
public static void main(String[] args) {
Parent obj = new Child();
System.out.println(obj.value);
}
}
输出是 parent,而不是 child。因为字段访问在编译期根据引用类型 Parent 解析,子类中的 value 只是隐藏了父类字段,并没有覆盖。这种字段隐藏和方法重写的行为差异经常造成困惑。把方法调用和字段访问放在一起理解,可以更清晰地看到动态绑定只适用于实例方法,不适用于字段、静态方法和私有方法。
三、重写、重载与静态方法隐藏的边界
很多人把多态等同于所有同名方法的运行期选择,实际上重载和重写遵循完全不同的规则。重载是在同一个类中定义多个同名方法,参数列表不同,编译器根据传入参数的静态类型选择具体签名。它属于静态绑定,调用结果在编译期就已经确定。重写则是子类提供与父类方法签名相同、返回类型兼容的实现,实例方法通过动态绑定在运行期选择。
一个典型误区是把父类引用指向子类对象后,认为重载方法也能根据实际类型选择。比如父类有 public void test(Animal a),子类新增 public void test(Dog d),当调用 parent.test(dog) 时,如果 parent 的静态类型是 Animal 引用,实际类型是 Dog 引用,编译期只能看到 Animal 中声明的版本,因此会调用父类的重载方法,而不会自动选择子类新增的 test(Dog d)。除非把 parent 声明为子类类型,或者使用双重分派等设计。
静态方法隐藏也容易和重写混淆。如果父类和子类都定义了签名相同的静态方法,通过父类引用调用时,执行的仍然是父类版本。因为静态方法属于类,不属于对象,调用指令 invokestatic 在编译期就把方法符号引用写入了常量池。了解这些边界后,再看多态的表述会更准确:父类引用指向子类对象时,只有被重写的实例方法会动态分派到子类实现,其他成员仍然以引用类型为准。
四、多态执行机制在代码设计中的落地
理解动态绑定不只为了解释面试题,更重要的是指导日常设计。当代码大量使用父类或接口引用传递对象时,系统的扩展点就自然集中在子类重写的方法上。比如定义支付接口 PaymentService,支付宝和微信分别实现 pay 方法,上层业务通过接口引用调用,新增支付渠道时无需修改调用方。这正是依赖倒置原则的体现,也依赖运行期动态分派才能生效。
但多态并不是万能钥匙。如果一段逻辑严重依赖具体子类的独有方法,就需要把引用向下转型,这通常意味着抽象层次设计得不够合理。频繁使用 instanceof 判断实际类型再分别处理,会把动态绑定带来的扩展性抵消掉。好的做法是把行为差异封装在重写方法内部,让调用方只依赖父类或接口契约。
性能方面,动态绑定虽然需要经过方法表查找,但现代JVM会通过内联缓存、逃逸分析等手段优化。绝大多数场景下,多态调用的开销可以忽略不计。真正需要关注的是设计清晰度:如果父类引用调用一个方法时连开发者自己都无法确定执行哪个版本,说明继承层次或命名可能已经混乱。掌握编译期与运行期的分工,能帮助我们写出更容易推理和维护的多态代码。