方法调用是否是很熟悉?那你真的了解它吗?今天就让咱们来盘一下它。java
首先你们要明确一个概念,此处的方法调用并非方法中的代码被执行,而是要肯定被调用方法的版本,即最终会调用哪个方法。编程
上篇文章中咱们了解到,class
字节码文件中的方法的调用都只是符号引用,而不是直接引用(方法在实际运行时内存布局中的入口地址),要实现二者的转化,就不得不提到解析和分派了。微信
解析
咱们以前说过在类加载的解析阶段,会将一部分的符号引用转化为直接引用,该解析成立的前提是:方法在程序真正运行以前就已经有一个可肯定的调用版本,而且这个方法的调用版本在运行期是不可改变的。咱们把这类方法的调用称为解析(Resolution
)。jvm
看到这个前提条件,有没有小伙伴联想到对象的多态性?ide
没错,就是这样,在java
中能知足不被重写的方法有静态方法、私有方法(不能被外部访问)、实例构造器和被final
修饰的方法,所以它们都适合在类加载阶段进行解析,另外经过this
或者super
调用的父类方法也是在类加载阶段进行解析的。工具
指令集
调用不一样类型的方法,字节码指令集里设置了不一样的指令,在jvm
里面提供了5条方法调用字节码指令:布局
invokestatic
:调用静态方法,解析阶段肯定惟一方法版本invokespecial
:实例构造器init
方法、私有及父类方法,解析阶段肯定惟一方法版本invokevirtual
:调用全部虚方法invokeinterface
:调用接口方法,在运行时再肯定一个实现该接口的对象invokedynamic
:先在运行时动态解析出调用点限定符所引用的方法,而后再执行该方法,在此以前的4条调用指令,分派逻辑是固化在Java
虚拟机内部的,而invokedynamic
指令的分派逻辑是由用户所设定的引导方法决定的。
invokedynamic
指令是Java7
中增长的,是为实现动态类型的语言作的一种改进,可是在java7
中并无直接提供生成该指令的方法,须要借助ASM
底层字节码工具来产生指令,直到java8
的lambda
表达式的出现,该指令才有了直接的生成方式。性能
小知识点:静态类型语言与动态类型语言测试
它们的区别就在于对类型的检查是在编译期仍是在运行期,知足前者就是静态类型语言,反之是动态类型语言。即静态类型语言是判断变量自身的类型信息,动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。this
例java
类中定义的基本数据类型,在声明时就已经肯定了他的具体类型了;而JS
中用var
来定义类型,值是什么类型就会在调用时使用什么类型。
虚方法与非虚方法
字节码指令集为invokestatic
、invokespecial
或者是用final修饰的invokevirtual
的方法的话,均可以在解析阶段中肯定惟一的调用版本,符合这个条件的就是咱们上边提到的五类方法。它们在类加载的时候就会把符号引用解析为该方法的直接引用,这些方法能够称为非虚方法。与之相反,不是非虚方法的方法是虚方法。
分派
若是咱们在编译期间没有将方法的符号引用转化为直接引用,而是在运行期间根据方法的实际类型绑定相关的方法,咱们把这种方法的调用称为分派。其中分派又分为静态分派和动态分派。
静态分派
不知道你对重载了解多少?为了解释静态分派,咱们先来个重载的小测试:
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 man = new Man();
来讲Human
称为变量的静态类型,而Man
咱们称为变量的实际类型,区别以下:
- 静态类型的变化仅仅在使用时才发生,变量自己的静态类型是不会被改变,而且最终静态类型在编译期是可知的。
- 实际类型的变化是在运行期才知道,编译器在编译程序时并不知道一个对象的具体类型是什么。
此处之因此执行的是Human
类型的方法,是由于编译器在重载时,会经过参数的静态类型来做为断定执行方法的依据,而不是使用实际类型。
全部依赖静态类型来定位方法执行版本的分派动做称为静态分派。静态分派的典型应用就是方法重载。静态分派发生在编译阶段,所以肯定静态分派的动做实际上不是由虚拟机来执行的,而是由编译器来完成。
动态分派
了解了重载以后再来了解下重写?案例走起:
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!
此次相信你们的结果都对了吧?咱们先来补充一个知识点:
父类引用指向子类时,若是执行的父类方法在子类中未被重写,则调用自身的方法;若是被子类重写了,则调用子类的方法。若是要使用子类特有的属性和方法,须要向下转型。
根据这个结论咱们反向推理一下:man
和women
是静态类型相同的变量,它们在调用相同的方法sayHello()
时返回了不一样的结果,而且在变量man
的两次调用中执行了不一样的方法。致使这个现象的缘由很明显,是这两个变量的实际类型不一样,Java
虚拟机是如何根据实际类型来分派方法执行版本的呢?咱们看下字节码文件:
man.sayHello(); woman.sayHello();
咱们关注的是以上两行代码,他们对应的分别是17和21行的字节码指令。单从字节码指令角度来看,它俩的指令invokevirtual
和常量$Human.sayHello:()V
是彻底同样的,可是执行的结果确是不一样的,因此咱们得研究下invokevirtual
指令了,操做流程以下:
- 找到操做数栈顶的第一个元素所指向的对象的实际类型,记做C。
- 若是在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,若是经过则返回这个方法的直接引用,查找过程结束;若是不经过,则返回
java.lang.IllegalAccessError
异常(假如不在一同一个jar包下就会报非法访问异常)。 - 不然,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 若是始终没有找到合适的方法,则抛出
java.lang.AbstractMethodError
异常。
因为invokevirtual
指令执行的第一步就是在运行期肯定接收者的实际类型,因此两次调用中的invokevirtual
指令并非把常量池中方法的符号引用解析到直接引用上就结束了,还会根据接收者的实际类型来选择方法版本(案例中的实际类型为Man
和Woman
),这个过程就是Java
语言中方法重写的本质。
咱们把这种在运行期根据实际类型肯定方法执行版本的分派过程称为动态分派。
单分派与多分派
方法的接收者与方法的参数统称为方法的宗量,这个定义最先应该来源于《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
。此次选择结果的最终产物是产生了两条invokevirtual
指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)
及Father.hardChoice(QQ)
方法的符号引用。由于是根据两个宗量进行选择,因此Java语言的静态分派属于多分派类型。
再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoice(new QQ())”
这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual
指令时,因为编译期已经决定目标方法的签名必须为hardChoice(QQ)
,虚拟机此时不会关心传递过来的参数“QQ”究竟是“腾讯QQ”仍是“奇瑞QQ”,由于这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,惟一能够影响虚拟机选择的因素只有此方法的接受者的实际类型是Father
仍是Son
。由于只有一个宗量做为选择依据,因此Java
语言的动态分派属于单分派类型。
虚方法表
在面向对象的编程中,会很频繁的使用到动态分派,若是在每次动态分派的过程当中都要从新在类的方法元数据中搜索合适的目标的话就极可能影响到执行效率。所以,为了提升性能,jvm
采用在类的方法区创建一个虚方法表(Vritual Method Table
,也称为vtable
,与此对应的,在invokeinterface
执行时也会用到接口方法表——Inteface Method Table
,简称itable
)来实现,使用虚方法表索引来代替元数据查找以提升性能。
每个类中都有一个虚方法表,表中存放着各类方法的实际入口:
- 若是某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。
- 若是子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。
Son
重写了来自Father
的所有方法,所以Son
的方法表没有指向Father
类型数据的箭头。可是Son
和Father
都没有重写来自Object
的方法,因此它们的方法表中全部从Object
继承来的方法都指向了Object
的数据类型。
为了程序实现上的方便,具备相同签名的方法,在父类、子类的虚方法表中都应当具备同样的索引序号,这样当类型变换时,仅须要变动查找的方法表,就能够从不一样的虚方法表中按索引转换出所需的入口地址。方法表通常在类加载的链接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
绑定机制
解析调用必定是个静态的过程,在编译期间就彻底肯定,在类装载的解析阶段就会把涉及的符号引用所有转变为可肯定的直接引用,不会延迟到运行期再去完成。分派(Dispatch
)调用则多是静态的也多是动态的。所以咱们把 解析 和 静态分派 这俩在编译期间就肯定了被调用的方法,且在运行期间不变的调用称之为静态连接,而在运行期才肯定下来调用方法的称之为动态连接。
咱们把在静态连接过程当中的转换成为早期绑定,将动态连接过程当中的转换称之为晚期绑定。
看到这,方法的调用你搞懂了吗?若是你还有什么困惑的话,能够关注微信公众号“阿Q说代码”,也能够加阿Q好友qingqing-4132,阿Q期待你的到来!