06、方法调用

1、方法调用的概述


1.1、概述


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

  • Java 的 Class 文件的编译过程不包含传统程序语言编译连接 步骤。
    • 因此,一切方法调用Class 文件中都只是 符号引用,存储在 静态常量池 中。
      • 不是方法在实际运行时,内存布局中的入口地址(即:直接引用)。
  • 这个特性给 Java 带来了更强大的 动态扩展能力,但也使得 Java 方法调用过程变得 相对复杂
    • 某些方法调用类加载 期间,就能确定 目标方法直接引用
    • 某些方法调用需在 运行 期间,才能确定 目标方法直接引用

1.2、方法调用的字节码指令


  • 调用不同类型方法字节码指令集 里设计了 不同的指令
  • 在 Java 虚拟机支持以下 5 条 方法调用 字节码指令,分别是:
    • invokestatic: 用于调用 静态方法
    • invokespecial: 用于调用 实例构造器 () 方法私有方法父类中的方法
    • invokevirtual: 用于调用所有的 虚方法
    • invokeinterface:用于调用 接口方法,会在运行时再确定一个实现该接口的对象。
    • invokedynamic: 先在运行时 动态解析 出调用点限定符所引用的方法,然后,再执行该方法。
  • 前面 4 条调用指令,分派逻辑都固化在 Java 虚拟机内部,
    • 而 invokedynamic 指令的分派逻辑是由用户设定的引导方法来决定的。

2、非虚方法 vs 虚方法


  • 只要能被 invokestaticinvokespecial 指令调用的方法
    • 都可以在 类加载解析 阶段 中确定 唯一的调用版本
    • Java 语言里符合这个条件方法共有 静态方法、私有方法、实例构造器、父类方法 4 种。
      • 再加上被 final 修饰的方法(尽管它由于历史原因使用 invokevirtual 指令调用),
    • 5 种方法方法调用 会在 类加载解析 阶段,把 符号引用 解析 为该方法的 直接引用
      • 这些 方法 统称为“非虚方法”(Non-Virtual Method)。
    • 与之相反,其它方法 就被称为“虚方法”(Virtual Method)。

  • 注意:
    • 在《Java语言规范》中明确定义了被 final 修饰的方法是一种 非虚方法

3、方法调用形式 – 解析调用(针对 非虚方法) – 编译 阶段 确定


  • 解析调用 一定是个 静态的过程,在 编译期间 完全确定
    • 类加载解析 阶段 就会把涉及的 符号引用 全部转变为明确的 直接引用
    • 不必延迟到 运行期 再去完成。

