方法调用:一看就懂,一问就懵?
方法调用是不是很熟悉?那你真的了解它吗?今天就让我们来盘一下它。
首先大家要明确一个概念,此处的方法调用并不是方法中的代码被执行,而是要确定被调用方法的版本,即最终会调用哪一个方法。
上篇文章中我们了解到,class
字节码文件中的方法的调用都只是符号引用,而不是直接引用(方法在实际运行时内存布局中的入口地址),要实现两者的转化,就不得不提到解析和分派了。
解析
我们之前说过在类加载的解析阶段,会将一部分的符号引用转化为直接引用,该解析成立的前提是:方法在程序真正运行之前就已经有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。我们把这类方法的调用称为解析(Resolution
)。
看到这个前提条件,有没有小伙伴联想到对象的多态性?没错,就是这样,在java
中能满足不被重写的方法有静态方法、私有方法(不能被外部访问)、实例构造器和被final
修饰的方法,因此它们都适合在类加载阶段进行解析,另外通过this
或者super
调用的父类方法也是在类加载阶段进行解析的。
指令集
调用不同类型的方法,字节码指令集里设置了不同的指令,在jvm
里面提供了5条方法调用字节码指令:
invokestatic
:调用静态方法,解析阶段确定唯一方法版本invokespecial
:实例构造器init
方法、私有及父类方法,解析阶段确定唯一方法版本invokevirtual
:调用所有虚方法invokeinterface
:调用接口方法,在运行时再确定一个实现该接口的对象invokedynamic
:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java
虚拟机内部的,而invokedynamic
指令的分派逻辑是由用户所设定的引导方法决定的。
❝❞
invokedynamic
指令是Java7
中增加的,是为实现动态类型的语言做的一种改进,但是在java7
中并没有直接提供生成该指令的方法,需要借助ASM
底层字节码工具来产生指令,直到java8
的lambda
表达式的出现,该指令才有了直接的生成方式。
「小知识点:静态类型语言与动态类型语言」
它们的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。即静态类型语言是判断变量自身的类型信息,动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。
「例」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期待你的到来!
觉得还不错?记得一键四连呦👇