JVM(2)之方法调用

<<深入理解java虚拟机>>

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

1.解析

1)什么是解析?

所有方法调用的目标方法在Class文件里面都是一个常量池中的符 号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前 提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不 可改变的。

解析调用一定是个静态的过程,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。在类加载的解析阶段就会把涉及的符号 引用全部转变为明确的直接引用,不必延迟到运行期再去完成,这类方法 的调用被称为解析(Resolution)。

2)哪类方法 的调用被称为解析?

符合“编译期可知,运行期不可变”,主要有静态方法私有方法实例构造器父类方法(通过super.调用)4种,再加上被final 修饰的方法(尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引 用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方 法就被称为“虚方法”(Virtual Method)。

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

·invokestatic。用于调用静态方法。

·invokespecial。用于调用实例构造器<init>()方法、私有方法和父类中的方法。

·invokevirtual。用于调用所有的虚方法。

·invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。

·invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4 条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引 导方法来决定的。

非虚方法

如果这个方法在编译器就确定了具体的调用版本,在运行时是不可以变的,称之为非虚方法。(也就是说在编译期间能够确定调用是子类方法还是父类方法,子类不能重写或者显示调用了父类方法)

虚方法

除了非虚方法的其它方法都为虚方法。

我们在使用多态的时候,是动态绑定,编译期间并不知道调用的是子类的方法还是父类的方法,只有在运行时才能确定下来。

上述讲解,大家可能不是很明白,简单来说就是:

①一个方法如果不会被重写(private 不会被继承、static会被继承不会被被重写、final能继承不能重写、构造器不能被继承),或者是显示调用的父类某个方法(super.),那么这些方法被称为非虚方法

②非虚方法在编译期间就能确定下来,运行期间不会改变,在类加载的解析阶段就会将常量池中的对非虚方法的符号引用,转变为直接引用

③对非虚方法的调用(此调用不是指执行具体方法指令,指确定具体调用哪个方法)称为解析(调用)

2.分派

分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在 Java虚拟机之中是如何实现的,也就是虚拟机如何 确定正确的目标方法。

1)静态分派

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

接下来通过一个例子解释什么是静态类型。

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(); 
        sr.sayHello(man); 
        sr.sayHello(woman);
 }
}

运行结果

hello,guy! 
hello,guy!

上面代码中的“Human”称为变量的“静态类型”(Static Type),或者叫“外观类 型”(Apparent Type),后面的“Man”则被称为变量的“实际类型”(Actual Type)或者叫“运行时类 型”(Runtime Type)。

静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅仅 在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;

实际类 型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

例如:

// 实际类型变化 
Human human = (new Random()).nextBoolean() ? new Man() : new Woman(); 
// 静态类型变化
sr.sayHello((Man) human) 
sr.sayHello((Woman) human)

对象human的实际类型是可变的,到底是Man还是Woman,必 须等到程序运行到这行的时候才能确定。

human的静态类型是Human,也可以在使用时(如 sayHello()方法中的强制转型)临时改变这个类型,但这个改变仍是在编译期是可知的。

回到上面的例子:

在重载时,使用哪个重载版 本,完全取决于传入参数的数量和数据类型。虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为 判定依据的。

由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定 了会使用哪个重载版本,因此选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到 main()方法里的两条invokevirtual指令的参数中。

注意:Javac编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯 一”的,往往只能确定一个“相对更合适的”版本。(比如:上述例子中,还存在static class Human extends Monkey { }; public void sayHello(Monkey mk){};)

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.sayHello(); 
        woman.sayHello(); 
        man = new Woman(); 
        man.sayHello(); 
    } 
}

运行结果:

man say hello 
woman say hello 
woman say hello

这里选择调用的方法版本是不可能再根据静态类型来决定的,而是跟据两个变量的实际类型决定的。

