JVM之字节码执行引擎(九)

字节码执行引擎

执行引擎是JVM核心的组成部分之一,虚拟机是一个相对于物理机的概念,物理机的执行引擎是直接建立在存储器、缓存、指令集和操作系统上的,而虚拟机的执行引擎完全由软件自行实现。因此可以不受物理条件制约地定制指令集与执行引擎地结构体系。

执行引擎在执行字节码地时候,通常会有解释执行和编译执行两种选择。

运行时栈帧结构

JVM以方法为最基本地执行单元,栈帧是用于支持JVM进行方法调用和方法执行背后地数据结构,它是JVM运行时数据区中虚拟机栈地栈元素,每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面的入栈到出栈的过程。

对于执行引擎来说,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是在运行的,被称为当前栈帧,与这个栈帧相关联的方法被称为当前方法

局部变量表

局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽为最小单位。

当一个方法被调用时,JVM会使用局部变量表完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过this来访问到这个参数,其余参数按照参数表顺序排列,占用从1开始的局部变量槽。

操作数栈

操作数栈也被称为操作栈,它是一个后入先出栈,操作数栈的最大深度在编译时写入到Code属性的max_stacks数据项之中,操作数栈的每一个元素都可以是包括long和double在内的任何Java数据类型,32位数据类型所占的栈容量为1,64位数据类型栈容量为2.

当一个方法刚刚开始执行时,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。

操作数栈中的数据类型必须与字节码指令的序列严格匹配,在编译程序代码时,编译器需要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。

在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但在大多数JVM实现里会进行一些优化操作,令两个栈帧出现一部分重叠,让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起。不仅可以节约空间,更重要的是在方法调用时可以直接共用一部分数据,无需进行额外的参数复制传递了。

方法返回地址

当一个方法开始执行后,只有两种方式退出这个方法,第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时可能会有返回值传递给上层的方法调用者,方法是否有返回之以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常调用完成

另一种方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是JVM内部异常,还是代码中使用了athrow指令产生的异常,只要在笨方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常调用完成,异常调用完成的方式退出,不会给它的调用者提供任何返回值。

方法退出的过程实际上等同于当前栈帧出栈,因此退出时可能执行饿操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈,调整PC计数器的值以指向方法调用指令后面的一条指令等。

方法调用

一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址。

解析

在类加载的解析阶段,会将其中的一部分符号引用转换成直接引用,这种解析能成立的前提是:方法在程序运行之前就有一个可确定的调用版本,并且方法的调用版本在运行期不可改变的。

在Java语言中符合“编译期可知,运行期不可知”的方法,主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问。这两种方法各自的特点决定了都不可能通过继承或别的方式重写出其他版本,因此都适合在类加载阶段进行解析。

调用不同种类的方法有不同的字节码指令,分别是: - invokestatic:调用静态方法 - invokespecial:用于调用实例构造器()方法、私有方法和父类中的方法 - invokevirtual:用于调用所有的虚方法 - invokeinterface:用于调用接口方法,会在运行时再确定一个实现该接口的对象 - invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后执行该方法

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java语言中符合这个条件的有静态方法、私有方法、实例构造器、父类方法,被final修饰的方法,这五种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为非虚方法

分派

另一种主要的方法调用形式:分派调用则复杂许多,他可能是静态的,可能是动态的,按照分派依据的宗量数可分为单分派和多分派,这两种分派方式两两组合可以得到四种分派组合情况。

静态分派

public class StaticDispatch {
	static abstract class Human{
	}
	static class Man extends Human{
	}
	static class Woman extends Human{
	}
	public static void sayHello(Human guy){
		System.out.println("hello,guy!");
	}
	public static void sayHello(Man guy){
		System.out.println("hello,gentlemen!");
	}
	public static void sayHello(Woman guy){
		System.out.println("hello,lady!");
	}
	
	public static void main(String[] args) {
		Human man=new Man();
		Human woman=new Woman();
		sayHello(man);
		sayHello(woman);
	}
}

运行结果为:

hello,guy!
hello,guy!

Human man=new Man();

上面代码的Human称为变量的静态类型或者叫外观类型,后面的Man称为变量的实际类型或者叫运行时类型,静态类型和实际类型在程序中都可能发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期间可知的,而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

JVM在重载时是通过参数的静态类型而不是实际类型作为判定依据的。

所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派,静态分派的最典型应用表现就是方法重载,静态分派发生在编译阶段。

动态分派

动态分派与Java语言多态性的——重写有着很密切的关系,以之前Human为例:

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!

JVM如何判断应该调用哪个方法呢?

显然,这里不可能再根据静态类型来决定,因为静态类型同样是Human的两个变量man和woman在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,Java虚拟机是如何根据实际类型来分派方法执行版本的呢? 我们从invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
  2. 如果在类型C中找到与常量中的描述符和简单名称相符合的方法,然后进行访问权限验证,如果验证通过则返回这个方法的直接引用,查找过程结束;如果验证不通过,则抛出java.lang.IllegalAccessError异常。
  3. 否则未找到,就按照继承关系从下往上依次对类型C的各个父类进行第2步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,则跑出java.lang.AbstractMethodError异常。

由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

JVM动态分派的实现

动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接受者类型的方法元数据中搜索合适的目标方法,因此JVM基于执行性能的考虑,真正运行时不会如此频繁地区搜索类型元数据。面对这种情况,一种基础且常见的优化手段是为类型在方法区中建立一个虚方法表,与之对应地,在invokeinterface执行时也会用到接口方法表。

图1-方法表结构

虚方法表中存放着各个方法地实际入口地址,如果某个方法在子类中没有被重写,那么子类地虚方法表中唔到地址入口和父类相同方法的地址入口时一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。


JVM之字节码执行引擎(九)
https://l1n.wang/2020/JVM/jvm09-bytecode-engine/
作者
Lin Wang
发布于
2020年4月3日
许可协议