方法调用
一起方法在Class文件中存储的只是符号引用,而并不是直接引用。在类的加载的解析阶段只会让其中的一部分符号引用转为直接引用,为什么会出现这种差异呢?原因是在程序运行之前,有很多种因素使得我们不能确定哪些方法应该被正确调用。在运行时就能确定的且这个方法在运行期不可变的就可以立即转为直接引用。
首先看如下一个类
public class TestClass extends Object {
public TestClass() {}//构造方法
public static void staticMethod(){};//静态方法
public final void finalMethod(){}; //final方法
private void privateMethod(){}; //私有方法
public void method(){};//普通方法
public static void main(String[] args) {
TestClass test = new TestClass();
TestClass.staticMethod();
test.finalMethod();
test.privateMethod();
test.method();
test.toString();//执行父类Object中的方法
//类型为接口
Runnable runnable=new Runnable() {
@Override
public void run() {}
};
runnable.run();//执行接口方法,多态
}
}
反编译后,查看它的字节码指令:
其中的调用方法的指令可分为:
- invokespecial:调用构造方法、私有方法
- invokestatic:调用静态方法
- invokevirtual:调用虚方法
- invokeinterface:接口方法
- invokedynamic:用于动态方法
其中invokespecial和invokestatic有个共同特点就是他们调用的方法就是我们前面说的在编译器就已经确定谁是哪个对象调用的此方法,例如如果是public的普通方法,很有可能被子类重写,然后子类采用多态的方式赋值为父类型,编译阶段jvm并不能确定到底是调用子类或是父类的该名称的方法,但是像一些构造方法,和私有方法,静态方法,final修饰的方法都不能被重写就不会发生这种情况,这些方法在编译阶段就能完全的确定下来调用的版本,因此可以在解析阶段就转为直接引用了。
final比较特殊,它属于invokevirtual,但是它不能被重写,它包括在非虚方法里面
这种能在编译器阶段就转为直接引用的被称为非虚方法,其他的叫非虚方法。
根据两种方法调用的类别,可分为解析调用和分派调用,解析调用就是执行非虚方法,它在编译器把涉及到的符号全部转为明确的直接引用,不必延迟到运行阶段;分派调用用于在运行阶段执行虚方法。
分派调用
1.静态分派
先看如下一段代码:采用方法的重载机制。
abstract class Human {
}
class Man extends Human {
}
class Woman extends Human {
}
public class Test{
public static void who(Human hunam){
System.out.println("human");
}
public static void who(Man hunam){
System.out.println("man");
}
public static void who(Woman woman){
System.out.println("human");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
who(man);//human
}
}
可能你已经知道答案了,但是为什么是这样呢?请继续看
Human被称为变量的静态类型,后面的woman和man被称为实际类型,其中静态类型是在编译器可知的,实际类型要等到运行时才能确定。
首先调用方法时,在编译期肯定是根据静态类型来匹配的,所以重载方法时是通过静态类型而不是实际类型作为判断依据的,javac编译器根据变量的静态类型发现能够匹配参数为Human的方法,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。
这种依赖静态类型来决定之行版本的动作称为静态分派。
2.动态分派
再来看一段代码,属于是方法的重写机制:
abstract class Animal {
public abstract void eat();
}
class Dog extends Animal{
@Override
public void eat() { System.out.println("吃骨头");
}
}
class Cat extends Animal{
@Override
public void eat() {System.out.println("吃鱼");
}
}
public class Test{
public static void main(String[] args) {
Animal dog = new Dog();
Animal cat = new Cat();
dog.eat();//吃骨头
cat.eat();//吃鱼
}
}
此时我们调用方法的类型不可再根据静态类型来确定,只能根据实际类型来判断到底是执行哪个方法的版本。我们从它的反编译的指令里面需找答案:
关键的是这个invokevirtual方法,该指令的运行解析过程是如下几个步骤:
- 找到栈顶的第一个元素所指向的对象的实际类型,如cat、dog等
- 再看看实际类型中是否有和要调用的虚方法相符的方法,有的话就调用(还需权限验证才能调用)
- 否则的话从下往上对该实际类型的各个父类继续进行搜素,验证
- 没有找到的话就抛出AbstractMehodMethodError异常
invokevirtual第一步就是根据实际类型来选择方法的版本,这个过程就是方法重写的本质,我们把这种在运行期根据实际类型来确定方法版本的分派称为动态分派。invokevirtual是关键。
字段是不参与多态的,字段只跟静态类型有关!
动态分派选择方法的调用版本是在运行时,根据接收者的实际类型在它的元空间搜索,但是基于性能考虑,避免频繁且反复的搜索元数据,java建立了一个虚方法表(vtable)来优化,与之对应的invokeinterface也有接口方法表(itable),用方法表来代替消耗更大的元数据查找。
class Father {
public void eat(){
System.out.println("father吃东西");
}
public void sleep(){
System.out.println("睡觉");
}
}
class Son extends Father {
@Override
public void eat() {
System.out.println("son吃东西");
}
}
如上的两个类的方法表下图,方法表中存放着各个方法的实际入口地址,如果子类没有重写父类的方法,那么子类的方法表中该方法就指向的是父类的实现入口,如果重写了,就会指向子类中重写的方法的入口。
方法表一般在类加载的连接阶段进行初始化。