JVM理论:(三/4)方法调用

  本文主要总结虚拟机调用方法的过程是怎样的,JAVA虚拟机里面提供了5条方法调用的字节码指令。分别如下:

  invokestatic:调用静态方法

  invokespecial:调用实例构造器<init>方法、私有方法和父类方法。

  invokevirtual:调用所有的虚方法。

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

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

  

  方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即真正要调用哪一个方法),暂时还不涉及方法内部的具体运行过程。一切方法调用在Class文件里面存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址,这个特性给Java带来了更强大的动态扩展能力,但也使得方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

一、解析  

  所有方法调用中的目标方法在Class文件里都是一个常量池中的符号引用,有这样一种情况,在编译器进行编译时就有一个可确定的调用版本,并且这个方法的调用版本在运行期间是不可变的,对于这样的方法,在类加载的解析阶段就会将符号引用转化为直接引用。对这类方法的调用过程称为解析,称这些方法为非虚方法。

  非虚方法(在解析阶段就可以确定唯一的调用版本)包括:

  (1)能被invokestatic和invokespecial指令调用的方法,有静态方法、私有方法、实例构造器、父类方法4类。对于静态方法、私有方法,前者与类型直接关联,后者在外部不可被访问,这就决定了它们都不可能通过继承或别的方式重写其版本。静态方法和私有方法为什么不能实现多态呢?分享这两篇文章https://blog.csdn.net/zhouhong1026/article/details/19114589https://blog.csdn.net/zhousenshan/article/details/51222908。  

  (2)被final修饰的方法,虽然final方法使用invokevirtual指令调用,但由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择。

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

二、分派

  解析调用描述的是那些在编译期就能确定唯一调用版本的方法,但对于那些有多个版本的方法,调用时又是如何确定该调用方法的哪一个版本呢?

  分派调用可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派,因此分派可分为:静态单分派、静态多分派、动态单分派、动态多分派。分派涉及到Java面向对象3个基本特征:继承、封装、多态。特别是多态性特征的一些体现——重载、重写。

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 man");
    }
    public void sayHello(Woman guy){
        System.out.println("Hello woman");
    }
    
    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 man = new Man();

  上面一行代码中,Human称为变量的静态类型,后面的Man则称为变量的实际类型,静态类型在编译期就可知,而实际类型变化的结果在运行期才可以确定。main()里面两次sayHello()方法调用,在方法接收者已经确定是对象“sr”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型,代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但Javac编译器在重载时是根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标。  

   编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本也不是唯一的,往往只能确定一个更加合适的版本。

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

2、动态分派

  动态分派和“重写”有着密切联系,以下列代码为例来说明重写。

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

  以上两个静态类型同是Human的变量man和woman,两个变量的实际类型不同,最终执行的目标方法也不同。两条调用都用到了invokevirtual指令,invokevirtual指令的运行解析过程大致分为以下几个步骤:

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

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

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

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

 由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质,我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派

3、单分派与多分派

  方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少宗量,可以将分派划分为单分派和多分派两种。像上面两个例子如果既需要确定方法调用者的实际类型,又需要通过参数的静态类型来确定使用哪种重载方法,属于多分派,若只以一个宗量对目标方法进行选择就是单分派。

4、虚拟机是如何具体实现动态分配的

  动态分派对方法版本选择时不会在运行期对类的方法元数据进行频繁搜索以找到合适的目标方法,而是为类在方法区中建立一个虚方法表,如果是接口,在invokeinterface执行时也会用到接口方法表,使用虚方法表索引来代替元数据查找以提高性能。

  

  虚方法表中存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。如上图所示,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法,所以他们的方法表中所有从Object继承来的方法都指向了Object的数据类型。方法表一般在类加载的连接阶段进行初始化,准备了类变量的初始值后,虚拟机会把该类的方法表也初始化完毕。

三、 invokedynamic对动态类型语言的支持

  像Java、C++等这些在编译期就进行类型检查过程的语言属于静态类型语言,像PHP、Python、Javascript等这些在运行期才进行类型检查的语言为动态类型语言。

  JDK1.7以前,4条方法调用指令invokevirtual、invokespecial、invokestatic、invokeinterface的第一个参数都是被调用的方法的符号引用,符号引用包含了此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,虚拟机就可以翻译出这个方法的直接引用。方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。所以这4条指令无法很好地支持动态语言。

  JDK1.7后,为了更好地支持动态类型语言,引入了第五条方法调用的字节码指令invokedynamic。某种程度上可以说invokedynamic指令与MethodHandle机制的作用是一样的。invokedynamic指令与前面4条“invoke*”指令的最大差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。在MethodHandles.Lookup中的三个方法findStatic()、findVirtual()、findSpecial()正是为了对应于invokestatic、invokevirtual & invokeinterface和invokespecial这几条字节码指令的执行权限校验行为。

import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
public class Test{
    class GrandFather{
        void thinking(){
            System.out.println("i am grandfather");
        }
    }
    class Father extends GrandFather{
        void thinking(){
            System.out.println("i am father");
        }
    }
    class Son extends Father{
        void thinking(){
            try{
                MethodType mt=MethodType.methodType(void.class);
                MethodHandle mh=lookup().findSpecial(GrandFather.class, "thinking", mt, getClass());  
                mh.invoke(this);
            }catch(Throwable e){
            }
        }
    }
    public static void main(String[] args){
        (new Test().new Son()).thinking();
    }
}

  上面这段代码,可以通过“super”关键字很方便地调用到父类中的方法,但如果要访问祖类的方法呢?在JDK 1.7之前,在Son类的thinking()方法中无法获取一个实际类型是GrandFather的对象引用,而invokevirtual指令的分派逻辑就是按照方法接收者的实际类型进行分派,这个逻辑是固化在虚拟机中的,程序员无法改变。在JDK 1.7后,通过invokedynamic的支持可以用上面的代码实现。

  

原文地址:https://www.cnblogs.com/zjxiang/p/9218164.html