为了更加深入的理解方法的覆盖和覆写原理需要了解java方法的调用原理
首先解释一下方法调用:
方法调用不等同于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本(即确定具体调用那一个方法),不涉及方法内部具体运行。
java虚拟机中提供了5条方法调用的字节码指令:
invokestatic:调用静态方法
invokespecial:调用实例构造器<init>方法、私有方法、父类方法
invokevirtual:调用所有虚方法。
invokeinterface:调用接口方法,在运行时再确定一个实现该接口的对象
invokedynamic:运行时动态解析出调用的方法,然后去执行该方法。
方法调编译成Class字节码后的状态:
Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里存储的都只是符号引用,而不是具体的方法执行入口地址(即直接引用)。
所以对于java的方法调用过程就变的复杂起来,需要在类加载期间,甚至是运行期间才能确定目标方法的直接调用。
关于具体的类加载机制可以参考JVM类加载机制
1、解析阶段
Class文件里所有的方法调用都是一个常量池中的符号引用,在类的解析阶段,会将其中一部分符号引用转化为直接引用。转化的这部分方法调用必须是:在程序运行之前就有一个可以确定的调用版本,并且这个调用版本在程序运行期间是不可改变的。
即:编译期可知,运行期不变的方法调用
这种类型的方法主要有:静态方法、私有方法。
只要能被invokestatic和invokespecial指令调用的方法都可以在解析阶段中确定唯一调用版本。符合这个条件主要有:静态方法、私有方法、实例构造器方法、父类方法。他们在类加载的时候就会把符号引用解析为该方法的直接引用。
2、分派
就是如何确定执行哪个方法,这里详细解释了重载和重写。
静态分派
首先看一个重载的例子
public class Main2 {
class A{
}
class B extends A{
}
public static void f(A a) {
System.out.println("A");
}
public static void f(B b) {
System.out.println("B");
}
public static void main(String[] args) {
A a = new Main2().new B();
f(a);
}
}
输出:A
这里将A称为静态类型或者外观类型,B称为实际类型。
编译器在运行前只知道一个对象的静态类型,并不知道对象的实际类型。
f方法经过了重载,有两个不同的参数,虚拟机方法调用时,他会直接使用静态类型进行匹配。也就是说:重载时是通过参数的静态类型而不是实际类型作为判定依据。并且静态类型是在编译期可知的,因此在编译阶段,javac编译器就可以根据参数类型确定具体使用哪个重载版本。
- 所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。
- 静态分派的典型应用就是方法重载。
- 静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
动态分派
这个分派体现了重写
主要和invokevirtual方法调用字节码有关,运行过程如下:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记为C;
- 在C中寻找与常量中的描述符合简单名都一致的方法,进行权限校验,如通过则返回这个方法的直接引用,查找结束;如果不通过,返回异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步搜索和验证。
- 如果始终没有找到合适的方法,则抛出异常。
虚拟机动态分派的实现
最常用的手段就是在方法区中建立一个虚方法表,如果一个方法在子类中没有被重写,那么子类的虚方法表里的地址入口就和父类对应方法地址入口一致。
即:每个对象都建立一个如上图的方法虚方法表,表中列出每个对象的所有方法,包括继承的方法,如果重写了对应的方法,则对应的地址就是重写方法的地址,如果没有重写就是原来的方法地址。