Java虚拟机之字节码执行引擎

时间:2021-10-18 17:08:10

1 概述

虚拟机执行引擎是Java虚拟机最核心的部分之一,其目的是实现:输入字节码文件,将字节码解析或等效处理后,执行并输出结果。

其中两种执行方式:解释执行和编译执行。

2 运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,他是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。

栈帧存储了方法的局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附件信息等信息。编译代码的时候,栈帧需要的局部变量表大小,操作数栈的深度等都已经存储在Code属性中。栈帧大小不受运行期变量数据的影响。每一个方法从调用开始至执行完成过程,都是对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的。称为当前栈帧(Current Stack Frame).与这个栈帧相关联的方法称为当前方法(Current Method).执行引擎的所有字节码指令都是只针对当前栈帧进行操作的,典型的栈帧结构如下:

Java虚拟机之字节码执行引擎

2.1 局部变量表

局部变量表是变量值的存储空间,存储的是方法参数和方法内部定义的局部变量,其容量用Slot1作为最小单位。在编译生成的class文件中,在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。虚拟机是通过索引定位的方式使用局部变量表的,范围是 [0-max_locals].

由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。

在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。如果是实例方法(非static方法),那局部变量表第0位索引的Slot存储的是方法所属对象实例的引用,因此在方法内可以通过关键字this来访问到这个隐含的参数。其余的参数按照参数表顺序排列,参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。

和类变量的两次初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。

2.2 操作数栈

操作数栈(Operand Stack)是一个LIFO栈,操作数栈最大深度在编译成class文件的时候也已经在Code属性中max_stacks明确指定。

当一个方法刚开始执行的时候,其操作数栈为空。方法执行过程中各种字节码指令往操作数栈写入和读取内容,对应着入栈和出栈操作。

2.3 动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,以支持动态连接。

2.4 方法返回地址

一个方法执行时,有且只有两张方式可以使此方法返回:执行引擎遇到任意一个方法返回的字节码指令,这时可能会由返回值传递给上层的方法调用者,此时属于正常退出;另一种是方法执行的时候遇到异常,且此异常未在方法体中捕获处理,将导致方法退出。

方法退出的时候,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中可能保存这个计数器值;方法异常退出时,返回地址是通过异常处理表确定的,栈帧中一般不会保存这部分信息。

方法退出过程相当于当前栈帧出栈,退出操作可能需要执行:恢复上层方法的局部变量表和操作数栈,如果有返回值则把返回值压入调用者栈帧的操作数栈中;调整PC计数器执行方法调用的指令后面一条指令等。

3 方法调用

方法调用并不等同于方法的执行,方法调用的唯一任务是:确定被调用方法的版本(即调用哪个方法)。由于Java编译成class文件时,只是保存了方法的符号引用,而没有直接引用(即方法的内存地址),故这里涉及到以下步骤。

3.1 解析

所有方法调用的目标方法都是在class文件中一个常量池的符号引用,故在类加载的解析阶段会把一部分符号引用转化为直接引用。此解析成立的前提是:方法在程序真正运行之前就有一个可以确定的调用版本,且此方法的调用版本在运行期是不可改变的。符合此前提的方法的调用称为解析(Resolution),也就是说解析式一个静态过程。

Java中,符合解析的方法主要包括静态方法和私有方法两类,前者是于类直接关联的;后者在外部不可访问。由于其特点决定其无法被重写或覆盖,故适合在类加载时加载解析。其实final修饰的方法,也是符合此约定的,会再类加载时解析。但由于其是使用invokevirtual调用的,故这里单独给出。

Java提供了五种调用方法的字节码指令:
invokestatic(调用静态方法);invokespecial(调用实例构造器,私有方法和父类方法);invokevirtual(调用所有的虚方法);invokeinterface(调用接口方法,会再运行时确定一个接口的实现);invokedynamic(先在运行时动态的解析出调用点限定符所引用的方法再执行该方法)

invokestatic和invokespecial指令调用的方法,都可以在解析阶段唯一确定调用版本,也就是会再类加载时会被解析。

