方法调用
方法调用并不等同于方法中的代码被执行,而是确定被调用方法的版本(哪一个方法要被执行),而并不涉及到方法内部的具体执行过程。
由于java中,方法调用在class文件中存储的都是符号引用,而不是实际运行时方法的直接引用(即方法的入口地址),这使得java具有较强大的动态扩展能力,但是也使得方法调用变得复杂。
有些方法在类加载的时候就可以唯一确定方法版本,而有些方法要等到运行期间才能确定方法的直接引用。因此,有解析和分派两种方式来分别确定方法版本。
解析
所有方法调用的目标方法在class文件中都是一个常量池中的符号引用。
类加载阶段
对于其中一部分可以确定版本且在运行期不会发生变化(“编译期可知,运行期不可变”)的方法,会将符号引用转化成直接引用。
静态解析
符合这个要求的方法有(静态方法、私有方法、实例构造器、父类方法、被final修饰的方法)。它们要么属于类型,要么在外部不可被访问,要么不能被覆盖,总之,都不可能有其他的版本。
从字节码指令的角度来看,jvm支持5种方法调用字节码指令
invokestatic
invokespecial
invokevirtual
invokeinterface
invokedynamic
1和2以及加上被final修饰的方法(它使用invokevirtual调用)都可以在类加载阶段完成解析。
分派
方法分派是多态基本性质的体现。具体而言,方法的重载(同名,但方法的参数类型或数量不同)依赖于静态分派。
方法的重写(同名,参数类型相同,但方法的版本依赖于方法接收者的实际类型)依赖于动态分派。
静态分派
静态分派是指依赖静态类型来确定方法执行版本的动作。典型的静态分派就是方法重载。
public class StaticDispatch {
public void hello(Human human) {
System.out.println("hey,human");
}
public void hello(Woman woman) {
System.out.println("hello,lady");
}
public void hello(Man man) {
System.out.println("hello, gentleman");
}
public static void main(String[] args) {
StaticDispatch staticDispatch = new StaticDispatch();
Human woman= new Woman ();
Human man = new Man();
staticDispatch.hello(man);
staticDispatch.hello(woman);
}
}
abstract class Human {
}
class Man extends Human {
}
class Woman extends Human {
}
public class DynamicDispatch {
psvm(){
}
}
在这个例子中 ,输出都属hey,guy。 其原因是woman和man两个变量的静态类型都是Human。
Human man = new Man()
其中Human成为变量的静态类型, 而Man则是变量的实际类型。对于方法重载,根据参数的静态类型来确定方法版本。因此选择了sayHello(Human human)这个方法版本。
静态分派实际上发生在编译阶段,在完成方法调用时,参数的类型和数量已经在程序中写好了。因此,也有人将它归为解析。
动态分派
abstract class Human {
public abstract boolean canBearAChild();
class Man extends Human {
public boolean canBearAChild(){return false;}
}
class Woman extends Human {
public boolean canBearAChild(){return true;}
}
public static void main(String[] args) {
Human man = new Man();
System.out.println(man.canBearAChild());
Human woman= new Woman();
System.out.println(woman.canBearAChild());
}
对于动态分派,虚拟机是如何确定方法版本的呢?
它将局部变量表中的引用压入操作数栈(如man),将它作为方法的接收者。
虚拟机根据这个引用去找到它所指向对象的实际类型,接下来在该类型中找到对应的方法;如果找不到,就去父类找,直到Object类都找不到的话就报异常。
因此,重写的本质就是,先在常量池中找到方法的符号引用,然后再运行时根据接收者(这个例子中的man/woman所指向的对象)的实际类型选择方法执行版本。
值得注意的是,字段是没有多态性的。(不会存在两个相同的字段)
即:如果子类中定义了和父类一样的字段,子类的内存中两个字段都会存在,但是子类的字段会覆盖掉父类的字段; 如果子类没有声明,那么就从父类继承下来。
虚拟机动态分派的实现原理是虚方法表。虚方法表存放着方法的实际入口地址,如果子类中没有重写,那么子类和父类该方法的地址入口是一样的;如果重写了,那么子类虚方法表的地址被替换成指向子类实现版本的方法入口地址。
虚方法表
public class AddA {
public static void main(String[] args) {
Father guy = new Son(30);
guy.saySomething();
System.out.println(guy.age);
}
}
class Father{
int age = 60;
public Father() {
saySomething();
}
public Father(int age) {
this.age = age;
}
public void saySomething(){
System.out.println("I am the father, " + age + "years old");
}
}
class Son extends Father{
int age = 20; // 把这行注释掉,看看结果,think why
//(注释掉之后age就是从Father继承下来的,不注释则子类对象中子类的age覆盖掉父类的age)。
public Son(int age) {
saySomething();
this.age = age;
saySomething();
}
public void saySomething(){
System.out.println("I am the son, " + age + " years old");
}
}