虚拟机是一个相对于物理机的概念,物理机执行引擎是基于处理器,硬件,指令集以及操作系统层面上的,而虚拟机引擎完全是有自己实现的,因此格式与结构体系可以自行定制。执行引擎是整个JVM的核心,没有执行引擎 前面所讲的都是白搭。
下面开始来分析JVM的执行引擎
1.运行时栈帧
前面在讲虚拟机内存分布的时候提了下,虚拟机栈是描述java方法执行的内存模型,每一个方法的执行的开始跟结束都对应着一个运行时栈帧在虚拟机栈中的入栈与出栈。
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
栈帧的结构:主要由 局部变量表,操作数栈,动态连接,方法返回地址 等构成
在编译的时候,栈帧中需要多大的局部变量表,多深的操作数栈就已经完全确定,都写入到方法表的code属性中,也就是说一个方法在调用的过程中需要的内存大小是固定不变的。
只有位于栈顶的栈帧才有有效的,对应的方法称为当前方法。
执行引擎运行的所有指令只针对当前栈帧和当前方法。
1.1 局部变量表
局部变量 表是一组变量存储空间,用来存放方法的 方法参数,方法内定义的局部变量,局部变量表的最大容量在编译的时候就已经确定。(code属性中的max_locals)
局部变量表的容量以变量槽(slot)为最小单位,具体的实现比较复杂,只需要记住以下几点就可以了:
1)一个slot可以存放一个32位以内的数据类型,64位的占2个solt。java中明确64位的是long & double
2)方法执行的时候,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程。如果执行的是实例方法(非static方法),那么局部变量表中第0位索引的Slot默认是用来传递方法所属对象的实例的引用,我们方法中常用到的 this 关键字可以访问到这个隐含参数。
3)为了节省栈帧空间,变量槽是可以复用的
4)局部变量不像类变量哪样会有准备和初始化的赋值阶段,所以如果一个局部变量定义了但是没有赋初始值 ,是不能使用的。
1.2 操作数栈(这个不太好理解)
从名字上可以看出来这是一个后入先出的栈,既然是栈那也就是一种数据结构,也就是用来存储数据的。
方法刚开始执行的时候,操作数栈是空的,在方法的执行过程中会有各种字节码指令往操作数栈中写入和提取数据,这也就是对应着 出栈/入栈的过程。操作数栈是一个变量的中转站,局部变量之间的各种运算都在操作数栈上进行
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序的时候编译器要严格保证这点,在类校验阶段的数据流分析中还要再次验证这点。也就是两个int 类型相加,最接近操作数栈顶的两个元素必须是int类型,而不能出现一个是float或者别的类型使用add相加的情况。
如下图所示:
操作数栈工作流程java虚拟机的解释执行引擎称之为 基于栈的执行引擎 ,其中的栈也就是说的 操作数栈。
1.3动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用的目的是为了支持方法调用过程中的动态连接。这些符号引用 一部分 在类加载的解析阶段或者第一次运行期间就会转化为直接引用,这种转化称之为静态解析。儿另外一部分将在每一次运行期间转化为直接引用,这部分称之为动态连接。也就是说在类加载的解析阶段将符号引用转化为直接引用的只有一部分,还有一部分转化是在运行时
1.4方法的返回值
一个方法开始执行后,只有两种方法可以退出这个方法。第一种就是执行引擎遇到任意一个方法返回指令,说片面点就是return指令,这个时候可能会有返回值传递给上层方法调用者。另外一种就是在方法执行过程中产生了异常,一个方法以异常完成出口的方式退出时,是不会给他的上层调用者产生任何返回值的。
方法退出的过程实质就是栈帧出栈的过程,退出时有可能执行的操作有:
1.回复上层方法的局部变量表与操作数栈
2.把返回值压如调用者栈帧的操作数栈中
3.调整PC计数器的值,将其指向方法调用指令的下一条指令
2.方法的调用(重点)
虚拟机如何确定正确的目标方法
方法的调用与方法的执行并不等同,方法的调用的任务只是确定被调用方法的版本(也就是具体是哪个类的哪个方法),暂时不涉及方法内部的具体运行过程。
java的方法调用比较复杂(多态的原因),需要在类加载期间,甚至在运行期间才能确定目标方法的直接引用。
2.1 解析
我们在前面 类加载阶段 的时候提到了解析过程,也就是将常量池中的符号引用转换为直接引用的过程。这种解析有个前提条件:方法在真正运行之前就有一个可确定的调用版本,并且这个调用版本在运行期间是不可改变的。调用目标在代码写好,编译器进行编译时就必须确定下来。
java语言中符合 编译器可知,运行期不变 这个要求的方法主要包括 静态方法 跟 私有方法 两大类
静态方法与类型关联,私有方法外部不可访问。 这两种方法的特点决定了他们都不可能通过继承或者别的方式重写其他版本,因此他们都适合在类加载阶段进行解析。
在解析阶段能确定唯一调用版本的主要有以下四种方法:
1)静态方法
2)私有方法
3)实例构造器
4)父类方法
这四种方法被称之为 非虚方法 ,相反其他的方法称之为 虚方法(final 方法除外)
java虚拟机规范中明确规定:final方法时非虚方法
解析调用一定是静态的,在编译期间就完全确定,但是分派调用却可能是静态也可能是动态的(这句话可以这么理解:如果一个类的方法被重写了,那么这个调用的目标就是不确定的也就是动态的,如果这个方法没有没重写,那么这个调用就会是静态的)
2.2 分派
静态分派:其实国外的技术文档中叫作 :Method OverLoad Resolution 方法重载解析
重载是实现多态的一种重要手段,编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的。如下面的例子:
Human man = new Man(); //Human是静态类型,Man与Woman是实际类型
Human woman = new Woman();如果调用man或者 woman的方法 ,其实际还是会调用Human里面的方法。
依赖静态类型来定位方法执行版本的分派动作叫做静态分配,静态分配的典型应用是 重载(OverLoad)
静态分派发生在编译阶段,因此静态分派的动作实质上不是由虚拟机来执行的。
动态分配:重写(Override)也是面向对象的多态性的另外一个重要体现
invokeVirtual指令是执行虚方法的指令,这个指令的执行过程大致分为以下几个步骤:
1)找到操作数栈顶的第一个元素所指向的对象的 实际类型,记作C
2)如果在类型C中找到与常量中描述符和简单名称都相符的方法,则进行方法的权限访问,如果通过则直接返回该方法的直接引用。
3)否则,则按照继承关系从下往上依次对C的父类递归进行第二步的搜索与验证过程
4)如果始终找不到合适的方法,则抛出异常。
由于invokeVirtual指令的第一步就是在运行期间确定接收者的实际类型,这个过程就是java语言中方法重写的本质。
我们将这种运行期根据实际类型确定方法执行版本的分派过程称之为动态分派
虚拟机中动态分派的实现:
不同的虚拟机有着不同的实现方式,一般而言会在类的方法区建立一个 虚方法表,使用虚方法表的索引来代替元数据的查找以提高性能。
虚方法表中存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那么子类的虚方法表中的地址入口与父类相同,都指向父类的地址入口,如果子类重写了这个方法,那么子类的虚拟方法表中的地址将会替换成指向子类实现版本的入口地址。
现在的java是一门静态多分派,动态单分派的语言
动态类型语言是指它的类型检查的主体过程是在运行期,而不是在编译期间。在编译器检查类型的语言有C++和java等,称之为静态语言。
基于操作数栈的指令集:
基于操作数栈的解释器工作原理
网友评论