方法调用的概念

方法调用不等于方法中的代码被虚拟机执行,方法调用阶段的唯一任务就是确定方法的版本(即调用哪一个方法),暂时还没有涉及方法内部的具体运行过程。

在程序运行过程中,方法的调用是最频繁、最普遍的,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法实际运行时内存布局中的入口地址(即直接引用)。这个特性也给Java带来了更强大的动态拓展能力,但也使得Java方法调用过程变得相对复杂,因为某些方法的调用要在类加载的时候甚至到运行期间才能确定目标方法的直接引用。

方法调用一般分为两种调用方式:解析调用和分派调用
image.png

解析调用

前面提到,所有方法的调用在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用。当然,这些方法需要满足条件:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期间是不可改变的。也就是说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来,这类方法的调用就被称为解析调用。

注意: Java语言中,满足“编译期可知、运行期不可变”这个要求的方法主要有静态方法和私有方法,前者与类型直接关联,后者在外部不可被访问。这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。

调用不同类型的方法,字节码指令集里设计了不同的指令。在Java虚拟机中支持下面5中指令:

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器()方法、私有方法和父类中的方法
  • invokevirtual:调用所有的虚方法
  • invokeinterface:调用接口方法,会在运行时再确定一个实现该接口的对象
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法

从上面指令来看,只要是被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中唯一确定方法的版本。Java中下面的5种方法就是解析调用的:

  1. 静态方法
  2. 私有方法
  3. 实例构造器()方法
  4. 父类方法

    说明: 除了上面的4种方法,还有被final修饰的方法也是解析调用,但它是由invokevirtual指令处理

  5. 被final修饰的方法

    注意: 上面5中在解析阶段就可以把方法的符号引用转化为直接引用的方法被称为“非虚方法”,相反的,这5种方法之外的方法就被叫做“虚方法”。

解析调用一定是一个静态过程,在编译期就完全确定,在类加载的解析阶段就会把方法的直接引用转化为直接引用,不必再等到运行时期再去完成了。

分派调用

静态分派

  1. public class StaticDispatch {
  2. static abstract class Father {
  3. }
  4. static class Son extends Father {
  5. }
  6. static class Daughter extends Father {
  7. }
  8. public void sayHello(Father father) {
  9. System.out.println("Hello,i am father");
  10. }
  11. public void sayHello(Son son) {
  12. System.out.println("Hello,i am son");
  13. }
  14. public void sayHello(Daughter daughter) {
  15. System.out.println("Hello,i am daughter");
  16. }
  17. public static void main(String[] args) {
  18. Father daughter = new Daughter();
  19. Father son = new Son();
  20. daughter.method();
  21. son.method();
  22. }
  23. }

注意:

  1. 形如“Father daughter = new Daughter()”的代码,其中Father叫做daughter对象的“静态类型”或“外观类型”或“编译时类型”,而Daughter则是对象daughter的“实际类型”或“运行时类型”。
  2. 对象的“静态类型”在编译时期就已经由编译器确定下来了,是不会改变的;而对象的“实际类型”在编译器是无法由编译器得知的,他必须在运行时由jvm来确定

上述代码的测试结果:
image.png
可以看到,调用的方法都是“public static void sayHello(Father father)”方法,上面的3个方法互为重载方法,为什么调用的是这个方法?对于重载的方法版本的选择,其实虚拟机(或准确的来说是编译器)在重载时是通过参数的静态类型(而不是实际类型)和数量作为判断依据的。这就是为什么son和daughter对象尽管实际类型不相同,但由于它们的静态类型都是Father而导致上述的测试结果的原因。

所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派,静态分派的最典型应用表现就是方法重载。

注意: 准确来说,静态分派并不是由虚拟机进行的,而是早早的在编译阶段就由编译器就进行了静态分派,其原因就是前面提到的静态分派是根据对象的静态类型来选择方法的版本,并不需要知道对象的实际类型。

虽然javac编译器可以确定重载的方法版本,但是有时候确定的版本并不一定是唯一的,往往只能确定一个“相对合适”的版本。详细参见《深入立即Java虚拟机 第3版》P306或下面的文章:
https://www.shangmayuan.com/a/e51a7d3f059a481eaf49ed59.html

动态分派

动态分派概念

动态分派是和静态分派相对的一个概念,静态分派是根据静态类型确定方法的版本,而动态分配则是根据实际类型确定方法的版本,其典型应用表现就是方法重写。

  1. public class DynamicDispatch {
  2. static abstract class Father {
  3. public abstract void method();
  4. }
  5. static class Son extends Father {
  6. @Override
  7. public void method() {
  8. System.out.println("Hello,i am son");
  9. }
  10. }
  11. static class Daughter extends Father {
  12. @Override
  13. public void method() {
  14. System.out.println("Hello,i am daughter");
  15. }
  16. }
  17. public static void main(String[] args) {
  18. Father daughter = new Daughter();
  19. Father son = new Son();
  20. daughter.method();
  21. son.method();
  22. }
  23. }

