字节码执行引擎是Java虚拟机最核心的组成部分之一。虚拟机是相对于物理机的概念,两者都有代码执行能力。不同的是物理机的执行引擎直接建立在物理硬件和操作系统层面上,而虚拟机的执行引擎则有自己的指令集,可以执行不被硬件直接支持的指令Java虚拟机规范中制定了虚拟机执行引擎的概念模型,不同的虚拟机只要满足这个概念模型的要求(输入字节码,处理过程是字节码解析的等效过程,输出执行结果),具体的实现可以自行制定(解释执行、编译执行或者两者结合等)
- 物理机的执行引擎:直接建立在处理器、硬件、指令集和操作系统层面
- 虚拟机的执行引擎:由自己实现,可以自行制定指令集与执行引擎的结构体系,并且能够执行不被硬件直接支持的指令集格式。
- java虚拟机的执行引擎:输入字节码文件,处理过程是字节码解析的等效过程,输出是执行结果。
一、运行时帧栈结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈中的栈元素。
存储内容:方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧的入栈和出栈。
在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到code属性之中,因此一个栈帧需要分配多少内存,不会受程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。但是只有位于栈顶的栈帧才是有效的,称为“当前栈帧”,与这个栈帧相关联的方法称为“当前方法”。执行引擎运行的所有字节码指令只对当前栈帧进行操作。典型的栈帧结构如下:
1、局部变量表
局部变量表(local variable table) 是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。
在java程序编译为Class文件时,在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Variable Slot,简称Slot)为最小单位,每个变量槽都能存放一个32位以内的数据类型由
boolean、byte、char、short、int、float、reference、returnAddress类型数据;64位的数据类型虚拟机以高位对齐的方式
为其分配两个连续的Slot空间,如double、long。
局部变量表通过索引定位的方式使用局部变量表,索引范围是0~局部变量表最大Slot数量;
第0位用于传递方法所属对象实例的引用,可以通过关键字“this”来访问到这个隐含参数。
类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;一次在初始化阶段,赋予程序员定于的初始值。
局部变量定义后没有赋值是不能使用的。
2、操作数栈
操作数栈(operand stack ,操作栈),后入先出(last in first out)的栈,最大深度在编译时写入Code属性的max_stacks数据项中。
操作栈的每一个元素可以是任意的java数据类型,包括long、double,32为数据类型占栈容量1,64位占2;方法执行时,操作数栈的
深度不会超过在max_stacks数据项中设定的最大值。
当一个方法刚刚开始执行时,方法的操作数栈为空,在方法执行过程中,字节码指令向操作数栈写入和提取内容,也就是出栈、入栈操作。
3、动态链接
每个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用,这个引用为了支持调用过程中动态链接(Dynamic linking)
4、方法返回地址
退出方法:
正常完成出口:执行引擎遇到任意一个方法返回的字节码指令
异常完成出口:在执行方法过程中遇到了异常,并且这个异常没有在方法体内处理。
二、方法调用
方法调用不等同与方法执行,方法调用阶段确定调用哪一个方法,不涉及方法内部具体运行过程。
1、解析
所有方法调用中的模板方法在Class文件里都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分的符号引用转化为直接引用,这种解析的前提是在真正运行之前有一个确定的调用方法,并且该方法在运行期是不可变的。换句话说:调用目标在程序代码写好、
编译器进行编译时就必须确定下来,这类方法的调用称为解析(resolution)。Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法;静态方法与类型直接关联,私有方法在外部不可访问,这两种方法各自特点决定了他们都不可能通过继承和重写来确定其他版本,因此都在类加载阶段进行解析。
方法调用字节码指令:
- invokestatic:调用静态方法
- invokespecial:调用实例构造器<init>方法、私有方法、父类方法
- invokevirtual:调用所有虚方法
- invokeinterface:调用接口方法、在运行时在确定一个实现此接口的方法
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后在执行该方法,在此之前的4条调用指令,分派逻辑是固化在java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
- 只要能被invokestatic、invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,
包括静态方法、私有方法、实例构造器、父类方法,在类加载时,会把所有的符号引用解析为该方法的直接调用。这些方法称为非虚方法。
非虚方法还包括:final方法,虽然被invokevirtual方法调用,,但是其无法被覆盖,没有其他版本,也就没有多态选择或者说多态选择唯一。
解析调用是一个静态调用的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用替换为可确定的直接引用,不会延迟到运行期完成。
2、分派
分派调用可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派,两类组合就构成了静态单分派、静态多分派、动态单分派、动态多分派。
- 静态分派
public class StaticDispatch {
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
public void sayHello(Human human) {
System.out.println("hello guy!");
}
public void sayHello(Man guy) {
System.out.println("hello,gentleman!");
}
public void sayHello(Woman woman) {
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);
}
}
//输出结果:
hello guy!
hello guy!
“Human”称为静态类型(static type),或者叫做外观类型(apparent Type);后面的“Man”则称为变量的实际类型(Actual Type)。
静态类型是在编译期可知的,实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么,使用哪个重载版本,完全取决于入参的数量和数据类型,虚拟机(准确的说是编译器)在重载时是通过参数的静态类型而不是实际类型
作为判断依据的,并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。
所依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派典型的应用方法是重载。
静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,
- 动态分派
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();
}
输出结果:
man say hello
woman say hello
woman say hello
invokevirtual指令运行时解析的过程:
1、找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
2、如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束。
如果不通过,返回java.lang.IllegalAccessError异常。
3、否则,按照继承关系从下往上一次对C的各个父类进行第2步的搜索和验证过程。
4、如果始终没有找到合适的方法,则抛出java.lang.AbstactMethodError异常。
由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引
用解析到了不同的直接引用上,这个过程就是java语言重新的本质,我们把在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
- 单分派与多分派
静态分派属于多分派类型,因为首先需确实静态类型,然后确定方法参数
动态分派属于单分派类型,因为在执行invokevirtual指令时,已经确定所执行的方法,而可以影响虚拟机选择的因素只有实际类型。 - 虚拟机动态分派的实现
通过虚方法表存放各个方法的实际入口地址,如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法
的地址入口是一致的,都指向父类的实现入口;如果子类中重写了这个方法,子类方法表中的地址将会诶替换为指向子类实现版本的入口地址。
java 1.7 是一个动态单分派、静态多分派的语言
三、基于栈的字节码解释执行引擎
上面已经把java虚拟机是如何调用方法讲完了,那么接下来就是虚拟机是怎么执行这些字节码指令的.虚拟机在执行代码时都有解释执行和编译执行两种选择。
解释执行
java语言刚开始的时候被人们定义为解释执行的语言,在jdk1.0来说是比较准确的,但随着虚拟机的发展,虚拟机中开始包含了即时编译器后,class文件中的代码到底是解释执行还是编译执行恐怕只有虚拟机自己才能判断了。
不过不管是解释还是编译,不管是物理机还是虚拟机,对于应用程序,机器肯定是无法像人一样阅读和理解,然后获得执行能力。大部分的程序代码到物理机或者虚拟机可执行的字节码指令集,都需要经历多个步骤,如下图,而中间那条就是解释执行的过程。
Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历树生成线性的字节码指令流的过程。因为一部分在虚拟机外,而解释器在虚拟机的内部,所以java程序的编译就是半独立的实现。
基于栈的指令集与基于寄存器的指令集
Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流里面大部分都是零地址指令看,他们依赖操作数栈进行工作。与之相对的另外一套常用指令集架构是基于寄存器的指令集。
两者优缺点:
1.基于栈的指令集主要优点就是可移植性,但因为相同的动作该指令集需要频繁操作内存,且多于寄存器指令集,速度就慢。
2.基于寄存器指令集主要优点就是速度快,操作少。但是因为寄存器是依赖于硬件的,所以它的移植性受到影响。
基于栈的解释器执行过程
这一内容通过一个一个四则运算进行讲解,下面是代码:
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a+b) * c;
}
下面是字节码执行过程图(包含字节码指令、pc计数器、操作数栈、局部变量表):
上面的演示,是一个概念模型,实际上肯定不会跟这个一样的,因为虚拟机中的解释器和即时编译器都会对输入的字节码进行优化。
本章内容思维导图:
参考:
https://blog.51cto.com/4837471/2159773