《深入理解java虚拟机》 第八章 虚拟机字节码执行引擎

概述

执行引擎是java虚拟机最核心的组成部分之一。
在java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型称为各种虚拟机执行引擎的统一外观(Facade):
    输入的是字节码文件
    处理过程是字节码解析的等效过程
    输出的是执行结果

运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,是vm运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。
栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。
每一个方法从调用开始至执行完成,都对应一个栈帧在虚拟机栈里从入栈到出栈的过程。
一个栈帧需要分配的内存大小,取决于虚拟机实现,不会受到程序运行期变量数据的影响。

一个方法的方法调用链可能会很长,很多方法同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

在这里插入图片描述

局部变量表 (Local Variable Table)

是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。
局部变量表容量以变量槽(Variable Slot)为最小单位。
虚拟机通过索引方式使用局部变量表,索引值得范围从0开始至局部变量表最大的Slot数量。
    如果访问32位数据类型变量,索引n代表使用地n个Slot。
    如果是64位数据类型变量,则说明会同时使用n和n+1 两个Slot,虚拟机规范不允许采用任何方式单独访问其中一个Slot。

局部变量与类变量不同,"不存在准备阶段"。类变量有两次赋初始值过程,
    1. 准备阶段,赋予系统初始值;
    2. 初始化阶段,赋予程序员定义的值。
而局部变量没有准备阶段,没有系统初始值,在没有显示为局部变量赋值时,代码可能无法编译通过。

操作数栈(Operand Stack)

是一个LIFO的栈。同局部变量表一样,操作数栈的最大深度也在编译时写入Code属性的max_stacks中。32位数据类型所占栈容量为1,64位数据类型所占的栈容量为2。

当一个方法刚开始执行时,方法的操作数栈是空的,在执行过程中,会有各种字节码指令基于操作数栈进行出栈/入栈操作。

另外,在概念模型中。两个栈帧作为虚拟机栈的元素,是完全相互独立的,但大多数的实现中,都会做一些优化处理。让两个栈帧的部分局部变量表重叠在一起,就可以共用数据,减少额外的参数赋值传递。

在这里插入图片描述

动态连接(Dynamic Linking)

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。

常量池中存有大量符号引用。这些符号引用:
    静态解析:
        一部分在类加载阶段或者第一次使用就转化为直接引用。
    动态连接:
        另外一部分将在每一次运行期间转为直接引用。

方法返回地址

一个方法开始执行后,只有两种方式可以退出此方法:
    1. 正常完成出口(Normal Method Invocation Completion):执行引擎碰到任意一个方法返回的字节码指令(return 等),将返回值传给方法调用者。
    2. 异常完成出口(Abrupt Method Invacation Completion):方法执行过程中遇到未处理异常。此情况不会给调用者产生任何返回值。
    无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能
继续执行,方法返回时可能需要在栈帧中保存--些信息,用来帮助恢复它的上层方法的执行
状态。一-般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很
可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,
栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢
复上层方法的局部变量表和操作数栈,把返回值( 如果有的话)压人调用者栈帧的操作数栈
中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息。

在实际开发中,一般会把动态连接、方法返回地址与其他附加信息归为一类,称为栈帧信息。

方法调用

方法 调用不等同于方法执行。
方法调用阶段"唯一任务是确定被调用方法的版本"(即调用哪一个方法),暂时还不涉及方法内部的运行过程。

解析(Resolution)

一切方法调用,在Class文件中存储的都是常量池中的符号引用,而不是直接引用。
在类的解析阶段,会将其中一部分转为直接引用。解析成立的前提:
    "方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的"(调用目标方法在编译器进行编译时就必须确定下来)。这类方法的调用称为解析。

非虚方法都可以在解析阶段中确定唯一的调用版本,包括:
    能被invokestatic和invokespecial指令调用(静态方法、私有方法、实例构造器、父类方法)、
    final修饰的方法
解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把设计的符号引用全转变为直接引用。

分派(Dispath)

分派调用可能是静态的,也可能是动态。
根据分派依据的宗量数可分为:
    单分派
    多分派。
两两组合后,构成了
    静态单分派
    静态多分派
    动态单分派
    动态多分派
java是面向对象的程序语言,因为 3个基本特征:
    继承、封装、多态

静态分派

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



/**
 * 方法静态分派演示
 * @author zzm
 */
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 sr = new StaticDispatch();
		sr.sayHello(man);
		sr.sayHello(woman);
	}
}


运行结果:
    hello,guy!
    hello,guy!