测试结果:
image.png
可以看到,对于方法的重写,虚拟机是根据对象“实际类型”去确定方法的版本的。即使如此,如果查看字节码文件其实jvm选择调用的还是静态类型的对应方法,但是为什么最终的结果却调用了是实际类型的对应方法呢?其实,jvm在调用某个实例的具体方法时,会有以下流程:

  1. 先由编译器根据对象被调用方法参数的静态类型和数量来静态分派方法
  2. 如果静态分派方法失败,虚拟机才会将对象的引用压入栈顶,然后执行invokevirtual指令
  3. 开始执行invokevirtual指令,首先找到操作数栈顶的第一个元素所指对象的实际类型,这里记作C
  4. 能否在C中查找到需要调用的简单名称和描述符相符的方法,若能,直接调用;否则继续下一步
  5. 向C的父类再做如上的搜索
  6. 如果还是没有找到对应的方法,则抛出AbstractMethodError异常

其中1、2步好理解,invokevirtual指令的执行就是3、4、5、6步,其流程如下:
image.png
正是因为invokevirtual指令第一步会确定栈顶对象(即这里的静态类型为Father的son和daughter)的引用的实际类型,所以虚拟机会根据son和daughter对象的实际类型来确定方法的版本,这就是Java方法重写的本质。

面试题:

  1. public class FiledHasNoPolymorphic {
  2. static class Father {
  3. public int money = 1;
  4. public Father() {
  5. money = 2;
  6. show();
  7. }
  8. public void show() {
  9. System.out.println("I am Father,i have $" + money);
  10. }
  11. }
  12. static class Son extends Father {
  13. public int money = 3;
  14. public Son() {
  15. money = 4;
  16. show();
  17. }
  18. @Override
  19. public void show() {
  20. System.out.println("I am Son,i have $" + money);
  21. }
  22. }
  23. public static void main(String[] args) {
  24. Father guy = new Son();
  25. System.out.println("This guy has $" + guy.money);
  26. }
  27. }

运行结果:
image.png
分析:

注意:
多态和虚方法参考文章:https://blog.csdn.net/qiyinmiss/article/details/47861589

动态分派实现优化

动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法,因此虚拟机基于执行性能的考虑,真正运行时一般不会如此频繁的反复搜索类型元数据。一般虚拟机会有一种优化手段:为类型在方法区建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。
image.png
虚方法表中存放着各个方法的入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址是一致的,都指向父类的实现入口(如上图的Object的方法)。如果子类重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址(如上图的Son的两个重写的hardChoice方法)。

注意:

  1. 为了实现方便,具有相同方法签名的方法,在父类、子类的虚方法表中都应具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
  2. 方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

单分派和多分派

方法的接收者和方法的参数统称为方法的宗量。 根据分派基于宗量多少(接收者是一个宗量,参数是一个宗量),可以将分派分为单分派和多分派。单分派是指根据一个宗量就可以知道调用目标(即应该调用哪个方法),多分派需要根据多个宗量才能确定调用目标。

注意: 方法的接收者就是某个方法所属的实际类型对象

  1. public class Dispatch {
  2. static class QQ{}
  3. static class _360{}
  4. public static class Father {
  5. public void hardChoice(QQ arg) {
  6. System.out.println("father choose qq");
  7. }
  8. public void hardChoice(_360 arg) {
  9. System.out.println("father choose 360");
  10. }
  11. }
  12. public static class Son extends Father {
  13. @Override
  14. public void hardChoice(QQ arg) {
  15. System.out.println("son choose qq");
  16. }
  17. @Override
  18. public void hardChoice(_360 arg) {
  19. System.out.println("son choose 360");
  20. }
  21. }
  22. public static void main(String[] args) {
  23. Father father = new Father();
  24. Father son = new Son();
  25. father.hardChoice(new _360());
  26. son.hardChoice(new QQ());
  27. }
  28. }

image.png

  • 对于编译阶段编译器的选择,也就是静态分派的过程,这时选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。最终生成两条invokevirtual指令,指向常量池的Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因此,静态分派根据两个宗量进行选择,属于多分派类型。
  • 对于运行期虚拟机的选择,也就是动态分派的过程,在执行son.hardChoice(QQ)方法对应的invokevirtual指令时,由于编译期已经确定目标方法的签名必须为hardChoice(QQ),虚拟机这时就不会去关心参数QQ的实际类型,因为这时参数的静态类型、实际类型都对方法的选择不会构成影响,唯一可以影响虚拟机选择的因素只有此方法的接收者的实际类型是Father还是Son。因此,动态分派根据一个宗量进行选择,属于单分派类型。