方法调用不等于方法执行,它唯一的任务就是确定被调用方法的版本,即具体调用哪一个方法,暂时不涉及方法内部的运行过程。
在程序运行时,进行方法调用是最普遍、最频繁的操作,在讲解Class文件编译过程时,已经表明此过程不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于“直接引用”)。
此特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至运行期间才能确定目标方法的直接调用。
上篇博文主要讲解了支持虚拟机进行方法调用和执行的数据结构——栈帧,而此篇博文将进入正题,结合理论与实践来进行讲解,涉及知识点如下:
- 方法调用中的解析定义、原理
- 静态、动态分派概念、原理
- 解释执行中的虚拟机执行引擎工作原理
- 代码实例字节码分析JVM运算流程
JVM高级特性与实践(一):Java内存区域 与 内存溢出异常
JVM高级特性与实践(二):对象存活判定算法(引用) 与 回收
JVM高级特性与实践(三):垃圾收集算法 与 垃圾收集器实现
JVM高级特性与实践(四):内存分配 与 回收策略
JVM高级特性与实践(五):实例探究Class类文件 及 常量池
JVM高级特性与实践(六):Class类文件的结构(访问标志,索引、字段表、方法表、属性表集合)
JVM高级特性与实践(七):九大类字节码指令集(实例探究 )
JVM高级特性与实践(八):虚拟机的类加载机制
JVM高级特性与实践(九):类加载器 与 双亲委派模式(自定义类加载器源码探究ClassLoader)
JVM高级特性与实践(十):虚拟机字节码执行引擎(栈帧结构)
JVM高级特性与实践(十二):Java内存模型 与 高效并发时的内外存交互(volatile变量规则)
JVM高级特性与实践(十三):线程实现 与 Java线程调度
JVM高级特性与实践(十四):线程安全 与 锁优化
一. 方法调用
1. 解析(Resolution)
(1)定义
所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用:在类加载的解析阶段,会将其中一部分符号引用转化为直接引用。
这种解析能成立的前提是:方法在程序真正运行前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好,编译器进行编译时就必须确定下来。这类方法的调用称为解析。
(2)编译期可知,运行期不可变
在java语言中符合“编译期可知,运行期不可变”这个要求的方法主要包括:
- 静态方法,与类直接相关
- 私有方法,在外部不可被访问
这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
(3)调用字节码指令
与之对应的是,在JVM 中提供了5条方法调用字节码指令,分别如下:
- invokestatic:调用静态方法。
- invokeespecial:调用实例构造器方法,私有方法和父类方法。
- invokevirtual:调用所有的虚方法。
- invokeinterface:调用接口方法,会在运行时再确定另一个实现此接口的对象。
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在java虚拟机内部 的,而invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的。
(4)非虚方法与虚方法
非虚方法:只要能被invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有:
- 静态方法
- 私有方法
- 实例构造器
- 父类方法
这4类在类加载的时候就会把符号引用解析为对该方法的直接引用。这些方法称为非虚方法。
虚方法:其他的方法称为虚方法(除去final方法);
(5)解析调用示例
在此示例中,静态方法sayHello()
只可能属于类型 StaticResolution,没有任何手段可以覆盖或隐藏这个方法。
以下是方法静态解析的代码:
public class StaticResolution {
public static void sayHello() {
System.out.println("hello world");
}
public static void main(String[] args) {
StaticResolution.sayHello();
}
}
程序的字节码:
使用javap 命令查看字节码,可知的确是通过 invokestatic 命令来调用 sayHello()
方法。
(6)非虚方法补充 —— final 方法
之前在解释 虚方法的定义时将final 方法给排除在外,因为 Java中的非虚方法除了使用 invokestatic、invokespecial调用的方法之外还有被final 修饰的方法。虽然final 方法是使用 invokevirtual 指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无需对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。在Java语言规范中明确说明了 final 方法是一种非虚方法。
(7)解析调用 与 分派调用
解析调用一定是个静态的过程:在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为 可确定的直接引用,不会延迟到运行期再去完成。
分派(Dispatch)调用可能是静态的也可能是动态的:根据分配依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派,静态多分派,动态单分派,动态多分派4种分派组合情况。(下一点中详解)
2. 分派
总所周知Java是一门面向对象的语言,因为Java具备的3个基本特征:继承、封装、多态。本点讲解的分派调用过程将会揭示多态性特征的一些基本体现,如“重载”和“重写”在JVM中是如何实现的。(此处的“实现”并非指语法,而是虚拟机如何确定正确的目标方法)
(1)静态分派
下面会先举出一些面试题中遇到的程序代码,后续将围绕此类的方法来重载(overload)代码,以分析虚拟机和编译器确定方法版本过程,读者不妨思考其程序结果。
方法静态分派演示代码
package org.fenixsoft.polymorphic;
/**
* 方法静态分派演示
* @author zzm
*/
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);
}
}
运行结果:
静态类型 和 实际类型 概念
此代码实际上是考验读者对重载的理解程度,为什么会选择执行参数类型为Human的重载呢?在理解之前,先按如下代码定义两个重要概念:
Human man = new Man();
上面代码中的Human 变量称为变量的静态类型(Static Type)或叫做外观类型(Apparent Type),后面的Man 则称为变量的实际类型(Actual Type)。
静态类型 和 实际类型 的区别
静态类型和实际类型在程序中都可以发生一些变化,以下是区别:
静态类型但仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型在编译期是可知的。
实际类型变化的结果在运行期才可确定,编译器在编译程序时并不知道一个对象的时机类型,例如以下代码:
//实际类型变化
Human man = new Man();
man = new Woman();
//静态类型变化
sr.sayHello((Man) man);
sr.sayHello((Woman) man);
示例分析
再回到示例中,main()
方法中两次sayHello()
方法调用,在方法接收者已经确定是 sr 的前提下,使用哪个重载版本,完全取决于传入参数的数量和数据类型。
代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确而言是编译器)在重载时是通过参数的静态类型并非实际类型作为判定依据的。并且静态类型是编译器可知的,因此在编译期间,Javac编译器会根据参数的静态类型决定使用哪个重载版本。
小结
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,它的典型应用是重载。
静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机而是编译器执行的。
编译器虽然能确定方法的重载版本,但很多情况下并不“唯一”,往往只能说是一个“更加适合”的版本。(产生这种模糊结论的主要原因是字母量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断)
(2)动态分派
动态过程与多态性的另外一个一个体现 —— 重写(Override)有着密切关系。下面还是先通过一个示例来讲解动态分派:
方法动态分派演示代码:
package org.fenixsoft.polymorphic;
/**
* 方法动态分派演示
* @author zzm
*/
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();
}
}
运行结果:
抛出问题
此问题的运行结果更加容易,只是虚拟机是如何知晓调用哪个方法?
显然此处已经无法根据静态类型来决定,因为同样都是Human的两个静态类型变量在调用sayHello()
方法时执行了不同的行为。导致此现象原因很明显,因为两个变量的实际类型不同,JVM是如何根据实际类型来分派方法执行版本的呢?来查看代码的字节码:
字节码分析
查看以上字节码,可见:
0~15行:是在进行一些准备工作,作用是建立 man 和 woman 的内存空间、调用Man、Woman类型的实例构造器,将这两实例的引用存放在第1、2个Slot 局部变量表 中。此动作也对应了如下代码:
Human man = new Man();
Human woman = new Woman();
16~21行(关键): 16、20行这两句将刚创建的两个对象引用压到栈顶,它们时将要执行 sayHello()
方法的所有者,称为接收者(Receiver)。17、21句是方法调用指令,虽然指令一样但最终执行目标方法却不同。原因是需要从 invokevirtual 指令的多态查找过程开始说起,运行时的解析过程大致如下:
- 1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记做 C;
- 2)如果在类型 C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验:
- 如果通过则返回这个方法的直接引用,查找过程结束。
- 如果不通过,则返回 java.lang.IllegalAccessError 异常;
- 3)否则,按照继承关系及从下往上依次对C 的各个父类进行第2步的搜索和验证过程;
- 4)如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError异常
方法重写本质
由于 invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual 指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是 java语言中方法重写的本质。
动态分派定义
这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
(3) 单分派 与 多分派
宗量:方法的接收者与方法的参数统称为方法的宗量;
单分派和多分派:根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派是根据多于一个宗量对目标方法进行选择。
下面根据一个例子来理解单分派和多分派:
/**
* 单分派、多分派演示
* @author zzm
*/
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还是Son,二是方法参数是QQ 还是360。
这次选择结果的最终产物是产生两条invokevirtual指令:两条指令的参数分别是常量池中指向Father.hardChoice(360)以及Father.hardChoice(QQ) 方法的符号引用。因为是根据两个宗量进行选择,所以java语言的静态分派属于多分派类型。
动态分派过程:在执行“
son.hardChoice(new QQ())
” 这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须是hardChoice(QQ)
。虚拟机并不关心传递过来的参数,因为此时参数的静态类型、实际类型都对方法选择不会造成任何影响。唯一可以影响虚拟机选择的因素只有此方法的接收者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以java语言的动态分派属于单分派类型。
小结
今天的 Java语言是一门 静态多分派、动态单分派的语言。按照目前java语言的发展趋势,它并没有直接变为动态语言的迹象,而是通过内置动态语言(如JavaScript)执行引擎的方式来满足动态性的需求
(4)虚拟机动态分派的实现
前面介绍的内容已经涵盖了虚拟机在分派中“会做什么”的问题,但是“具体是如何做到的”,各个虚拟机的实现还是有差别。
动态分派带来的缺陷
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。
虚方法表(Virtual Method Table)
面对这种情况,最常用的稳定优化手段就是为类在方法区中建立一个虚方法表(Virtual Method Table),使用虚方法表索引来代替元数据查找以提高性能,查看以下代码清单所对应的虚方法表结构图:
结构分析
虚方法表中存在着各个方法的实际入口地址。
- 如果某个方法在子类中没有被重写:那子类的虚方法表里的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。
- 如果子类重写了此方法:子类方法表中的地址将会替换为指向子类实现版本的入口地址。
如上图结构示例,Son重写了来自Father的全部方法,所以Son的方法表没有指向Father 类型数据的箭头。但是它们都没有重写来自Object的方法,所以他们的方法表中所有从Object继承来的方法都指向了Object的数据类型。
为了程序实现的方便,具有相同签名的方法,在父类、子类的虚方法表中都应具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
优化手段补充
虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存(Inline Cache)和基于类型继承关系分析(Class Hierarchy Analysis, CHA)技术的守护内联(Guarded Inlining)两种非稳定的激进优化手段来获得更高的性能。
二. 基于栈的字节码解释执行引擎
虚拟机是如何执行方法中的字节码指令?上文已经提到,许多JVM的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译产生本地代码执行)两种选择,在后续来讨论在解释执行中的虚拟机执行引擎是如何工作的。
1. 解释执行
(1)解释执行 还是 编译执行
Java语言经常被定位为“解释执行”的语言,但当主流虚拟机中包含了即时编译后,Class文件中代码到底会被解释执行还是 编译执行,只有虚拟机才能准确判断。再后来,Java发展出可直接生成本地代码的编译器,C/C++语言也出现通过解释器执行的版本,此时再笼统说“解释执行”就毫无意义。
只有确定了谈论对象是否是某种具体的Java实现版本和执行引擎运行模式时,谈“解释执行”或“编译执行”才会比较明确。
(2)编译过程
不论是编译还是解释,物理机或虚拟机,大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,需要经过下图几个步骤:
在上图中,最下面的那条分支就是传统编译原理中程序代码到目标机器代码的生成过程;中间那条分支就是解释执行过程。
(3)独立与非独立语言
对于一门具体语言的实现来说:
- 一类代表是C/C++语言:词法分析,语法分析以及后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言。
- 一类代表是java:也可以把其中一部分实现为一个半独立的编译器, 这类代表是 java语言。
(4)Java程序编译分析
在java语言中,javac 编译器完成了程序代码经过词法分析,语法分析到抽象语法树,在遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在JVM 之外进行的,而解释器是在虚拟机的内部,所以java程序的 编译就是半独立的实现。
2. 基于栈的指令集 与 基于寄存器的指令集
(1) 代码及字节码示例
以上已讲解了理论知识,本节通过代码时间来看虚拟机中实际执行,例子如下:
public class Test {
public static int calc(){
int i=100;
int j=200;
int k=300;
return (i+j)*k;
}
public static void main(String[] args) {
Test.calc();
}
}
javap指令查看字节码:
(2)流程解析
javap 提示这段代码需要深度为2 的操作数栈和4 个Slot的局部变量空间,根据以上流程画出以下7张图来描述代码、操作数栈和局部变量表的变化:
a. 执行编译地址为0 的指令情况
a. 执行编译地址为1 的指令情况
a. 执行编译地址为11 的指令情况
a. 执行编译地址为12 的指令情况
a. 执行编译地址为13 的指令情况
a. 执行编译地址为14 的指令情况
a. 执行编译地址为16 的指令情况
上面的执行过程仅仅是一种概念模型,虚拟机真正执行过程与之差异很大。不过我们可以从这段程序的执行中看出栈结构指令集的一般运行过程,整个运算过程的中间变量都以操作数栈的入栈、出栈为信息交换途径,符合之间分析的特点。
三. 总结
本篇博文总结
上一篇博文和此篇从概念模型的角度来讲解了虚拟机的方法调用和字节码执行。在上篇博文中,讲解了支持虚拟机进行方法调用和执行的数据结构——栈帧,此篇博文的第一大点就结合简单的实例与理论,讲解了虚拟机中方法调用(解析、分派)的原理、运行,而第二大点显示讲解了有关解释执行的相关理论,后续通过图解分析一个例子的字节码执行过程,来了解基于栈的解释器执行过程。
“虚拟机执行子系统”部分总结
总而言之,在“虚拟机字节码执行引擎”章节中,主要学习了如何找到正确的方法、执行方法内的字节码、以及执行代码时涉及的内存结构。再结合之前讲解过的“类文件结构”、“虚拟机加载机制”,针对讲解了Java程序是如何存储、如何载入(创建)、以及如何执行,完成了“虚拟机执行子系统”这部分的学习。
如有错误,虚心指教~