概述
虚拟机是对应物理机的概念,这两种机器都有执行代码的能力,但物理机的执行引擎是建立在处理器、缓存、指令集、操作系统之上的。虚拟机的引擎是由软件自行实现的,不受物理条件的约束定制指令集和执行引擎的结构,所以就能执行那些不被物理结构支持的指令集格式。
在不同的虚拟机执行中,执行引擎有两种执行形式:解释执行(通过解释器执行)和编译执行(通过及时编译器变成本地代码执行),也可能两个都有,也有可能有几个不同级别的及时编译器的执行引擎。但所有虚拟机执行引擎的输入输出是一样的,输入的是字节码二进制流,过程是等效字节码解析,输出是执行结果。
运行时栈帧结构
方法是虚拟机的最基本执行单元,栈帧是支持虚拟机进行方法调用和执行的数据结构,是虚拟机运行的时候虚拟机栈的栈元素;栈帧包含局部变量表、操作数栈、动态连接、方法返回地址和一些额外信息,每个方法调用到执行结束就对应一个栈帧入栈到出栈的全过程。
在编译java源码的时候,栈帧中需要多大的局部变量表、需要多深的操作数栈就已经被计算好了,并且被写入方法表的code属性里。
从java程序的角度来看,同一时间同一线程,位于调用堆栈里的所有方法都处于执行中。但对于执行引擎来说,只有位于调用堆栈最上面的方法才是有效的,被称为当前栈帧,与栈帧关联的方法叫做当前方法。执行引擎执行的所有字节码指令都只对当前栈帧有效。
下面是栈帧的概念结构:
1 局部变量表
一组变量值的储存空间,用于储存方法参数和方法内部定义的局部变量;
在java程序被编译成class文件时,在方法的code属性的max_local里存着该方法所需分配的局部变量表的最大容量。
容量以变量槽为最小单位;槽的大小可以随虚拟机、处理器、操作系统的不同而改变。但需要让变量槽和32位虚拟机中的一致。
一个变量槽能放一个32位以内的数据类型,java中不超过32位的数据类型有int、boolean、byte、short、char、float、reference和returnAddress这8种类型。
reference表示对一个对象实例的引用,可以通过这个引用完成两件事:1、直接或间接的查到对象在java堆里的数据存放的起始地址或索引。2、直接或间接的查到对象所属的数据类型在方法区的存储的类型信息。
对于64位的数据类型(long和double),以高位对齐的方式放到两个变量槽里。
java虚拟机用索引的方式去使用局部变量表,32位数据类型就是索引N代表第N个变量槽,64位数据类型就是代表第N个和第N+1个变量槽。
当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。
局部变量表的变量槽可以重复使用。但会对垃圾回收行为有影响。🐱
局部变量没有准备阶段,没有默认值。
所以这段代码不能执行。
2 操作数栈
操作栈,先入后出栈。操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。
栈里可以是任意数据类型,32位的栈容量为1,64位的栈容量为2。
方法刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。例如执行加法的指令iadd,要求栈最上面两个数是int类型,将其出栈相加,把结果再入栈。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配。
在概念模型里,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但会允许栈帧进行重叠,这样可以节约空间以及共用一部分数据。概念模型如下所示:
3 动态连接
每个栈帧都有一个指向运行中的常量池中该栈帧所属方法的引用,这是为了支持方法调用中的动态连接。
就是常量池里的符号引用在方法运行期间转化为直接引用。
4 方法返回地址
方法开始执行的时候有两种方法可以退出这个方法:1、正常调用完成:执行引擎遇到任意一个方法返回的字节码指令。2、异常调用完成:在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。
在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行。
方法退出的过程实际上等同于把当前栈帧出栈。恢复上层方法的局部变量表和操作数栈等
5 附加信息
例如与调试、性能收集相关的信息。
方法调用
方法调用阶段唯一的任务就是确定被调用方法的版本。
1 解析
调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析,符号引用变为直接引用。
静态方法和私有方法不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。
invokestatic用于调用静态方法、invokespecial用于调用实例构造器()方法、私有方法和父类中的方法、invokevirtual用于调用所有的虚方法、invokeinterface用于调用接口方法,会在运行时再确定一个实现该接口的对象、invokedynamic先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法
这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。
这些方法统称为非虚方法,其他方法就被称为虚方法。
Java中的非虚方法除了使用invokestatic、invokespecial调用的方法之外还有一种,就是被final修饰的实例方法。
解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用。
2 分派
1、静态分派
Human称为变量的静态类型,Man则被称为变量的实际类型。静态类型和实际类型在程序中都可能会发生变化,
静态类型的变化仅仅在使用时发生,而实际类型变化的结果在运行期才可确定。
重载方法选择的优先级:
注释掉sayHello(char arg)方法后:
注释掉sayHello(intarg)方法后
注释掉sayHello(long arg)方法后
注释掉sayHello(Character arg)方法后
注释掉sayHello(Serializable arg)方法后
把sayHello(Object arg)也注释掉后
2、动态分配
invokevirtual指令的运行时解析过程:
1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C
2)在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
3)否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程
4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常
3、单分派与多分派
运行结果如下:
方法的接收者与方法的参数统称为方法的宗量。
根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。
上面这段代码中编译阶段中编译器的选择过程,也就是静态分派的过程,选择目标的依据有两种:一是静态类型是Father还是Son,二是方法参数是QQ还是360。是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
运行阶段中虚拟机的选择,也就是动态分派的过程。由于编译期已经决定目标方法的签名必须为hardChoice(QQ),
唯一可以影响虚拟机选择的因素只有该方法的接受者的实际类型是Father还是Son,因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。
4、虚拟机动态分配的实现
为类型在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。
如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。