深入理解java虚拟机笔记(6)虚拟机字节码执行引擎2

三、方法调用

方法调用并不等同于方法中的代码被执行, 方法调用阶段唯一的任务就是确定调用哪一个方法 , 暂时还未涉及方法内部的具体运行过程。 在程序运行时, 进行方法调用是最普遍、 最频繁的操作之一。

但第7章中已经讲过, Class文件的编译过程中不包含传统程序语言编译的连接步骤, 一切方法调用在Class文件里面存储的都只是符号引用, 而不是方法在实际运行时内存布局中的入口地址(直接引用) 。 这个特性给Java带来了更强大的动态扩展能力, 但也使得Java方法调用过程变得相对复杂, 某些调用需要在类加载期间, 甚至到运行期间才能确定目标方法的直接引用。

3.1.解析

承接前面关于方法调用的话题, 所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用, 在类加载的解析阶段, 会将其中的一部分符号引用转化为直接引用, 这种解析能够成立的前提是: 方法在程序真正运行之前就有一个可确定的调用版本, 并且这个方法的调用版本在运行期是不可改变的。 换句话说, 调用目标在程序代码写好、 编译器进行编译那一刻就已经确定下来。 这类方法的调用被称为解析(Resolution) 。

在Java语言中符合“编译期可知, 运行期不可变”这个要求的方法, 主要有静态方法和私有方法两大类, 前者与类型直接关联, 后者在外部不可被访问, 这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本, 因此它们都适合在类加载阶段进行解析。

调用不同类型的方法, 字节码指令集里设计了不同的指令。 在Java虚拟机支持以下5条方法调用字节码指令, 分别是:

·invokestatic。 用于调用静态方法。

·invokespecial。 用于调用实例构造器<init>()方法、 私有方法和父类中的方法。

·invokevirtual。 用于调用所有的虚方法。

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

·invokedynamic。 先在运行时动态解析出调用点限定符所引用的方法, 然后再执行该方法。

前面4条调用指令, 分派逻辑都固化在Java虚拟机内部, 而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。

只要能被invokestatic和invokespecial指令调用的方法, 都可以在解析阶段中确定唯一的调用版本,Java语言里符合这个条件的方法共有静态方法、 私有方法、 实例构造器、 父类方法4种, 再加上被final修饰的方法(尽管它使用invokevirtual指令调用) , 这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。 这些方法统称为“非虚方法” , 与之相反, 其他方法就被称为“虚方法”(Virtual Method) 。

Java中的非虚方法除了使用invokestatic、 invokespecial调用的方法之外还有一种, 就是被final修饰的实例方法。 虽然由于历史设计的原因, final方法是使用invokevirtual指令来调用的, 但是因为它也无法被覆盖, 没有其他版本的可能, 所以也无须对方法接收者进行多态选择, 又或者说多态选择的结果肯定是唯一的。 在《Java语言规范》 中明确定义了被final修饰的方法是一种非虚方法。

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

而另一种主要的方法调用形式: 分派(Dispatch) 调用则要复杂许多, 它可能是静态的也可能是动态的, 按照分派依据的宗量数可分为单分派和多分派。 这两类分派方式两两组合就构成了静态单分派、 静态多分派、 动态单分派、 动态多分派4种分派组合情况, 下面我们来看看虚拟机中的方法分派是如何进行的

3.2. 分派

众所周知, Java是一门面向对象的程序语言, 因为Java具备面向对象的3个基本特征: 继承、 封装和多态。 本节讲解的分派调用过程将会揭示多态性特征的一些最基本的体现, 如“重载”和“重写”在Java虚拟机之中是如何实现的, 这里的实现当然不是语法上该如何写, 我们关心的依然是虚拟机如何确定正确的目标方法。

1.静态分派

为了解释静态分派和重载(Overload) , 笔者准备了一段经常出现在面试题中的程序代码, 读者不妨先看一遍, 想一下程序的输出结果是什么。 后面的话题将围绕这个类的方法来编写重载代码, 以分析虚拟机和编译器确定方法版本的过程。 程序如代码清单8-6所示。

虚拟机(准确地说是编译器) 在重载时是通过参数的静态类型而不是实际类型作为判定依据的。 由于静态类型在编译期可知, 所以在编译阶段, Javac编译器就根据参数的静态类型决定了会使用哪个重载版本.

