前述
本来这边博文是在上月发布的,由于保存图片的七牛云图床域名过期,在申请域名和备案过程中耗了时间。后面的博文依然每月更新一篇。
1、介绍
执行引擎说白点就是执行代码,在了解虚拟机如何执行代码之前,来看看方法执行的过程,如下图执行简单的类所示:
这里涉及到的运行时数据区域有方法区、堆、虚拟机栈。方法区存放类,堆中存放类的对象、虚拟机栈存放需要执行的方法。
Java运行代码是按照方法为基本单位的,执行方法的同时会创建一个栈帧,方法的执行开始到结束,对应着该方法的栈帧在虚拟机栈中入栈和出栈操作。
2 虚拟机栈
虚拟机栈描述的是Java方法执行的内存模型,栈中存放的是栈帧。和程序计数器、本地方法栈一样,虚拟机栈也是线程私有的,它的生命周期与线程相同。一个线程的方法调用链可能很长,很多方法都同时处于执行状态。对于执行引擎来说,活动线程中,只有栈顶的栈帧有效,称为当前栈帧,这个栈帧所关联的方法称为当前方法。
3 运行时栈帧结构
栈帧中存放的信息有局部变量表、操作数栈、动态链接、方法返回地址以及一些其他信息,存放这些信息所占的内存是固定的,在虚拟机创建栈帧的同时就确定了,它不会随运行期而改变,仅取决于虚拟机的实现。栈帧用于支持虚拟机进行方法调用和方法执行。
3.1 局部变量表
局部变量表是一组存储空间,用于存放方法参数和局部变量,其容量大小在栈帧创建的时候就确定了,在方法的Code属性的locals数据项中指明了该容量大小。
变量分为类变量和局部变量,类变量在类加载过程中会经历准备和初始化两个阶段,在准备阶段赋予类变量系统默认值,在初始化阶段赋予类变量程序员定义的值,且不进行人为赋值同样可以使用;而局部变量需要人为赋值在进行使用。
局部变量表的容量以变量槽(Slot)为最小基本单位,JVM规范中并未指明Slot占用的内存大小,但可以存放占用32位以内的数据,包括6种基本数据类型、对象引用(reference)和returnAddress。局部变量表的第0位索引默认存储的内容为方法所属对象实例的引用,可以使用this关键字访问此隐含参数。局部变量表中的变量槽是可以重复使用的,当局部变量超出作用域,且方法中继续操作局部变量表的指令,则回收超出作用域的局部变量所占的变量槽供重新分配使用,便于节省栈空间。
3.2 操作数栈
操作数栈通常称为操作栈,与局部变量表一样,其做大深度在栈帧创建时以确定,在方法的Code属性的max_stacks数据项中指明。
操作栈能够保存任意的Java数据类型,32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。
3.3 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有该引用是为了支持方法调用过程中的动态连接。在常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中执行方法的符号引用作为参数。
这些符号引用有些在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析,例如static 方法;另一部分将在每一次的运行期间转化为直接引用,这种转化称为动态链接,例如接口方法。
3.4 方法返回地址
方法执行结束过程相当于栈帧出栈操作,因此在退出操作的同时可能会执行的操作有:恢复上层方法的局部变量表和操作数栈、把返回值压入操作数栈、调整程序计数器的值以其指向方法调用指令后面的一条指令。
4 方法调用
方法调用不等同于方法执行,方法调用阶段的唯一任务是确定被调用方法的版本(即调用哪一个方法)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6BMDBQui-1618973437279)(http://image.ttsource.net.cn/15402884363611.jpg#pic_center)]
例如上图所示的偏移地址为17的指令,调用符号引用为Human.SayHello()方法,那它调用的直接引用的方法是Man.sayHello()方法,还是Woman.SayHello()方法,方法调用解决的就是这个问题。
4.1 解析调用
前面已提过,方法调用中的目标方法在Class文件里都是一个运行时常量池中的符号引用。在类加载阶段,部分符号引用会转化为直接引用,这个转化的前提是:
方法在程序真正运行之前就有一个可以确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。也就是说,调用目标在程序代码中写好、编译器进行编译时就必须确定下来,这类方法的调用称为解析。
满足解析的方法主要有静态方法和私有方法两大类,这两种方法都不可通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。
解析调用一定是个静态的过程,在编译期就完全确定,在类装载的解析阶段就会把涉及的服药引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派调用则可能是静态的,也有肯能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派四种情况。
4.1.1 方法调用字节码指令
- invokestatic:调用静态方法
- invokespecial: 调用实例构造器方法、私有方法和父类方法
- invokevirtual: 调用所有的虚方法
- invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器和父类方法四类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,反之称为虚方法。
4.2 分派调用
在分派调用中揭露Java中是如何实现重载和重写的。
4.2.1 静态分派
在分析静态分派之前,先给出一段代码和两个概念:
public class StaticDispatch {
static abstract class Human {}
static class Man extends Human {}
static class Woman extends Human {}
public void sayHello(Human guy) {
System.out.println("hello, guy!");
}
public void sayHello(Man guy) {
System.out.println("hello, gentleman!");
}
public void sayHello(Woman guy) {
System.out.println("hello, lady!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch staticDispatch = new StaticDispatch();
staticDispatch.sayHello(man);
staticDispatch.sayHello(woman);
}
}
上述代码的执行结果为
上面代码中"Human"称为变量的静态类型,"Man"和"Woman"称为变量的实际类型。man和woman两个对象被转型后,通过特征签名匹配,只能匹配到对应的父类的重载方法。至于man和woman的具体类型是什么,需要在运行期才能确定。
依据静态类型来定位方法执行版本的分派动作,称为静态分派,典型应用就是方法重载。
虽然编译器能够找到方法的重载版本,这种找到只是一种"最合适"的版本。产生这种现象的原因是字面量不需要定义,所有字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。如下代码所示:
public class Overload {
public static void sayHello(Object arg) {
System.out.println("hello Object");
}
public static void sayHello(int arg) {
System.out.println("hello int");
}
public static void sayHello(long arg) {
System.out.println("hello long");
}
public static void main(String[] args) {
sayHello('a');
}
}
main方法中调用sayHello()方法,传的参数是字符’a’,那这个’a’是 char 的’a’,还是int的65,还是 long型的65l,还是Object的65呢,通过对’a’字面量的理解,在上述代码中,所有编译器能找到最合理的执行版本应该是sayHello(int arg)。
4.2.2 动态分派
在运行期间,根据实际类型确定方法执行版本的分派动作,称为动态分派,典型应用–方法重写。
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();
}
}
运行结果
上述代码的字节码如下所示:
0~15行是准备动作,作用是建立man和woman的内存空间,调用Man和Woman类型的实例构造器,并将两个实例的引用存放在第1和第2个局部变量表Slot中。
16和20行代码分别将刚刚创建的两个对象的引用压入栈顶;第17和21行是方法调用指令,从字节码的角度看,两条调用指令无论是指令还是参数都完全一样,但是两条指令最终执行的目标方法并不同,其原因是invokevirtual指令的多态查找导致的。
4.2.3 多态查找
invokevirtual指令的运行时解析过程大致分为:
- 找到操作数栈顶的第一个元素所致的对象的实际类型,记作C.
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的调用该方法的符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。
5 基于栈的字节码解释执行引擎
5.1 编译执行
将代码编译成可运行的中间文件,并保存,以便下次直接使用的技术–即时编译器技术(JIT)。
但是编译执行不是拿到代码后立即开始编译代码的,原因是无法判断代码是否需要被重复执行,无法选择优化的编译策略。编译执行需要将代码编译成中间可执行文件,这中间势必会消耗额外的时间和内存,所以待确定需要采用编译执行的时候再采用该执行方式。
5.2 指令集
指令集分为基于栈的指令集和基于寄存器的指令集。
基于栈的指令集依赖操作数栈进行工作。优点是可移植性强,代码相对紧凑,编译器实现简单等,缺点是执行速度相对较慢,因为完成相同功能所需指令数较多,而且在这过程中频繁的进行内存访问。
基于寄存器的指令集依赖寄存器进行工作。
5.3 执行过程
借助一段文章分析同一段代码在不同执行引擎下的执行效果
public static void foo() {
int a = 1;
int b = 2;
int c = (a + b) * 5;
}
基于栈的Hotspot的执行过程如下:
基于栈的DalvikVM执行过程如下所示:
基于汇编语言的执行过程:
基于JVM的逻辑运算模型如下图所示:
因此执行到JVM上的过程就是下面的形式: