Java虚拟机知识整理——方法调用

来源:互联网 发布:张宇唱功知乎 编辑:程序博客网 时间:2024/04/29 14:22

方法调用

方法调用不等同与方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本,暂时还不涉及方法内部的具体运行过程。在程序运行时,进项方法调用时最普遍、最频繁的操作。Class文件的编译过程中不包含传统编译中的连接不走,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在史记运行时内部布局中的入口地址。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

解析

前面说到,所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个确定的调用版本,并且这个方法的调用保本在运行期间是不可改变的。话句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来,这类方法的调用成为解析。
在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两个大类,前者与类型直接关联,或者在外部不可被访问,这两种方法各自的特点决定了他们都不可能通过继承活别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
在Java虚拟机里面提供了5条方法调用字节码指令

  • invokestatic:调用静态方法。
  • invokespecial:调用实例构造器< init>方法、私有方法和父类方法。
  • invokevirtual:调用所有的虚方法。
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此类接口的对象。
  • invokedynamic:先在运行动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

只要能被invokestatic和invokesspecial指令调用方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法四类,它们在类加载的时候就会吧符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法都称为虚方法(除去final方法)。虽然final方法一是用invokevirtual指令来调用,但是由于它无法被覆盖,没有其他版本,所以也无需对方法接受者进行多态选择,又或者说多态选择的结果肯定是唯一的。在Java语言规范中明确说明了final方法是一种非虚方法。
解析调用一定是个静态的过程,在编译期间就完全确定,在类装在的解析阶段就会吧设计的符号引用区别脑补转变为可确定的直接引用,不会延迟到运行期再去完成。而分派调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分配和多分派。这两类分派方式的亮亮组合就构成了静态单分派、静态多分配、动态单分派、动态多分派4种分派组合情况。

分派

Java是一门面向对象的程序语言,因为Java具备面向对象的3个基本特征:继承、封装和多态。这里所说的分配调用过程将会解释多态性特征的一些最基本的体现,如“重载”和“重写”在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成为变量的静态类型,或者叫做外观类型,静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会改变,并且最终的静态类型是在编译期可知的;而且实际类型变化的结果在运行期才可确定,编译期在编译程序的时候并不知道一个对象的实际类型是什么。
有了这两个概念的解释,再开始说明代码,main()里面的两次sayHello()方法调用,在方法接收这已经确定是对象“sr”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中可以的定义了两个静态类型相同但是实际类型不同的变量,但虚拟机在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了相应的调用目标。
所有依赖静态类型来定位方法执行版本的分配动作成为静态分派。静态分派的的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这种重载版并不是唯一的,往往只能确定一个“更加合适的”版本。
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在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,Java虚拟机是如何根据根据实际类型来分派方法执行版本的呢?
invokevirtual指令的运行时解析过程大致分为以下几个步奏

  • 找到操作数栈顶的第一个元素锁只想的对象的实际类型,记作C。
  • 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问时间校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
  • 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
  • 如果始终没有找到合适的方法,则抛出java.langAbstractMethodError异常
    如果invokevirtual指令的第一步就是在运行期间确定接受者的实际类型,所以两次调用中的invokevitual指令把常量池中类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。
    1. 单分派与多分派
      方法的接受者与方法的参数统称为方法的宗量。这个定义最早应该来源于《Java与模式》艺术,根据分配基于多少宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派是根据多于一个宗量对目标方法进行选择。
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.这次选择的结果的最终产物是产生了两条invokvirtual指令,两条指令的参数分别为常量池中指向两个方法的符号调用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行方法调用的时候,更准确的说,就是在执行这句话锁对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoise(QQ),虚拟机此时不会关心传递过来的参数到底是什么,因为这时参数的静态类型,实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接收者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

本文总结

上述论证的结果,可以总结说:今天的Java语言是一门静态多分派的语言。强调“今天的Java语言”是因为这个结论未必会恒久不变。
前面介绍的分派过程,作为对虚拟机概念模型的解析基本告一段落,它基本解决了虚拟机在分派中“会做什么”这个问题。但是虚拟机“具体是如何做到的”,可能各种虚拟机的实现都会有些差别。

0 0
原创粉丝点击