分派
前言
在学习访问者模式的时候看到有关分派的概念,在网上浏览了不少文章,大多数要么用词模糊要么有些地方乱写,看地我头大,于是有了这篇文章。我会先介绍一些基础概念来帮助大家理解分派是什么;然后引出本文要讲的静态分派和动态分派以及单分派多分派知识点并配以简短有力的解释。接着通过几个案例加上底层原理分析来验证我对第二部分的解释;第四部分是一些扩展知识比如可以规避 instanceof
的伪动态双分派工作、静态分派的优先级匹配以及强制转换,最后是本篇的回顾总结部分。我会在文中关键地方使用类比以及图文的方式增进大家对有关知识点的认识,完成各自的认知升级。另外说明一下,本人理解有限,难免有疏漏的地方,欢迎大家在文章后面的留言区指正。
基础概念
重载和重写
重载:Overload,类内部有多个同名方法,参数的个数或者类型不同
重写:Override,又名覆写,即子类覆盖父类的方法
注意区分多态和重载,多态是建立在重写的基础之上的,是类与类之间的关系,是发生在不同的类之间的,子类重写父类的方法。不同的子类实现父类有着不同的实现形态。 多态有3个条件:继承、重写(重写父类继承的方法)、父类引用指向子类对象 而重载是在类的内部方法构型上的不同,是在同一个类里面的。同一个函数名称不同参数的多个方法,实现同一类型的功能。
静态和动态
静态:对应编译期
动态:对应运行时
静态类型和实际类型
静态类型:变量的声明类型
实际类型:变量指向堆里对象的真实类型
宗量
分为方法的调用者和参数两个宗量
分派
又称绑定,确定目标方法的过程,因为java多态以及方法重载的存在,一个方法的符号引用可能对应多个目标方法
展开来说一下,分派是一个动词,肯定有主语和宾语,根据分派的词意,宾语还得分成简宾和直宾。要学习这个动词得先搞清楚它的主语和宾语分别是什么 主语:编译器/虚拟机 间接宾语:一地址指令invoke的操作数 直接宾语:方法入口在jvm进程中的逻辑地址(编译期如果确定不了会给出带参数宗量的符号引用) 状语:在编译期/在运行时 组装成句子:在编译期/运行时,编译器/虚拟机分派方法入口在jvm进程中的逻辑地址(编译期如果确定不了会给出带参数宗量的符号引用)给到一地址指令invoke的操作数
要想搞懂分派这个动词,得先把这个句子中的名词给捋顺,编译器和虚拟机大家都熟悉,方法入口在jvm进程中的逻辑地址和一地址指令invoke,这两块涉及到操作系统和组成原理还有jvm指令集的知识,在此就不展开了,想要了解的话可以在我的博客分类(操作系统、组成原理、JVM)中找到相关内容。
解析
在程序运行时,进行方法调用是最普遍最频繁的操作,但Class文件的编译过程不包含传统编译中的链接步骤,一切方法调用在Class文件里存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到了运行期间才能确定目标方法的直接引用。
对符号引用和直接引用不清楚的可以看我的这篇博文:{%post_link 工作/200_编程语言/java/jvm/详解“符号引用转直接引用” %}
所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用。这种解析能成立的前提是:方法在程序真正运行前就有一个可确定的调用版本,并且该方法版本在运行期不可改变。满足这种“编译期可知,运行期不可变”的要求的方法,主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可访问。将这种方法的符号引用转为直接引用就叫做“解析” Resolution。
在Java虚拟机里面提供了5条方法调用字节码指令,分别如下:
-
invokestatic 调用静态方法
-
invokespecial 调用实例构造器
<init>
方法、私有方法和父类方法(显示使用super) -
invokevritual 调用所有的虚方法
-
invokeinterface 调用接口方法,会在运行时再确定一个实现此接口的对象
-
invokedynamic 先在运行时动态解析出调用点限定符所引用的方法,然后再执行此方法,在此之前 的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引用方法决定的。
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法,私有方法,实例构造器,父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,其他方法称为非虚方法(除了final方法)。
final修饰的方法,虽然是使用invokevirtual指令来调用,但由于它无法被覆盖,没有其他版本,因此也是非虚方法。
解析调用一定是个静态的过程,在编译期间就能完全确定,在类加载的解析阶段就会把涉及的符号引号全部转变为可确定的直接引用,不会延迟到运行期去完成。分派(Dispatch)可能是静态也可能是动态的,根据分派依据的宗量数(方法的调用者和方法的参数统称为方法的宗量)可分为单分派和多分派。这两种分派方式的两两组合就构成了静态单分派,静态多分派,动态单分派,动态多分派这4种组合。
虚方法和非虚方法
非虚方法:静态方法、私有方法、final修饰的方法
虚方法:除上面以外的方法
虚方法表
由于动态分派很频繁,虚拟机在运行时需要频繁地在子类和父类的方法元数据中搜索合适的目标方法,基于性能的考虑,JVM会在方法区中维护一个虚方法表,使用虚方法表来代替元数据查找以提高性能。虚方法表本质就是索引是虚方法方法名,值是内存布局的入口地址(是一个逻辑地址)的一张索引表,存储在方法区对象的klass数据结构中,感兴趣的可自行了解,不影响阅读本篇,可以结合invokevirtual执行流程图理解。
虚方法和虚方法表可以看我的这篇博客:{%post_link 工作/200_编程语言/java/jvm/5_虚拟机栈 虚拟机栈7.4节~7.6节 %}
静态调用和动态调用
静态调用:在编译期确定方法调用者的实际类型 invokestatic、invokespecial
动态调用:在运行时确定方法调用者的实际类型除 invokestatic、invokespecial和final
静态分派和动态分派
静态分派
-
编译器对目标方法的选择,根据方法调用者的静态类型和参数的静态类型及个数确定目标方法的符号引用,分派的结果是产生一条invoke指令
eg:
invokevirtual #7
其中
#7
表示方法的符号引用1
#7 = Methodref #5.#34 // com/eh/eden/java8/demo/Father.t:(Ljava/lang/Object;)V
可以看到调用者的静态类型是Father;方法参数是Object,由于是invokevirtual指令,在运行时会将符号引用转为直接引用。
如果是静态调用,在解析阶段就能将方法的符号引用转为直接引用
如果是动态调用,需要到运行时才能将符号引用转为直接引用,但是参数依然根据静态类型
-
重载的本质是静态分派
动态分派
-
虚拟机对目标方法的选择,虚拟机看到invokevirtual这条指令就会试图将方法的符号引用转为直接引用,这也是解析可能发生在初始化后的原因。虚拟机此时不关心方法的参数是什么静态类型或者实际类型或者是有几个,也就是参数由编译器决定,但是虚拟机会根据方法调用者的实际类型决定调用哪个目标方法,这也就是invokevirtual指令的运行机制,后文会有讲解。
-
重写的本质是动态分派
-
invokevirtual执行过程
在第一个案例中会结合具体字节码说明,有详细流程图说明
可以使用位向量(大端存储)表示目标方法的符号引用,静态分派负责给位向量赋初始值,其中高位是方法调用者的声明类型,低位是参数的声明类型以及个数。动态调用时虚拟机会使用方法的调用者的实际类型去寻找目标方法,参数保持不变,如下图所示
单分派多分派
前面给出:方法的调用者与方法的参数称为方法的宗量。单分派是根据一个宗量对目标方法进行选择,多分派是根据多个宗量对目标方法进行选择。
静态多分派
结合神图,先来看编译阶段编译器的选择过程,即静态分派过程。静态分派根据两个宗量确定目标方法,所以Java语言的静态分派属于多分派类型。
动态单分派
再来看运行阶段虚拟机的选择,即动态分派过程。由于编译期已经了确定了目标方法的参数类型,因此唯一可以影响到虚拟机选择的因素只有此方法调用者的实际类型。因为只有一个宗量作为选择依据,所以 Java 语言的动态分派属于单分派类型。
一句话总结:Java是一个支持静态双分派的动态单分派语言。
案例说明
案例一
案例意图
重载的本质是静态分派
网上好多资料说重载选择静态分派实现,重写选择动态分派实现这种说法真的误导人
如果是先有鸡后有蛋,那么分派就是鸡,重载和重写是蛋。上面这个说法是先有蛋再有鸡,谬之大矣!
|
|
字节码:
|
|
我们着重看方法调用部分
|
|
将参数和方法调用者压操作数栈,然后调用invokevirtual指令
其中#7和#8在静态常量池中表示的方法符号引用是:
|
|
可以看到在编译期,静态分派是根据方法调用者和参数的静态类型以及个数确定目标方法(比如com/eh/eden/java8/demo/Father.t:(Ljava/lang/Object;)V
)的。
下面给出invokevirtual指令的执行过程:
ok,现在f.t(o)
的i由编译器确定是它的静态类型Object,f由虚拟机确定是它的实际类型Father,所以会执行Father.t(Object)方法。f.t(i)
的i由编译器确定是它的静态类型Integer,f由虚拟机确定是它的实际类型Father,所以会执行Father.t(Integer)方法。
综上所述,重载的本质是静态分派,确定了方法参数的类型以及个数
案例二
案例意图
如果是静态调用,在解析阶段就能将方法的符号引用转为直接引用
|
|
注意子类的静态方法不是重载父类
|
|
可以注意到不管是f.t还是s.t,o还是i,方法调用指令是invokestatic,所以这个目标方法在编译期根据他们的静态类型就已经确定了,类加载链接阶段就会将目标方法的符号引用转为直接引用,跟虚拟机没关系。所以才有s.t输出也是Father -> o
案例三
案例意图
- 如果是动态调用,需要到运行时才能将符号引用转为直接引用,但是参数依然根据静态类型
- 重载的本质是动态分派
|
|
因为是动态调用,所以方法的直接引用地址需要在运行时由虚拟机确定,和案例一中一样使用invokevirtual指令确定方法的直接引用地址。第一步是确定变量的实际类型也就是Son,第二步是将方法的符号引用转为直接引用。
参数的声明 类型以及个数确定的前提下,确定变量的实际类型就可以可以唯一确定一个方法,由虚拟机确定目标方法的过程叫做动态分派,所以重写的本质是动态分派。
就好比高位和低位确定,一个内存地址也就确定了。再来对照下位向量表示法图
扩展知识
伪动态双分派
先来看个小案例:
|
|
通过阅读前面的文章我们知道静态分派是指在编译期就已经确定要执行哪一个方法。方法的重载(方法名相同而参数不同)就是静态分派的,重载时,执行哪一个方法在编译期就已经确定下来,所以运行结果是两个b。
如果希望使用重载的时候,程序能够根据传入参数的实际类型动态地调用相应的方法,也就是说,我们希望java的重载是动态的,而不是静态的。但是由于java的重载不是动态绑定,只能通过程序来人为判断,我们一般会使用instanceof操作符来进行类型的判断代码如下:
|
|
这种方式有个明显的缺点,如果一个类有很多子类,每个子类都使用instanceof来做条件判断显然是不合适的,必须通过其他更好的方式实现才行,这就引出了下面要讲的伪动态双分派工作,这也是访问者模式的精髓。
使用伪动态双分派工作
|
|
所谓的动态双分派就是在运行时依据两个实际类型去确定一个方法的执行版本,实现的手段是通过两次动态单分派来达到动态双分派的效果,所以称作伪动态双分派。
我们来分析a.accept(b1)
的调用过程
第一次动态双分派就不讲了,方法accept的调用者实际类型是A,进入到方法accept里面
b1.visit(this)
大家注意区分 visit(this)中的this变量是静态分派的,你写在哪个类中,它的静态类型就是哪个类,这是在编译期就确定的,不确定的是它的实际类型。
通过第一次动态分派a的类型已经确定了,也就是A。此时需要根据b1的实际类型确定visit方法的版本,b1的实际类型是B1,所以visit方法的版本就是B1.accept(A)。如此一来,就完成了动态双分派的过程。
变量的静态类型发生变化情况
可通过 强制类型转换 改变 变量的静态类型
|
|
本质没变,在编译器确定变量的静态类型是Integer,所以输出Son -> i 也不奇怪了。
静态分派的优先级匹配问题
基本类型
方法重载,参数是基本类型。程序中没有显示指定静态类型,如何进行静态分派?
代码如下
|
|
因为‘a’
是一个char
类型数据(即静态类型是char
),所以会选择参数类型为char
的重载方法。
若注释掉sayHello(char arg)
方法,那么会输出
|
|
因为‘a’
除了可代表字符串,还可代表数字97。因此当没有最合适的sayHello(char arg)
方式进行重载时,会选择第二合适(第二优先级)的方法重载,即sayHello(int arg)
也就是当没有最合适的方法进行重载时,会选优先级第二高的的方法进行重载,以此类推。
优先级顺序为:char>int>long>float>double>Character>Serializable>Object>...
其中...
为变长参数,将其视为一个数组元素。变长参数的重载优先级最低。
因为 char
转型到 byte
或 short
的过程是不安全的,所以不会选择参数类型为byte
或 short
的方法进行重载,故优先级列表里也没有。
引用类型
根据 继承关系 进行优先级匹配
回顾与总结
-
Java里的方法分为虚方法和非虚方法,虚方法的调用在编译期由编译器来确定方法的执行版本,在运行时由虚拟机来确定方法的执行版本。
-
静态分派和动态分派
类型 | 分派原理 | 发生阶段 | 应用场景 |
---|---|---|---|
静态分派 | 根据变量的静态类型 | 编译期,由编译器决定选择哪个目标方法 | 重载Overload |
动态分派 | 根据变量的实际类型 | 运行时,由虚拟机决定选择哪个目标方法 | 重写Override |
-
Java是一个支持静态双分派的动态单分派语言
-
可通过 强制类型转换 改变 变量的静态类型
-
当程序没有显示指定变量的静态类型时,会根据变量的真实类型按照一定的优先级匹配规则(分基本类型和引用类型)进行匹配
-
伪动态双分派工作可以规避instanceof