虚拟机字节码执行引擎

字节码就像是汇编语言,是 JVM 的指令集。

执行引擎是 Java 虚拟机最核心的组成部分之一。“虚拟机” 是一个相对于 “物理机” 的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行哪些不被硬件直接支持的指令集格式。

在 Java 虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型称为各种虚拟机执行引擎的统一外观(Facade)。在不同的虚拟机实现里面,执行引擎在执行 Java 代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。但从外观上看起来,所有的 Java 虚拟机的执行引擎都是一致的:

输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

运行时栈帧

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

每一个栈帧都包括了局部变量表操作数栈动态连接方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的 Code 属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如图所示。

stack-frame

局部变量表

局部变量表(Local Variable Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译称为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。

局部变量以变量槽(Variable Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot应占用的内容空间大小,只是很有导向性的说到每个Slot都应该存放一个boolean、byte、char、short、int、float、reference或returnAddress。

由于局部变量表是建立在线程的栈上,是线程的私有数据,因此无论两个连续的Slot是否为原子操作,都不会引起数据安全问题。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。如果访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型的变量,则说明会同时使用n和n+1两个Slot。

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

操作数栈

操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出栈(FIFO)。同局部变量表一样,操作数栈的最大深度也是编译的时候被写入到方法表的Code属性的max_stacks数据项中。

操作数栈的每一个元素可以是任意Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。栈容量的单位为“字宽”,对于32位虚拟机来说,一个”字宽“占4个字节,对于64位虚拟机来说,一个”字宽“占8个字节。

当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指向操作数栈中写入和提取值,也就是入栈与出栈操作。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。另外我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈

另外,在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的,但是大多数虚拟机的实现里都会作一些优化处理,令两个栈帧出现一部分重叠。让下栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用返回时就可以共用一部分数据,而无须进行额外的参数复制传递了。

动态连接

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

在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。

这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析
另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接

方法返回地址

当一个方法被执行后,有两种方式退出这个方法。

  • 正常完成出口(Normal Method Invocation Completion):在执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定。
  • 异常完成出口(Abrupt Method Invocation Completion):在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。
    使用异常完成出口的方式退出,是不会给它的调用都产生任何返回值的。

附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。
在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息。

方法调用

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

在程序运行时,进行方法调用是最普遍、最频繁的操作。在Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法的调用过程变得相对复杂,需要在类加载期间甚至到运行期间才能确定目标方法的直接引用。

解析

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用。

这种解析能成立的前提是:方法在程序真正运行之前就有一可确定的调用版本,并且这个方法的调用版本是运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

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

与之相对应,在Java虚拟机里提供了5条方法调用字节码指令,分别是:

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器方法,私有方法和父类方法。
  • invokevirtual:调用虚方法。
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
  • invokedynamic:动态解析出需要调用的方法,然后执行。

前四条指令固化在虚拟机内部,方法的调用执行不可认为干预,而invokedynamic指令则支持由用户确定方法版本。

只要能被invokestatic与invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法私有方法实例构造器父类方法四类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以统称为非虚方法,与之相反,其它方法就称为虚方法(除去final方法)。

解析(Resolution)调用一定是个静态过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。
分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派与多分派。这两类分派方式两两组件就构成了静态单分派,静态多分派,动态单分派与动态多分派情况。

分派

分派调用更多的体现在多态上。

  • 静态分派(Method Overload Resolution):所有依赖静态类型来定位方法执行版本的分派成为静态分派,发生在编译阶段,典型应用是方法重载(Overload)。
  • 动态分派:在运行期间根据实际类型来确定方法执行版本的分派成为动态分派,发生在程序运行期间,典型的应用是方法的重写(Override)。
  • 单分派:根据一个宗量对目标方法进行选择。
  • 多分派:根据多于一个宗量对目标方法进行选择。

方法的接收者与方法的参数统称为方法的宗量
Java语言是一门静态多分派,动态单分派的语言。

动态类型语言支持

JDK 7 的发布中,字节码指令集中添加了invokedynamic指令。这条新增加的指令是 JDK 7 实现“动态类型语言”( Dynamically Typed Language) 支持而进行的改进之一。

动态类型语言概念

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期。
“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个重要特征。

java.lang.invoke 包

该包的主要目的是在之前单纯靠符号引用来确定调用目标方法这种方式之外,提供一种新的动态确定目标方法的机制(称为MethodHandle)。

MethodHandle 的使用方法和效果与 Reflection 有众多相似之处,不过,它们还是有以下这些区别:

  • Reflection 是在模拟 Java 代码层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用。
  • Reflection 是重量级,而 MethodHandle 是轻量级。
  • Reflection API 的设计目标是只为 Java 语言服务的,MethodHandle 可服务于所有 Java 虚拟机之上的语言,其中也包括 Java 语言。

invokedynamic 指令

在某种程度上, invokedynamic 指令与 MethodHandle 机制的作用是一样的,都是为了解决原有 4 条”invoke*” 指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中。

invokedynamic 指令与前面 4 条” invoke*” 指令的最大差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。
可以通过”super” 关键字很方便地调用到父类中的方法,但如果要访问祖类的方法呢?可以使用 MethodHandle 来解决相关问题。


补充说明

Java语言经常被人们定位为“解释执行”的语言,在Java初生的JDK1.0时代,这种定义还是比较准确的,但是当主流虚拟机中包含了即时编译器后,Class文件到底会被解释执行还是编译执行,就成为了只有虚拟机自己才能准确判断的事情。因此,只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈论解释执行还是编译执行才会比较确切。

但是无论是编译执行也好,解释执行也好,从程序源码到物理机的目标代码或者虚拟机的指令集之前,大都会经过下述图所描述的几个部分:

compile-process

在Java语言中,javac编译器完成了词法分析、语法分析以及抽象语法树的过程,再遍历语法树生成线性字节码指令流的过程,此过程是在虚拟机外部进行的,而解释器又是在虚拟机内部完成的。因此Java程序的编译是半独立的实现。

  • Java编译器输入的指令流基本上是一种基于栈的指令集架构,指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。
  • 另外一种指令集架构则是基于寄存器的指令集架构,典型的应用是x86的二进制指令集,比如传统的PC以及Android的Davlik虚拟机。

两者之间最直接的区别是,基于栈的指令集架构不需要硬件的支持,而基于寄存器的指令集架构则完全依赖硬件,这意味基于寄存器的指令集架构执行效率更高,单可移植性差,而基于栈的指令集架构的移植性更高,但执行效率相对较慢,除此之外,相同的操作,基于栈的指令集往往需要更多的指令。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值