所有依赖静态类型来决定方法执行版本的分派动作, 都称为静态分派。 静态分派的最典型应用表现就是方法重载。 静态分派发生在编译阶段, 因此确定静态分派的动作实际上不是由虚拟机来执行的, 这点也是为何一些资料选择把它归入“解析”而不是“分派”的原因。

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

2.动态分派 了解了静态分派, 我们接下来看一下Java语言里动态分派的实现过程, 它与Java语言多态性的另外一个重要体现重写有着很密切的关联。 我们还是用前面的Man和Woman一起sayHello的例子来讲解动态分派, 请看代码清单8-8中所示的代码

根据《Java虚拟机规范》 ,invokevirtual指令的运行时解析过程大致分为以下几步:

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

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

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

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

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

既然这种多态性的根源在于虚方法调用指令invokevirtual的执行逻辑, 那自然我们得出的结论就只会对方法有效, 对字段是无效的, 因为字段不使用这条指令。 事实上, 在Java里面只有虚方法存在,字段永远不可能是虚的, 换句话说, 字段永远不参与多态, 哪个类的方法访问某个名字的字段时, 该名字指的就是这个类能看到的那个字段。

当子类声明了与父类同名的字段时, 虽然在子类的内存中两个字段都会存在, 但是子类的字段会遮蔽父类的同名字段。

3.单分派与多分派

方法的接收者与方法的参数统称为方法的宗量, 这个定义最早应该来源于著名的《Java与模式》一书。 根据分派基于多少种宗量, 可以将分派划分为单分派和多分派两种。 单分派是根据一个宗量对目标方法进行选择, 多分派则是根据多于一个宗量对目标方法进行选择。

Java语言是一门静态多分派、 动态单分派的语言。按照目前Java语言的发展趋势, 它并没有直接变为动态语言的迹象, 而是通过内置动态语言(如JavaScript) 执行引擎、 加强与其他Java虚拟机上动态语言交互能力的方式来间接地满足动态性的需求。

但是作为多种语言共同执行平台的Java虚拟机层面上则不是如此, 早在JDK 7中实现的里面就已经开始提供对动态语言的方法调用支持了, JDK 7中新增的invokedynamic指令也成为最复杂的一条方法调用的字节码指令, 稍后笔者将在本章中专门开一节来讲解这个与Java调用动态语言密切相关的特性。

4.虚拟机动态分派的实现

前面介绍的分派过程, 作为对Java虚拟机概念模型的解释基本上已经足够了, 它已经解决了虚拟机在分派中“会做什么”这个问题。 但如果问Java虚拟机“具体如何做到”的, 答案则可能因各种虚拟机的实现不同会有些差别。

动态分派是执行非常频繁的动作, 而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法, 因此, Java虚拟机实现基于执行性能的考虑, 真正运行时一般不会如此频繁地去反复搜索类型元数据。

面对这种情况, 一种基础而且常见的优化手段是为类型在方法区中建立一个虚方法表(Virtual Method Table, 也称为vtable, 与此对应的, 在invokeinterface执行时也会用到接口方法表——Interface Method Table, 简称itable) , 使用虚方法表索引来代替元数据查找以提高性能[8]。 我们先看看代码清单8-11所对应的虚方法表结构示例, 如图8-3所示

虚方法表中存放着各个方法的实际入口地址。 如果某个方法在子类中没有被重写, 那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的, 都指向父类的实现入口。 如果子类中重写了这个方法, 子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。

在图8-3中, Son重写了来自Father的全部方法, 因此Son的方法表没有指向Father类型数据的箭头。 但是Son和Father都没有重写来自Object的方法, 所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。

为了程序实现方便, 具有相同签名的方法, 在父类、 子类的虚方法表中都应当具有一样的索引序号, 这样当类型变换时, 仅需要变更查找的虚方法表, 就可以从不同的虚方法表中按索引转换出所需的入口地址。 虚方法表一般在类加载的连接阶段进行初始化, 准备了类的变量初始值后, 虚拟机会把该类的虚方法表也一同初始化完毕。

 

 

上文中笔者提到了查虚方法表是分派调用的一种优化手段, 由于Java对象里面的方法默认(即不使用final修饰) 就是虚方法, 虚拟机除了使用虚方法表之外, 为了进一步提高性能, 还会使用类型继承关系分析 、 守护内联 、 内联缓存等多种非稳定的激进优化来争取更大的性能空间.

posted @ 2022-03-21 13:52  Mars.wang  阅读(35)  评论(0编辑  收藏  举报