Human man = new Man();中,Human是静态(外观)类型,Man是实际类型。
虚拟机(准确地说是编译器)在重载时是通过参数的静态(外观)类型而不是实际类型作为判断依据。
另外,编译器虽然能确定出方法的重载版本,但在很多情况下重载版本不是唯一的,往往只能确定一个"更加合适的"版本。

静态方法也可以拥有重载版本,选择重载版本的过程也是通过静态分派完成的。

动态分配

/**
 * 方法动态分派演示
 * @author zzm
 */
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指令的多态查找过程开始说起,invokevirtual 指令的运行时解析过程大致分为以
下几个步骤:
1)找到操作数栈顶的第一个元素 所指向的对象的实际类型,记作C。
2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限
校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.
IllegalAccessError异常。
3)否则,按照继承关系从下往.上依次对C的各个父类进行第2步的搜索和验证过程。
4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
由于invokevirtual指令执行的第一 步就是在运行期确定接收者的实际类型,所以两次调
用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过
程就是"Java语言中方法重写的本质"。

在这里插入图片描述

单分派与多分派

"方法的接受者与方法的参数统称为宗量"。
单分派:根据一个宗量对目标方法进行选择
多分派:根据多个宗量对目标方法进行选择
"java目前是静态多分派,动态单分派语言"

虚拟机动态分派的实现


动态类型语言支持

JDK7 增加了invokedynamic字节码指令。
是JDK7实现动态类型语言支持而进行的改进之一。
也是为JDK8顺利实现Lamada表达式做准备。

动态类型语言

动态类型语言是什么?
    "动态类型语言关键特征是它的类型检查的主体过程是在运行期而不是编译期"。
    在编译期就进行类型检查过程的语言(如C++、java)就是静态类型语言
    "变量无类型而变量值才有类型"

JDK1.7与动态类型


java.lang.invoke包

JDK1.7实现JSR-292,java.lang.invoke就是JSR-292一个重要组成部分。
它提供新的动态确定目标方法的机制,称为MethodHandle。(类似于C++的函数指针,C#的委托)

仅站在Java语言的角度来看,MethodHandle的使用方法和效果与Reflection 有众
多相似之处,不过,它们还是有以下这些区别:
    1. 从本质上讲, Reflection和MethodHandle机制都是在模拟方法调用,但Reflection是
    在模拟Java代码层次的方法调用,而MethodHandle 是在模拟字节码层次的方法调用。
    
    在MethodHandles.lookup中的3个方法--findStatic()、 findVirtual()、 findSpecial()正
    是为了对应于invokestatic、invokevirtual & invokeinterface 和invokespecial这几条字
    节码指令的执行权限校验行为,而这些底层细节在使用Reflection API时是不需要关
    心的。
    2. Reflection是重量级,MethodHandle是轻量级。
    前者是方法在Java一端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。而后者仅仅包含与执行该方法相关的信息。
    3. MethodHandle与Reflection除了,上面列举的区别外,最关键的一点还在于去掉前面讨
    论施加的前提“仅站在Java语言的角度来看”: Reflection API的设计目标是只为Java语言
    服务的,而MethodHandle则设计成可服务于所有Java虚拟机之上的语言。

invokedynamic指令

invokedynamic指令与前面4条“invoke*”指令最大的区别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。

掌握方法分派规则

在此章节,使用JDK1.8运行之后的结果,与书中的不同。于是翻阅了许多资料,请查看:https://blog.csdn.net/lik_lik/article/details/89307676

基于栈的字节码解释执行引擎

解释执行

如今,基于物理机、Java虚拟机,或者非Java的其他高级语言虚拟机(HLLVM)的语;
言,大多都会遵循这种基于现代经典编译原理的思路,在执行前先对程序源码进行词法分析
和语法分析处理,把源码转化为抽象语法树( Abstract Syntax Tree, AST)。 对于一门具体语
言的实现来说,词法分析、语法分析以至后面的优化器和目标代码生成器都可以选择独立于
执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言。也可以选择把其中一 部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java
语言。又或者把这些步骤和执行引擎全部集中封装在一-个封闭的黑匣子之中,如大多数的
JavaScript执行器。

基于栈的指令集与基于寄存器的指令集

java编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA)。
与之相对的是主流PC机中直接支持的指令集架构。

基于栈的指令集:
    主要优点:
        可移植,
    主要缺点:
        频繁的内存访问、大量的指令数量使得执行速度相对较慢。
    

猜你喜欢

转载自blog.csdn.net/lik_lik/article/details/89322483