4、方法调用形式 – 分派调用(针对 虚方法


  • 分派(Dispatch)调用则要复杂许多
    • 它可能是 静态的 也可能是 动态的
    • 按照分派依据的 宗量数 可分为 单分派多分派
  • 这 两类 分派方式 两两组合:
    • 就构成了 静态单分派、静态多分派、动态单分派、动态多分派 4 种分派组合情况。

4.1、静态分派(针对 虚方法) – 编译 阶段 确定


  • 在英文《Java虚拟机规范》和《Java语言规范》里,都叫“Method Overload Resolution”。
    • 方法 重载解析

  • 所有依赖 静态类型 来决定方法执行版本分派动作,都称为 静态分派
  • 静态分派 发生在 编译 阶段
    • 因此,确定静态分派动作实际上不是由虚拟机来执行的。
    • 这点也是为何一些资料选择把它归入“解析调用”而不是“分派调用”的原因。

  • 方法 静态分派 示例:
public class StaticDispatch {
    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(Man guy) {
        System.out.println("hello,gentleman!");
    }

    public void sayHello(Woman guy) {
        System.out.println("hello,lady!");
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch sr = new StaticDispatch();
        // hello,guy!
        sr.sayHello(man);
        // hello,guy!
        sr.sayHello(woman);
    }
}
  • 代码分析:为什么 jvm 会选择 执行参数类型 为 Human 的 重载 版本呢?
        Human man = new Man();
        Human woman = new Woman();
  • 把上面代码中:
    • Human 被称为 变量的 静态类型(Static Type) / 外观类型(Apparent Type)
    • Man / Woman 则被称为 变量的 实际类型(Actual Type) / 运行时类型(Runtime Type)
  • 静态类型实际类型 在程序中都可能会发生变化 ,区别是:
    • 静态类型变化仅仅在使用时发生变量本身的 静态类型 不会被改变
      • 并且最终的 静态类型 是在 编译期 可知的;
    • 实际类型 变化的结果在 运行期 才可确定。
      • 编译器在编译程序的时候并不知道一个对象的实际类型是什么。

  • main() 里的两次 sayHello() 方法调用,在方法接收者已经确定对象 “sr” 的前提下:
    • 使用哪个重载版本,就完全取决于 传入参数数量数据类型
  • 代码中故意定义了两个 静态类型 相同,而 实际类型 不同的变量,
    • 编译器重载 时是通过参数的 静态类型不是 实际类型 作为判定依据的。
  • 由于 静态类型编译期 可知的。
    • 所以,在 编译 阶段,Javac 编译器 就根据参数静态类型 决定了会使用哪个重载版本
    • 因此,选择了 sayHello(Human) 作为调用目标,并把这个方法的 符号引用 写到 main() 方法里的两条 invokevirtual 指令参数中。

4.2、动态分派 – 说明会做什么?


  • 把在运行期根据 实际类型 确定方法执行版本分派过程称为 动态分派
    • 动态分派与 Java 语言多态性 的另外一个重要体现 方法重写(Override)有着很密切的关联。

  • 示例:
public class DynamicDispatch {
    static abstract class Human {
        protected abstract void sayHello();
    }
    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }
    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("woman say hello");
        }
    }
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        // man say hello
        man.sayHello();
        // woman say hello
        woman.sayHello();
        man = new Woman();
        // woman say hello
        man.sayHello();
    }
}
  • main 方法的字节码分析:
    • 0~15 行的字节码是准备动作,作用:
      • 是建立 man 和 woman 的内存空间、调用 Man 和 Woman 类型的 实例构造器
      • 将这 两个实例的引用 存放在第 1、2 个 局部变量表 变量槽 中。
    • 16~21 行是关键部分:
      • 16 和 20 行的 aload 指令分别把刚刚创建的两个 对象的引用 压到 栈顶
        • 这两个 对象 是将要执行的 sayHello() 方法的所有者,称为接收者(Receiver);
      • 17 和 21 行是方法用指令,这两条调用指令单从字节码角度来看:
        • 无论是指令(都是 invokevirtual )还是参数(都是常量池中第 22 项的常量,注释显示了这个常量是 Human.sayHello() 的符号引用)都完全一样.
        • 但是这两句指令最终执行的目标方法并不相同。
      • 那看来解决问题的关键还必须从 invokevirtual 指令本身入手,要弄清楚它是如何确定调用方法版本如何实现多态查找来着手分析才行。
public static void main(java.lang.String[]);
  Code:
    Stack=2, Locals=3, Args_size=1
      0: new #16; //class org/fenixsoft/polymorphic/DynamicDispatch$Man
      3: dup
      4: invokespecial #18; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Man."<init>":()V
      7: astore_1
      8: new #19; //class org/fenixsoft/polymorphic/DynamicDispatch$Woman
     11: dup
     12: invokespecial #21; //Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
     15: astore_2
     16: aload_1
     17: invokevirtual #22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
     20: aload_2
     21: invokevirtual #22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
     24: new #19; //class org/fenixsoft/polymorphic/DynamicDispatch$Woman
     27: dup
     28: invokespecial #21; //Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
     31: astore_1
     32: aload_1
     33: invokevirtual #22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
     36: return
  • 根据《Java虚拟机规范》, invokevirtual 指令运行时解析过程大致分为以下几步:
    • 1、找到操作数栈顶第一个元素所指向的对象的 实际类型,记作 C。
    • 2、如果在 类型 C 中找到与常量中的描述符简单名称都相符的方法,则进行访问权限校验
      • 如果通过则返回这个方法直接引用,查找过程结束;
      • 不通过则返回 java.lang.IllegalAccessError 异常。
    • 3、否则,按照继承关系从下往上依次对 C 的各个父类进行第二步的搜索验证过程。
    • 4、如果始终没有找到合适的方法 ,则抛出 java.lang.AbstractMethodError 异常。

  • 结论:
    • 正是因为 invokevirtual 指令执行的第一步就是在运行期确定接收者实际类型
      • 所以,两次调用中的 invokevirtual 指令并不是把常量池方法的符号引用解析直接引用上就结束了,还会根据方法接收者实际类型选择方法版本
    • 这个过程就是 Java 语言中 方法重写的本质
  • 把这种在运行期根据实际类型来选择方法执行版本分派过程称为 动态分派

  • 这种多态性根源在于 虚方法 调用指令 invokevirtual执行逻辑
    • 可以得出的结论多态只会对方法有效,对字段是无效的
      • 因为,字段不使用 invokevirtual 指令
  • 事实上,在 Java 里面只有 虚方法 存在,字段永远不可能是虚的(即:字段永远不参与多态)。
    • 当子类声明了与父类同名的字段时。
    • 虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。