3.2 分派

分派是实现Java多态性的基础。分为静态、动态;单分派、多分派。

注意

重载:参数静态类型;
重写:参数动态类型

3.2.1 静态分派

所有依赖静态类型来定位方法执行版本的分派称为静态分派。静态分派的典型应用是方法重载。

这里有一点需要注意:编译器在 重载时 是通过参数的静态类型而不是实际类型作为判断依据的,且静态类型在编译时已知,因此在编译阶段,javac会根据参数的静态类型决定使用哪个版本的重载。

例如以下示例,最终打印出来的会试两个 Hello, Human!

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, Human!");
}
public void sayHello(Man guy) {
System.out.println("Hello, Man!");
}
public void sayHello(Woman guy) {
System.out.println("Hello, Woman!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch staticDispatch = new StaticDispatch();
staticDispatch.sayHello(man);
staticDispatch.sayHello(woman);
}
}

方法重载参数的匹配规则:char->int->long->float->double->装箱类->装箱类的接口类型->装箱类从下往上的父类型->可变参数类型,也就是说首先会进行自动转型,然后是装箱操作,接着是装箱后的接口(实现了多个接口则优先级一致),再然后父类(从下往上递归),最后才是可变参数。

3.2.2动态分派

动态分派是在虚拟机执行的时候的分派操作。涉及到invokevirtual指令,这个指令的多态查找规则如下:

  1. 找到操作数栈顶的第一个元素所指对象的实际类型 C .
  2. 若类型C中找到与常量中的描述符和简单名称都匹配的方法,则进行访问权限校验,通过则返回此方法的直接引用,查找结束;未通过则返回java.lang.IllegalAccessError
  3. 若没找到匹配的方法则按照继承关系从下往上依次对C的父类进行第2部的搜索和验证过程。
  4. 若最终没有找到合适的方法,则抛出java.lang.AbstractMethodError
3.2.3 单分派与多分派

静态分派的时候根据静态类型来决定方法的参数;然后动态分派在执行的时候,根据动态分派来获取方法的接受者的实际类型,再执行其实际类型来达到动态分派。

3.2.4 虚方法表/接口方法表

虚方法表vtable是类在方法区建立的一个表,表中存放了各个方法的实际入口地址,以避免在类的方法元数据中频繁查找,优化查找速度。

虚方法表中除了子类的方法外,还包含父类的方法。若某个方法在子类没有被重写,则子类的虚方法表里面该方法的入口地址和父类中相同方法的入口地址是一致的,都指向父类的实现入口;若子类重写了方法,则子类虚方法表中该方法的地址会替换成子类实现版本的入口地址。

父子虚方法表中,相同签名的方法具有相同的索引序号,这样在类型变换时,只需要变更查找的虚方法表就能获取到实际需要的入口地址。

虚接口表itable与上述类似,不再详述。

3.3动态语言支持

JDK1.7在虚拟机指令集中增加了invokedynamic;于此相应,增加了java.lang.invoke包,通过MethodHandle,提供了一种有别于之前( 单纯依靠符号引用来确定调用目标方法机制),通过动态方式确定目标方法的机制,以此添加了对动态语言的支持。

java.lang.invoke包含了一些类和方法以实现动态语言支持,主要有以下这些

  1. MethodHandle:此对象可以代表一个可以调用方法的’引用’
  2. MethodType:代表方法类型:MethodType.methodType()第一个参数为返回值,后续参数为方法具体参数。
  3. MethodHandles.lookup():在指定方法中查找符合给定的方法名称方法类型并且符合调用权限的方法句柄。

与反射的区别如下:

  1. Reflection是在模拟java代码层次的方法调用;MethodHandle是模拟字节码层次的方法调用。
  2. Reflection是重量级的,包含了方法的几乎java端所有信息,比如方法签名,描述符,方法属性表各种属性,执行权限等等;而MethodHandle是轻量级的,仅仅包含了执行方法的相关信息。