jvm字节码执行引擎,都帮你整理好了

概述

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

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

运行时栈帧结构

栈帧(Stack Frame)

是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

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

对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。jvm字节码执行引擎,都帮你整理好了

1、局部变量表

概念

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

局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot所占用的内存空间大小,只是很有导向性地说到每个Slot都应该能够存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存放,但这种描述与明确指出“每个Slot占用32位长度的内存空间”是有一些差别的,它允许Slot的长度可以随着处理器、操作系统火虚拟机的不同而发生变化。只要保证即时在64位虚拟机中使用了64位的物理内存空间去实现一个Slot,虚拟机仍要使用对齐和补白的手段让Slot在外观上看起来与32位虚拟机中的一致

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

注意:1、为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。不过这样的设计除了节省栈帧空间以外,还会伴随一些额外的副作用,例如,在某些情况下,Slot的复用会直接影响到系统的垃圾收集行为;2、如果一个局部变量定义了但没有赋初始值是不能使用的

2、操作数栈

操作数栈(Operand Stack)也常称作操作栈,它是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也是在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的java数据类型,包括long和double。32位数据类型所占的栈容量为1, 64位的数据类型所占的栈容量为2。在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

当一个方法刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。

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

3、动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将会在每一次运行期间转化为直接引用,这部分称为动态连接。

4、方法返回地址

方法执行后退出方法的两种方式:

方式一:

执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。

方式二:

在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。注意:一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态,

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

5、附加信息

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

方法调用

定义

方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于直接引用)。这个特性给java带来了更强大的动态扩展能力,但也使得java方法调用过程变得相对复杂起来,需要在类加载期间,甚至运行期间才能确定目标方法的直接引用。

1、解析

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

java虚拟机提供了5条方法调用字节码指令:

  • invokestatic:调用静态方法。
  • invokespecial:调用实例构造器方法、私有方法和父类方法
  • invokevirtual:调用所有的虚方法
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在java虚拟机内部的,而invokeDynamic指令的分派逻辑是由用户所设定的引导方法决定的

非虚方法

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。

注意java中被final修饰的方法也是非虚方法,虽然final方法是使用invokevirtual指令来调用的,但是由于他无法被覆盖,没有其他版本,所以也无须对方法接受者进行多态选择。在java语言规范中明确说明了final方法是一种非虚方法。

2、分派

java是一门面向对象的程序语言,因为java具备面向对象的3个基本特征:继承、封装和多态。而分派调用过程将揭示多态性特征的一些最基本的体现,如“重载”和“重写”在java虚拟机之中是如何实现的,这里的实现当然不是语法上该如何写,我们关心的依然是虚拟机如何确定正确的目标方法。

a、静态分派

Human man = new Man();我们把上面代码中的“Human”称为变量的静态类型(Static Type),或者叫做外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。 静态分派的典型应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加适合的”版本。

b、动态分派

invokevirtual指令的运行时解析过程大致分为以下几个步骤:

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

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

c、单分派与多分派

方法的接收者与方法的参数统称为方法的宗量,这个定义最早应该来源于《Java与模式》一书。根据分派基于多少种宗量,可以将分派或分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

d、虚拟机动态分派的实现

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的”稳定优化“手段就是为类在方法区中建立一个虚方法表(Virtual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表—interface Method Table,简称ITable),使用虚方法表索引来代替元数据查找提高性能。jvm字节码执行引擎,都帮你整理好了

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

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

动态类型语言支持

什么是动态类型语言

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有很多,常用的包括:APL、Groovy、JavaScript、PHP、Python等等。相对的,在编译期就进行类型检查过程的语言(如C++和java等)就是常用的静态类型语言。

注意:动态类型语言与动态语言、弱类型语言并不是一个概念。

java.lang.invoke包

jdk1.7实现了JSR-292,新加入的java.lang.invoke包就是JSR-292的一个重要组成部分,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为MethodHandle。

详细的MethodHandle使用参考https://www.jianshu.com/p/a9cecf8ba5d9

仅站在java语言的角度来看,MethodHandle的使用方法和效果与Reflection有众多相似之处,不过,他们还是有以下区别:

  • 从本质上讲,Reflection和MethodHandle机制都是在模拟方法调用,但Reflection是在模拟java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在MethodHands.lookup中的3个方法——findStatic()、findVirtual()、findSpecial()正是对应于invokestatic、invokevirtual & invokeinterface和invokespecial这几个字节码的执行权限校验行为,而这些底层细节在使用Reflection API时是不需要关心的。
  • Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息多。前者是方法在java一端的全面映射,包含了方法的签名、描述符以及方法属性表中各种属性的java端表示方式,还包含执行权限等的运行信息。而后者仅仅包含与执行该方法相关的信息。用通俗的话来讲,Reflection是重量级,而MethodHandle是轻量级。
  • 由于MethodHandle是对字节码的方法调用的模拟,所以理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前还不完善)。而通过反射去调用方法则不行。

最关键的一点还是在于去掉前提 “仅站在java语言的角度来看” :Reflection API的设计目标是只为java语言服务的,而MethodHandle则设计成可服务与所有虚拟机之上的语言。

invokedynamic指令

每一处含有invokeDynamic指令的位置都称为“动态调用点”(Dynamic Call Site),这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为jdk1.7新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以的到3项信息:引导方法(Bootstrap Method,此方法存在放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。引导方法是由固定的参数,并且返回值是java.lang,invoke.Callsite对象,这个代表真正要执行的目标方法调用。根据CONSTANT_invokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得CallSite对象,最终调用要执行的目标方法。

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

1、解释执行

jvm字节码执行引擎,都帮你整理好了

大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过图8-4中下面那条分支,图8-4中下面那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程,而中间的那条分支,自然就是解释执行的过程。

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

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

基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些寄存器则不可避免地要受到硬件的约束。如果用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机来实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获得尽量好的性能,这样实现起来也更加简单一些。

栈架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。

栈架构指令集的主要缺点就是执行速度相对来说会稍慢一点。虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本省就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味这频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。

–摘自《深入理解Java虚拟机》