Java虚拟机是 如何根据实际类型来分派方法执行版本的呢?使用javap命令输出这段代码的字节码

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #2                  // class cn/hanna/jvm/chapter01/DynamicDispatch$Man
         3: dup
         4: invokespecial #3                  // Method cn/hanna/jvm/chapter01/DynamicDispatch$Man."<init>":()V
         7: astore_1
         8: new           #4                  // class cn/hanna/jvm/chapter01/DynamicDispatch$Woman
        11: dup
        12: invokespecial #5                  // Method cn/hanna/jvm/chapter01/DynamicDispatch$Woman."<init>":()V
        15: astore_2
        16: aload_1
        17: invokevirtual #6                  // Method cn/hanna/jvm/chapter01/DynamicDispatch$Human.sayHello:()V
        20: aload_2
        21: invokevirtual #6                  // Method cn/hanna/jvm/chapter01/DynamicDispatch$Human.sayHello:()V
        24: new           #4                  // class cn/hanna/jvm/chapter01/DynamicDispatch$Woman
        27: dup
        28: invokespecial #5                  // Method cn/hanna/jvm/chapter01/DynamicDispatch$Woman."<init>":()V
        31: astore_1
        32: aload_1
        33: invokevirtual #6                  // Method cn/hanna/jvm/chapter01/DynamicDispatch$Human.sayHello:()V
        36: return

0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实 例构造器,将这两个实例的引用存放在第1、2个局部变量表的变量槽中,这些动作实际对应了Java源 码中的这两行:

Human man = new Man(); 
Human woman = new Woman();

接下来的16~21行是关键部分,16和20行的aload指令分别把刚刚创建的两个对象的引用压到栈 顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);17和21行是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量 池中第22项的常量,注释显示了这个常量是Human.sayHello()的符号引用)都完全一样,但是这两句指 令最终执行的目标方法并不相同。那看来解决问题的关键还必须从invokevirtual指令本身入手,invokevirtual指令的运行时解析过程[4]大致分为以下几步:

1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。

2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果 通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。

3)否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。

4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

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

注意:但是字段是不参与多态的,也就是说当子类声明了与父类同名的字段时,虽然在子类的内存中两 个字段都会存在,但是子类的字段会遮蔽父类的同名字段,父类无法调用子类的同名字段。

补充一点:子类的构造函数执行时才会被初始化

也就是说public Men(){

private int money = 8;

super(); // 在父类构造方法没执行完之前,子类对象还未初始化,(money=0)一旦出了super()子类对象中的属性就会初始化了(money=8)。

}

3)分派与多分派

方法的接收者(方法调用者)与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对 目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

举例说明:

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.hardChoice(new _360());
         son.hardChoice(new QQ()); 
    } 
}

运行结果:

father choose 360 
son choose qq

我们关注的首先是编译阶段中编译器的选择过程,也就是静态分派的过程。这时候选 择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结 果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向 Father::hardChoice(360)及Father::hardChoice(QQ)方法的符号引用。

因为是根据两个宗量进行选择,所以 Java语言的静态分派属于多分派类型

再看看运行阶段中虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoice(new QQ())”这 行代码时,更准确地说,是在执行这行代码所对应的invokevirtual指令时,由于编译期已经决定目标方 法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇 瑞QQ”,因为这时候参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚 拟机选择的因素只有该方法的接受者的实际类型是Father还是Son。

因为只有一个宗量作为选择依据, 所以Java语言的动态分派属于单分派类型

综上可以总结:如今的 Java语言是一门静态多分派动态单分派的语言。

4)虚拟机动态分派的实现

动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的 方法元数据中搜索合适的目标方法,因此,Java虚拟机实现基于执行性能的考虑,真正运行时一般不 会如此频繁地去反复搜索类型元数据。面对这种情况,一种基础而且常见的优化手段是为类型在方法 区中建立一个虚方法表(Virtual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也 会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以 提高性能。

 虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方 法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了 这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。在图8-3中,Son重写 了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有 重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。

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

查虚方法表是分派调用的一种优化手段,为了进一步提高性能,还会使用类型继 承关系分析(Class Hierarchy Analysis,CHA)、守护内联(Guarded Inlining)、内联缓存(Inline Cache)等多种非稳定的激进优化来争取更大的性能空间。

解析与分派这两者之间的关系并不是二选一的 排他关系,它们是在不同层次上去筛选、确定目标方法的过程。例如前面说过静态方法会在编译期确 定、在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通 过静态分派完成的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值