Java虚拟机在执行方法调用时,并不是简单地按照代码书写顺序去找一个同名函数,而是要通过一套被称为分派(Dispatch)的机制,在多个可能的方法版本中确定真正该执行的那一个。分派的核心问题在于:当存在方法重载或方法重写时,虚拟机依据什么信息、在什么时候、通过哪条字节码指令来完成选择。要搞清楚分派指令如何选择方法,就必须把编译期能确定的静态分派和运行期才确定的动态分派分开来看。

静态分派:编译期按形参类型锁定重载方法
静态分派发生在编译阶段,它的本质是编译器在处理方法重载(Overload)时,根据方法的接收者以及参数的静态类型(又称声明类型、外观类型)来匹配最合适的方法签名。这里的关键词是静态类型,也就是变量在源代码中被声明时的类型,而不是它实际引用的对象类型。例如我们有一个父类 Animal 和子类 Cat,当写下 Animal a = new Cat() 时,a 的静态类型是 Animal,实际类型是 Cat,静态分派只看 Animal。
在生成的字节码中,静态分派的结果已经固定。编译器会直接把方法调用解析为指向某个具体的方法符号引用,例如 invokevirtual com.example.Test.print(Lcom/example/Animal;)V 中的参数描述符是在编译时就选定的。如果存在多个重载版本,编译器会按照最匹配原则(参数类型最近、不需要过多自动转换)来挑选。下面是一段展示静态分派现象的代码:
class Animal {}
class Cat extends Animal {}
class Dog extends Animal {}
public class Test {
public void print(Animal a) {
System.out.println("Animal");
}
public void print(Cat c) {
System.out.println("Cat");
}
public static void main(String[] args) {
Test t = new Test();
Animal a = new Cat();
t.print(a); // 输出 Animal,因为静态类型是 Animal
}
}
从上面例子可以看到,虽然 a 实际指向 Cat 对象,但 print 调用绑定的是 print(Animal) 而不是 print(Cat)。这是因为静态分派只看 a 的静态类型 Animal。这种机制的优点是高效、无运行期开销,缺点是缺乏灵活性,无法实现基于对象真实类型的分支逻辑。在复杂泛型或自动拆装箱场景中,静态分派还可能引发一些令人困惑的重载歧义,比如 print(Object) 与 print(int) 在传入 null 时的编译错误。
动态分派:运行期按实际类型实现多态重写
动态分派主要用来支持方法重写(Override)带来的多态行为。它的决策时间是在程序运行期间,依据调用者对象的实际类型(运行时类型)来决定执行哪个类的方法版本。在字节码层面,动态分派通常由 invokevirtual 指令完成。执行 invokevirtual 时,虚拟机并不会直接查符号引用对应的固定方法,而是先找到操作数栈顶的对象引用,再从这个对象所属类的虚方法表(vtable)中按照方法签名去查找真正要执行的方法入口。
虚方法表是Java虚拟机为了加速动态分派而设计的数据结构。每个类在加载后都会维护一张表,里面按固定顺序存放该类及其父类可重写方法的直接地址。子类重写父类方法时,会在自己的虚方法表里把对应槽位指向自己的实现。当执行 invokevirtual 时,虚拟机用固定的索引去查实际对象类的虚方法表,从而跳到正确版本。下面的代码展示了动态分派的效果:
class Animal {
public void sound() {
System.out.println("animal sound");
}
}
class Cat extends Animal {
public void sound() {
System.out.println("meow");
}
}
class Dog extends Animal {
public void sound() {
System.out.println("bark");
}
}
public class Test2 {
public static void main(String[] args) {
Animal a1 = new Cat();
Animal a2 = new Dog();
a1.sound(); // 输出 meow
a2.sound(); // 输出 bark
}
}
在上面的例子中,a1 和 a2 的静态类型都是 Animal,但运行期虚拟机会根据它们分别指向 Cat 和 Dog 的实际类型,在各自虚方法表中找到 sound 的正确实现,这就是动态分派。动态分派让同一段调用代码在不同对象上表现出不同行为,是面向对象多态的基石。它的代价是每次调用都需要一次查表或内联缓存判断,不过现代虚拟机通过方法内联、单态内联缓存等优化把大部分开销降到了很低。
需要注意,动态分派只针对实例方法重写,且 private、static、final 方法不能被重写,因此它们走的是静态绑定(invokespecial 或 invokestatic),不属于动态分派范畴。如果父类的某方法是 final,子类无法覆盖,虚拟机在编译期就能确定唯一版本,省去了运行期查表。
分派指令的协作与典型误区分析
在真实的Java方法调用中,静态分派和动态分派常常叠加出现。比如一个子类重写父类方法,同时又有基于参数的重载,这时编译器先用静态分派选重载签名,运行期再用动态分派选重写版本。以 test.print(Animal) 为例,如果 print 在父类和子类中都有不同实现,那么编译期决定调用的是参数类型为 Animal 的那个重载,运行期再决定执行父类还是子类的 print 主体。这种双层选择容易让初学者误以为重载也是运行期多态,其实重载完全由静态分派在编译期定死。
一个常见误区是认为把变量强转就能改变动态分派结果。实际上,强制类型转换(cast)只改变静态类型,让编译器选不同重载,但对象实际类型没变,动态分派依旧按原实际类型走。例如 ((Animal) cat).sound() 与 cat.sound() 在 Cat 重写 sound 时输出完全一致。另一个误区是依赖 null 做重载区分,由于 null 没有实际类型,静态分派会按最具体的可接收类型匹配,若多个重载都能接收 null,编译就会报歧义错误,这同样是静态分派规则导致的。
从指令角度看,invokestatic 用于静态方法绑定,invokespecial 用于构造器、私有方法和父类方法,这两者都不涉及动态分派;只有 invokevirtual 和 invokeinterface 才会触发基于实际类型的动态分派。理解这些指令的分工,就能明白为什么有时改了子类实现却没生效(可能方法被标成 static 走了 invokestatic),也能在排查性能问题时意识到频繁接口调用比类方法调用多一层查表成本。