JVM Bytecode Execution Engine Subsystem
JVM执行引擎是Java虚拟机核心组件之一。物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上,而虚拟机的执行引擎是自己实现的,可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
运行时栈帧结构
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,只是很有导向性地说到每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存放。
方法执行时,如果执行的是实例方法,那局部变量表中第0位索引的默认是this的引用,即实例本身。
注1:与虚拟机模型设计不同的是,执行引擎的实现为了节约局部变量表的空间,局部变量表的Slothi可以重用的。
注2:局部变量定义了但没有赋初始值是不能使用的,因为局部变量的加载没有类加载的准备和初始化阶段。
操作数栈
操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出(Last In First Out, LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。
动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接
方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。
第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
附加信息
调试信息等,属于虚拟机可以自由实现的部分。
方法调用
方法调用阶段是确定被调用方法版本的过程。Java的编译过程并不存在连接过程,是在JVM运行时进行动态调用的。
解析
在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。
- invokestatic:调用静态方法。
- invokespecial:调用实例构造器
<init>
方法、私有方法和父类方法。 - invokevirtual:调用所有的虚方法。
- invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的。其中只要能被invokestatic和invokespecial指令调用的方法(即非虚方法),都属于静态解析可以确定调用版本的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
分派Dispatch
分派调用过程是Java多态的一种基本体现,主要是有重载、重写两块。
静态分派
在编译阶段,依赖静态类型来定位方法执行版本的动作成为静态分派。典型应用是方法重载。但是,在很多情况下,重载版本并不唯一,所以虚拟机在运行时也会选更加合适的版本。
静态分派示例:
1 |
|
1 | hello, guy! |
重载方法匹配优先级代码示例:
1 | package org.fenixsoft.polymorphic; |
- 代码输出:
1
hello char
- 注释掉sayHello(char arg)方法,代码输出:
1
hello int
- 注释掉sayHello(int arg),代码输出:
1
hello long
- 注释掉sayHello(long arg),代码输出:
1
hello Character
- 注释掉sayHello(Character arg),代码输出:
1
hello Serializable
- 注释掉sayHello(Serializable arg),代码输出:
1
hello Object
- 注释掉sayHello(Object arg),代码输出: 这个示例生动的展示了JVM在运行时静态分派时,是从继承关系中从下往上开始搜索,越接近上层的优先级越低。即使方法调用传入的参数值为null,这个规则仍然适用。变长参数的重载优先级是最低的。
1
hello char ...
动态分派
动态分派是重写的重要体现。
动态分派示例:
1 | package org.fenixsoft.polymorphic; |
运行结果:
1 | man say hello |
从字节码的角度来看, sayHello()方法均是通过invokevirtual指令触发,但是最终的执行方法版本却完全不同,invokevirtual执行的运行时解析过程如下:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang. IllegalAccessError异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
单分派与多分派
方法的宗量,即方法的接收者与方法的参数统称。可以有单宗量分派,即根据一个宗量对目标方法进行选择。也可以有多宗量分派,即根据多个宗量对目标方法进行选择。Java的静态分派属于多分派类型。JVM在运行时动态分派属于单宗量分派。
单分派和多分派代码示例:
1 | public class Dispatch { |
运行结果:
1 | father choose 360 |
虚拟机动态分派的实现
处于性能考虑,动态分派常用”稳定优化“手段:在类的方法区建立一个虚方法表(Virtual Method Table, vtable),和接口方法表(Interface Method Table, itable)。从而虚拟机不需要进行元数据查找,直接通过虚方法表确定应该执行的方法版本。
动态类型语言支持
动态语言的关键特征是它的类型检查的主体过程实在运行期而不是编译期,代码会更加简洁。而静态语言在编译器确定类型,最显著的好处是编译器可以提供严谨的类型检查,利于稳定性及代码达到更大规模。目前JVM支持的动态语言有Clojure, Groovy, Jython, JRuby等。
字节码解释执行引擎
本节探讨的是JVM将会如何对方法中的字节码进行解释执行的。
- 传统编译过程是从程序源码到目标代码的一个过程,代表有C/C++语言。
- Java是采用了现代的编译原理思路,把源码转化成抽象语法树,再由JVM进行解释执行,属于编译半独立实现。C#也是一种半独立实现的编译语言。
- 而有些语言则将词法分析,抽象语法树,解释执行都封装在一起,例如JavaScript执行器,这类语言一般属于动态语言。
指令集架构
现在的指令执行主要有两种执行方式:
- 基于栈的指令集架构
- 可移植
- 执行速度相对较慢
- 基于寄存器的指令集架构
- 执行速度快
Java是基于栈的指令集架构。