方法调用阶段唯一的任务就是确定被调用方法的版本(调用的是哪一个方法),暂时还不涉及方法内部的具体运行过程。Class文件的编译过程中 不包含传统编译过程中的“连接”,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址。这给java带来更强的动态扩展功能的同时,也使用java方法的调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
1.解析
在类加载的解析阶段,会将一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可变得(调用目标在程序代码写好、编译器进行编译时就必须确定下来)这类方法的调用称为解析。
在java语言中符合“编译器可知,运行期不可变”的方法只有类方法和私有方法,因此他们都适用在类加载阶段进行解析。
与之对应的是,java虚拟机里面提供了五条方法调用字节码指令。
invokestatic | 调用静态方法 |
invokespecial | 调用实例构造器、私有方法和父类方法 |
invokevirtual | 调用所有的虚方法(可以被覆写的方法都可以称作虚方法,因此虚方法并不需要做特殊的声明,也可以理解为除了用static、final、private修饰之外的所有方法都是虚方法。)注意:虽然final是用此指令调用,但并不是虚方法 |
invokeinterface | 调用接口方法,会在运行时再确定一个实现此接口的对象 |
invokedynamic | 先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在java虚拟机内部的,而invokedynamic的分派逻辑是由用户所设定的引导方法决定的 |
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,静态方法、实例构造器、私有方法和父类方法这四类在类加载的时候就会把符号引用解析为直接引用。这些方法可以被称为非虚方法。
public class StaticResolution{ public static void sayHello(){ System.out.println("hello world"); } public static void main(String[] args){ StaticResolution.sayHello(); } }
查看这段程序的字节码
发现的确是通过invokestatic命令调用的sayhello();
解析调用是静态的过程,在编译期间就完全确定下来,在类加载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派调用则可能是静态的也可能是动态的,根据分派的宗量数可分为单分派和多分派。
2.分派
可以通过分派调用过程揭示多态性特征的一些最基本的体现,如“重载”和“重写”在java虚拟机中时如何实现的
①静态分派
public class A{ static abstract class Human{} static class Man extends Human{} static class Woman extends Human{} public void sayHello(Human guy){ System.out.println("hello guy"); } public void sayHello(Human guy){ System.out.println("hello guy"); } public void sayHello(Human guy){ System.out.println("hello guy"); } public static void main(String[] args){ Human man=new Man(); Human women=new Woman(); A a=new A(); a.sayHello(man); a.sayHello(women); } }
输出结果是
hello guy hello guy
关于重载方法的使用,在方法接受者已经确定是对象“sr”的情况下,使用哪个重载版本,完全取决于传入参数的数量和数据类型。代码中可以地定义了两个编译期类型相同但运行期类型不同的变量,但编译器在重载时是通过参数的编译期类型而不是运行期类型作为判定依据的,并且编译期数据在编译期可知的,因此,在编译阶段,javac编译期会根据传入参数的编译期类型决定使用哪个重载版本。
所有依赖编译期类型来定位方法执行版本的分派动态称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译期虽然能确定出方法的重载版本,但是在很多情况下这个重载版本并不是“唯一的”,往往是只能确定一个“更加合适的”版本,我们知道编写代码最重要的就是不能让计算机糊涂,而这种模糊的概念很少见,这是因为字面量是不需要定义的,所以字面量没有显示的编译期类型,它的编译期类型只能通过语言上的规则去理解和推断。
public class Overload{ public static void sayHello(Object arg){ System.out.println("hello Object"); } public static void sayHello(int arg){ System.out.println("hello int "); } public static void sayHello(long arg){ System.out.println("hello long"); } public static void sayHello(Character arg){ System.out.println("hello Character"); } public static void sayHello(char arg){ System.out.println("hello char"); } public static void sayHello(char... arg){ System.out.println("hello char..."); } public static void sayHello(Serializeable arg){ System.out.println("hello Serializeable "); } public static void main(String[] args){ sayHello(‘a‘); } }
输出为
hello char
很好理解,因为‘a‘是char类型的数据,系统自然会选择参数类型为char的重载方法,如果把char类型的重载方法去掉。
会输出
hello int
这时发生了一次自动类型转换,‘a’除了可以代表一个字符串,还可以代表数字97,因此参数类型为int的重载也是合适的,继续去掉int类型的方法
输出
hello long
发生了两次自动类型转换,char->int->long,实际上还可以继续进行char->int->long->float->double,但是代码中没有float和double的重载方法,去掉long的方法
会输出
hello Character
这是发生了一次自动装箱。去掉Character类型。
输出
hello Serializable
这是因为Character实现了Serializable,自动装箱以后找不到装箱类,只能向父类或接口转型,这是如果Character还实现了一个接口,并且这个接口的重载类型也在上面代码中,编译器会糊涂,拒绝编译。这时程序必须显示地指定字面量的编译器类型,再去掉这个方法
hello Object
这时是char装箱后转型为父类了,依次往上搜索。如果把这个也去掉
输出就变成
hello char...
只剩下了这一个sayhello(char... org),说明变长参数的重载优先级是最低的。
这个例子演示了编译期间选择静态分派目标的过程,这个过程也是java语言实现方法重载的本质。
注意:解析和分派之间并不是互斥的,而是是在不同层次上去筛选、确定目标方法的过程。例如:类方法在类加载的时候进行解析,但是类方法也会有重载版本,选择重载版本的过程也是通过静态分派完成的。
②动态分派
动态分派与重写有着密切的关联。
public class A{ static abstract class Human{ protected abstract void sayHello(); } static class Man extends Human{ protected void sayHello(){ System.out.println("hello man"); } } static class Woman extends Human{ protected void sayHello(){ System.out.println("hello woman"); } } public static void main(String[] args){ Human man=new Man(); Human women=new Woman(); man.sayHello(); women.sayHello(); man=new Woman(); man.sayHello(); } }