/**
 * 字段不参与多态
 */
public class FieldHasNoPolymorphic {
    static class Father {
        public int money = 1;
        public Father() {
            money = 2;
            showMeTheMoney();
        }
        public void showMeTheMoney() {
            System.out.println("I am Father, i have $" + money);
        }
    }
    static class Son extends Father {
        public int money = 3;
        public Son() {
            money = 4;
            showMeTheMoney();
        }
        public void showMeTheMoney() {
            System.out.println("I am Son, i have $" + money);
        }
    }
    public static void main(String[] args) {
        Father gay = new Son();
        System.out.println("This gay has $" + gay.money);
    }
}
  • 运行结果:
I am Son, i have $0
I am Son, i have $4
This gay has $2
  • 分析:
    • 输出两句都是“I am Son”。因为 Son 类在创建的时候,首先隐式调用了 Father 的构造函数。
      • 而 Father 构造函数中对 showMeTheMoney() 的调用是一次 虚方法 调用
      • 实际执行的版本是 Son::showMeTheMoney() 方法,所以,输出的是“I am Son”。
    • 这时候,虽然父类的 money 字段已经被初始化成 2 了。
      • 但 Son::showMeTheMoney() 方法中访问的却是子类的 money 字段。
        • 这时候结果自然还是 0。
        • 因为,它要到子类的构造函数执行时才会被初始化。
    • main() 的最后一句通过静态类型访问到了父类中的 money,输出了 2。

4.3、单分派与多分派

  • 方法的 接收者方法的 参数 统称为 方法的宗量
    • 这个定义最早应该来源于著名的《Java与模式》一书。
    • 根据分派基于多少种宗量,可以将分派划分为 单分派多分派 两种。
  • 单分派 是根据一个宗量目标方法进行选择
  • 多分派 是根据多于一个宗量目标方法进行选择

  • 示例:
package org.rainlotus.materials.javabase.a04_oop.method.staticDispatch;

/**
 * 单分派、多分派演示
 * @author zzm
 */
public class Dispatch {
    static class QQ {}
    static class _360 {}
    public static class Father {
        public void hardChoice(QQ arg) {
            System.out.println("father choose qq");
        }
        public void hardChoice(_360 arg) {
            System.out.println("father choose 360");
        }
    }
    public static class Son extends Father {
        public void hardChoice(QQ arg) {
            System.out.println("son choose qq");
        }
        public void hardChoice(_360 arg) {
            System.out.println("son choose 360");
        }
    }
    public static void main(String[] args) {
        Father father = new Father();
        Father son = new Son();
        // father choose 360
        father.hardChoice(new _360());
        // son choose qq
        son.hardChoice(new QQ());
    }
}
  • 分析:
  • 1、编译阶段编译器 的选择过程,也就是 静态分派 的过程。
    • 这时候选择目标方法的依据有两点:
      • 1、静态类型是 Father 还是 Son。
      • 2、方法参数是 QQ 还是 360。
    • 这次选择结果的最终产物是产生了两条 invokevirtual 指令,
      • 两条指令的参数分别为常量池中指向:
        • Father::hardChoice(360) 及 Father::hardChoice(QQ) 方法的 符号引用
    • 因为,是根据两个宗量进行选择****。
      • 所以,Java 语言的 静态分派 属于 多分派类型
  • 2、运行阶段虚拟机 的选择,也就是 动态分派 的过程。
    • 在执行 son.hardChoice(new QQ()) 这行代码所对应的 invokevirtual 指令 时:
      • 由于编译期已经决定目标方法的签名必须为 hardChoice(QQ) 。
      • 此时,虚拟机不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ”。
        • 因为,这时候参数的静态类型实际类型都对方法的选择不会构成任何影响。
      • 唯一可以影响 虚拟机 选择的因素只有该方法的接受者实际类型****是 Father 还是 Son。
    • 因为,只有一个宗量作为选择依据
      • 所以,Java 语言的 动态分派 属于 单分派类型

