执行引擎是Java虚拟机最为核心的组成部分之一. 虚拟机是一个相对于物理机的概念, 两种及其都有代码执行能力, 其区别是物理机的执行引擎是直接建立在处理器, 硬件, 指令集和操作系统层面上的, 而虚拟机的执行引擎则是由自己实现的, 因此可以自行指定指令集与执行引擎的结构体系, 并且能够执行哪些不被硬件直接支持的指令集格式
1.运行时栈帧结构
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构, 它是由虚拟机运行时数据区中的虚拟机栈中的栈元素. 栈帧存储了方法的局部变量表, 操作数栈, 动态连接和方法返回地址等信息. 每一个方法从调用开始到执行完成的过程, 都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程
在编译程序代码的时候, 栈帧中需要多大的局部变量表, 多深的操作数栈都已经完全确定了, 并且写入到方法表中的Code属性中, 因此一个 栈帧需要分配多少内存 ,不会受到程序运行期变量数据的影响.
一个线程中的方法调用链可能会很长, 很多方法都同时处于执行状态 . 对于执行引擎来说, 在活动线程中, 只有位于栈顶的栈帧才是有效的, 称为当前栈帧, 与这个栈帧相关联的方法称为当前方法. 执行引擎 运行的所有字节码指令都 只针对当前栈帧进行操作
1>局部变量表
局部变量表 是一组变量值存储空间, 用于存放方法参数和方法内部定义的局部变量. 在Java程序编译为Class文件时, 就在方法的Code属性的max_loacls数据项中确定了该方法所需要分配的局部变量表的最大容量
局部变量表的容量以变量槽(Slot)为最小单位, 每个Slot都能存放一个boolean, byte, char, short, int, float, reference或returnAddress类型的数据. 前几种数据类型都可以用32位来存放, 但第七种reference类型表示对一个对象实例的引用, 虚拟机没有直接规定其长度和结构, 但是应能通过对象引用做到两点 : 1.从引用中直接或简介地查找到对象在Java堆中的数据存放的起止地址索引 2.从引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息
对于64位的数据, 虚拟机会以高位对齐的方式为其分配两个连续的Slot空间,Java中明确的(reference类型可能是32位可能是64位) 64位的数据类型只有long 和double两种. 由于局部变量表建立在线程的堆栈上, 是线程私有的数据, 无论读写两个连续的Slot是否为原子操作, 都不会引起数据安全问题
在执行方法时, 虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的, 如果执行的是实例方法, 那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用
为了尽可能节省栈帧空间, 局部变量表中的Slot是可以重用的, 方法体中定义的变量, 其作用域并不一定会覆盖整个方法体, 如果当前字节码PC计数器的值已经超出了某个变量的作用域, 那这个变量对应的Slot就可以交给其他变量使用
2>操作数栈
操作数栈也被称为操作栈, 是一个后入先出栈, 其最大深度也在编译的时候写入到Code属性的max_stacks数据项中.
在做算数运算的时候是通过操作数栈进行的, 调用其他方法的时候是通过操作数栈进行参数传递的
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配, 在编译程序代码的时候, 编译器要严格保证这一点, 在类校验阶段的数据流分析中还要再次验证这一点
另外, 在概念模型中, 两个栈帧作为虚拟机栈的元素, 是完全相互独立的. 但在大多数虚拟机的视线里都会做一些优化处理, 令两个栈帧出现一部分重叠, 让下面战阵的部分操作数栈, 与上面栈帧的部分局部变量表重叠在一起, 这样在进行方法调用时就可以公用一部分数据, 无须进行额外的参数复制传递
3>动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用, 持有这个引用是为了支持方法调用过程中的动态连接. Class文件的常量池中有大量的符号引用, 字节码的方法调用指令就以常量池中指向方法的符号引用作为参数. 这些符号引用一部分会在类加载阶段或者第一次使用的时候就转为直接引用, 这种转化叫做静态解析; 另一部分会在每一次运行期间转化为直接引用, 这部分称为动态连接
4>方法返回地址
当一个方法开始执行后, 只有两种方式可以退出这个方法, 第一种方式是执行引擎遇到任意一个方法返回的字节码执行, 这时候可能会有返回值传递给上层方法的调用者, 是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定, 这种退出方法的方式称为正常完成出口
另外一种退出方式是, 在方法执行过程中遇到了异常, 并且这个异常没有在方法体内得到处理, 这种退出方法的方式称为异常完成出口
无论采取何种退出方式, 在方法退出后, 都需要返回到方法被调用的位置, 程序才能继续执行, 方法返回时可能需要在栈帧中保存一些信息, 用来恢复它的上层方法的执行状态. 一般来说, 方法正常退出时, 调用者的PC计数器的值可以作为返回地址, 栈帧中很可能会保存这个计数器值.
方法退出过程实际上就是把当前栈帧出栈, 因此退出时的可能操作有 : 恢复上层方法的局部变量表和操作数栈, 把返回值亚茹调用者栈帧的操作数栈中, 调整PC计数器的值以指向方法调用后面一条指令等
2.方法调用
方法调用阶段唯一的任务就是 确定被调用方法的版本(即调用哪一个方法). 前面说过, Class文件的编译过程不包含传统编译中的连接过程, 一切方法调用在Class文件里存储的都只是符号引用, 而不是方法在实际运行时内存布局的入口地址(直接引用). 这个特性给Java带来了更强大的扩展能力, 但也使Java方法调用过程变得复杂起来, 需要在类加载期间, 甚至到运行期间才能确定目标方法的直接引用
1>解析
在类加载的解析阶段, 会将其中的一部分符号引用转化为直接引用, 这种解析的前提是 :方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的. Java语言中符合"编译期可知,运行期不变"这个要求的方法, 主要包括静态方法和私有方法两种, 前者与类型直接关联, 后者在外部不可访问, 这两种方法各自的特点都决定了它们不可能通过继承或别的方式重写其他版本, 因此它们都适合在类加载阶段进行解析
在Java虚拟机中提供了5条方法调用字节码指令
- invokestatic : 调用静态方法
- invokespecial : 调用实例构造器 <init> 方法, 私有方法和父类方法
- invokevirtual : 调用所有的虚方法
- invokeinterface : 调用接口方法, 会在运行时再确定一个实现此接口的对象
- invokedynamic : 现在运行时动态解析出调用点限定符所引用的方法, 然后再执行该方法. 前面的4条调用指令, 分派逻辑是固化在Java虚拟机内部的, 而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的
只要能被invokestatic和invokespecial指令调用的方法, 都可以在解析阶段中确定唯一的调用版本, 符合这个条件的有静态方法, 私有方法, 实例构造器, 父类方法4类, 它们在类加载的时候就会把符号引用解析成直接引用, 这些方法可以称为非虚方法. 与之相对 其他方法(除了final方法)叫做虚方法. 虽然final方法是通过invokevirtual指令调用的, 但是因为它没法被覆盖, 也没有其他版本,所以final方法 是非虚方法
解析调用是一个静态的过程, 在编译器就完全确定, 在类装载的解析阶段就把涉及的符号引用转化为直接引用. 而分派调用可能是静态的也可能是动态的, 根据其宗量数还可以分为单分派和多分派, 下面看虚拟机中的方法分派是如何进行的
2>分派
Java是一门面向对象的语言, 其3大基本特征有 : 继承,封装和多态. 分派调用的过程会揭示多态性特征的一些最基本的体现, 如"重载"和"重写"在Java虚拟机中是如何实现的, 也就是虚拟机如何 确定正确的目标方法
静态分派
重载依赖静态分派
在进行编码时, 定义变量时的类型称为静态类型, 而变量实际引用的对象的类型称为实际类型, 静态类型和实际类型在编码时都可以发生一些变化, 区别是静态类型的变化仅仅 在使用时发生, 变量本身的静态类型不会变化, 并最终的静态类型是在编译器可知的. 而实际类型的变化结果在运行期才能确定, 编译器在编译程序的时候并不知道一个对象的实际类型是什么
//实际类型变化
Human man = new Man();
man = new Woman();
//静态类型变化
sr.sayHello((Man) man);
sr.sayHello((Woman) man);
所以在方法接受者确定的情况下, 使用哪个重载版本, 就完全取决于传入参数的数量和数据类型. 虚拟机重载时是通过参数的静态类型而不是实际类型来作为判决依据的.所有依赖静态类型来定位方法执行版本的分派动作称为静态分派.
动态分派
重写依赖动态分派
子类实现了父类的方法, 如果用子类的对象(无论变量是父类类型还是子类类型)调用该方法, 实际上输出的都会是子类调用的结果, 因为在执行时判断了方法接收对象的实际类型
invokevirtual在运行时解析大致分为以下几个步骤
- 1.找到操作数栈顶的第一个元素所指向的对象的实际类型C
- 2.如果在类型C中找到与常量中的描述符和简单名称都相符的方法, 则进行权限访问校验, 如果通过则返回这个方法的直接引用, 查找过程结束; 否则返回java.lang.IllegalAccessError异常
- 3.否则, 按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程
- 4.如果始终没有找的合适的方法, 抛出java.lang.AbstractMethodError异常
这个过程就是Java语言方法重写的本质, 把这种在运行期根据实际类型确定方法执行版本的过程称为动态分派
单分派与多分派
方法的接受者与方法的参数统称为方法的宗量. 根据分派基于多少宗量, 可以把分派划分为单分派和多分派两种.单分派是根据一个宗量对目标方法进行选择, 多分派则是根据多个宗量对目标方法进行选择
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语言的动态分派属于单分派类型
动态分派的实现
由于动态分派是非常频繁的动作, 而且动态分派的方法版本选择过程需要运行在累的方法元数据中搜索合适的目标方法, 因此在虚拟机的实际实现中基于性能的考虑, 大部分 不会进行如此频繁地 搜索. 最常用的"稳定优化"手段就是为类在方法区建立一个虚方法表, 与此对应的, 在invokeinterface执行的时候也会用到接口方法表.
虚方法表中存放着各个方法的实际入口地址, 如果某个方法在子类中没有重写, 那子类的虚方法表里的地址入口就和父类相同方法的入口地址是一样的, 都指向父类的实现入口. 如果子类中重写了这个方法, 子类方法表中的地址会替换为指向子类实现版本的入口地址
3.动态类型语言支持
invokedynamic指令是jdk7实现"动态类型语言"支持而进行的改进之一, 也是jdk8可以顺利实现Lambda表达式做技术准备
JDK 1.7引入了java.lang.invoke包, 这个包的主要目的是在之前单纯依靠符号引用确定调用的目标方法这种方式之外, 提供一种新的动态确定目标方法的机制, 称为MethodHandle, 这样Java语言也可以拥有类似于函数指针或者委托的方法别名的工具了
仅在Java语言的角度看, MethodHandle的使用方法和效果与Reflection有很多相似之处, 但是他们还是有却别
- 本质上说, Reflection和MethodHandle都是在模拟方法调用, 但Reflection是在模拟Java代码层次的方法调用, 而MethodHandle实在模拟字节码层次的方法调用
- Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethHandle对象包含的信息多. Reflection是重量级, MethodHandle是轻量级
- Reflection API的设计目标只是为Java语言服务的, 而MethodHandle则设计成可服务于所有Java虚拟机上的语言
invokedynamic指令与MethodHandle机制的作用是 一样的, 都是为了解决原有4条"invoke*"指令方法分派规则固化在虚拟机之中的问题, 把如何查找目标 方法的决定权从虚拟机转嫁到具体用户代码上.
每一处含有invokedynamic指令的位置都叫做**“动态调用点”**, 这条指令第一个参数为JDK1.7新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中我们可以得到3项信息 : 引导方法,方法类型, 名称. 虚拟机根据其中的信息, 可以找到并执行引导方法, 聪的得到一个CallSite对象, 最终调用要执行的目标方法
4.基于栈的字节码解释执行引擎
Java语言中, Javac编译器完成了程序代码经过词法分析,语法分析到抽象语法树, 再遍历语法树生成现行的 字节码指令流的过程. 因为这一部分动作是在Java虚拟机之外进行的, 而解释器在虚拟机的内部, 所以Java程序的编译就是半独立的实现
Java编译器输出的指令流, 基本上是一种基于栈的指令集架构, 指令流中的指令大部分都是零地址指令, 他们依赖操作数栈进行工作, 与此对应的另外一套常用的指令集架构 是基于寄存器的指令集. 基于栈指令集的主要优点是可移植性好, 由虚拟机实现来自行决定 把一些访问最频繁的数据(程序计数器, 栈顶缓存)等放到寄存器中. 且代码相对紧凑, 编译器实现更简单; 但缺点是执行速度相对来说会 稍慢一些, 虽然栈架构指令代码紧凑, 但是频繁的出栈入栈操作产生了相当多的指令数量, 且栈实现在内存之中, 频繁地栈 访问意味着频繁的内存访问, 对 处理器来说, 内存访问始终是执行速度的瓶颈. 尽管虚拟机可以采取栈顶缓存的手段, 把最常用的操作映射到寄存器中避免直接内存访问, 但这也只是优化措施. 由于指令数量和内存访问的原因, 导致了栈架构指令集的 执行速度会相对较慢