4.4、虚拟机 动态分派 的实现 – 说明具体如何做?

  • 因各种虚拟机实现不同会有些差别

  • 动态分派是执行非常频繁的动作,而且动态分派方法版本选择过程需要运行时接收者 类型方法元数据中搜索合适的目标方法
    • 因此,JVM 实现基于执行性能考虑,真正运行时一般不会如此频繁地去反复搜索类型元数据

  • 优化手段:使用 虚方法表 索引代替元数据查找提高性能
    • 类型方法区中建立一个 虚方法表(Virtual Method Table,也称为 vtable)。
    • invokeinterface 执行时会用到 接口方法表(Interface Method Table,简称 itable)。

  • 虚方法表 中存放着各个方法的 实际入口地址
    * 如果某个方法在子类中没有被重写。
    + 子类的虚方法表中的地址入口和父类相同方法地址入口一致的
    + 都指向父类的实现入口。
    * 如果子类中重写了这个方法。
    + 子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。
    • 上图中,Son 重写了来自 Father 的全部方法。
      • 因此,Son 的方法表没有指向 Father 类型数据的箭头。
    • 但是, Son 和 Father 都没有重写来自 Object 的方法。
      • 所以,它们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型。
  • 为了程序实现方便:
    • 具有相同签名的方法,在父类、子类的 虚方法表 中都应当具有一样的索引序号
    • 当类型变换时,仅需要变更查找的 虚方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
  • 虚方法表一般在 类加载 的 连接 阶段 进行初始化
    • 准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。

5、静态绑定 vs 动态绑定


  • 方法绑定:把一个方法与其所在的/对象关联 起来叫做 方法的绑定

5.1、静态绑定(Static Binding)/ 静态方法绑定 / 早期绑定


  • 编译时,编译器可以准确地知道应该调用哪个方法,这称为 静态绑定
    • Java 中只有 private 方法static 方法final 方法构造器super 调用的父类方法super 调用的父类构造器 等方法。
  • 编译阶段就已经指明了被调用方法常量池中的符号引用
    • JVM 运行时,静态绑定 只需要进行一次 常量池解析 即可。
    • 运行效率比 动态绑定 高。

5.2、动态绑定(Dynamic Binding)/ 动态方法绑定 / 动态分派 / 后期绑定


  • 引用类型变量 在运行时,根据指向实际对象,才自动地选择适当的方法,称为 动态绑定
    • 因为:一个 引用类型变量 可以指向 多种实际对象,这一点称为多态 (polymorphism)。
public class Parent {

    public void m(){
        System.out.println("父类方法执行");
    }

    public static void main(String[] arg){
        List<Parent> list = Arrays.asList(new A(),new B(),new C());
        for(Parent p : list){
            p.m();
        }
    }
}

class A extends Parent{
    public void m(){
        System.out.println("A 类方法执行");
    }
}

class B extends Parent{
    public void m(){
        System.out.println("B 类方法执行");
    }
}

class C extends Parent{
    public void m(){
        System.out.println("C 类方法执行");
    }
}

/*
输出:
    A 类方法执行
    B 类方法执行
    C 类方法执行
*/
  • 在 C++ 中,如果希望实现 动态绑定,则需要将成员函数声明为 virtual(虚拟的)
  • 在 Java 中,动态绑定 是默认的行为。
    • 不希望让一个方法是虚拟的(virtual),则可以将它标记为 final
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值