《深入理解Java虚拟机——JVM高级特性与最佳实践》学习笔记——虚拟机字节码执行引擎

    xiaoxiao2021-03-25  7

    虚拟机字节码执行引擎

    物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式

    Java虚拟机的执行引擎:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果

    运行时栈帧结构

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

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

    局部变量表

    局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量

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

    局部变量没有像类变量那样存在”准备阶段”,因此局部变量没有默认的初始值,所以在定义的时候就要手动赋值

    操作数栈

    操作数栈也常称为操作栈,它是一个后入先出(LIFO)栈

    当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。如整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈

    动态连接

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

    方法返回地址

    当一个方法开始执行后,只有两种方式可以退出这个方法,第一种是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,这种退出方式称为正常完成出口;另一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,这种退出方法的方式称为异常完成出口

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

    附加信息

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

    方法调用

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

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

    解析

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

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

    在Java虚拟机中,invokestatic和invokespecial指令调用的方法称为非虚方法,可以在解析阶段中确定唯一的调用版本,而invokevirtual、invokeinterface、invokedynamic指令调用的方法称为虚方法(final修饰的方法除外)

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

    分派

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

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

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

    在运行期根据实际类型确定方法执行版本的分派过程称为动态分派

    3.单分派与多分派 方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种,单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择

    4.虚拟机动态分派的实现 由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的是为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能

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

    为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址

    动态类型语言支持

    什么是动态类型语言?动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期

    静态类型语言在编译期确定类型,最显著的好处是编译期可以提供严谨的类型检查,利于稳定性及代码达到更大规模;而动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中需要用大量”臃肿”代码来实现的功能,由动态类型语言来实现可能会更加清晰和简洁,清晰和简洁也就意味着开发效率的提升

    java.lang.invoke包

    这个包的主要目的是在之前单纯依靠符号引用来确定的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为MethodHandle,eg:

    public class MethodHandleTest{ static class ClassA{ System.out.println(s); } public static void main(String[] args) throws Throwable{ Object obj = System.currentTimeMillis() % 2 == 0?System.out : new ClassA(); /*无论obj最终是哪个实现类,下面这句都能正确调用到println方法*/ getPrintlnMH(obj).invokeExact("icyfneix"); } private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable{ /*MethodType:代表"方法类型",包含了方法的返回值(methodType()的第一个参数)和具体参数(methodType()第二个及以后的参数)*/ MethodType mt = MethodType.methodType(void.class,String.class); /*lookup()方法来自于MethodHandles.lookup,这句的作用是在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄*/ /*因为这里调用的是一个虚方法,按照Java语言的规范,方法第一个参数是隐式的,代表该方法的接收者,也即是this指向的对象,这个参数以前是放在参数列表中进行传递的,而现在提供了bindTo()方法来完成这件事情*/ return lookup().findVirtual(reveiver.getClass(),"println",mt).bindTo(reveiver); } }

    实际上,方法getPrintlnMH()中模拟了invokevirtual指令的执行过程,只不过它的分派逻辑并不是固化在Class文件的字节码上,而是通过一个具体方法来实现,而这个方法本身的返回值(MethodHandle对象),可以视为对最终调用方法的一个”引用”

    MethodHandle和Reflection比较

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

    invokedynamic指令

    在某种程度上,invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条”invoke*”指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码中,让用户(包含其他语言的设计者)有更高的自由度。invokedynamic采用上层Java代码和API来实现,MethodHandle采用字节码和Class中其他属性、常量来完成

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

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

    解释执行

    大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过如下几个步骤

    Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现

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

    Java编译器输出的指令流,基本上是一种基于栈的指令集架构(ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作,与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集。现在主流PC机中直接支持的指令集架构依赖寄存器进行工作

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

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

    基于栈的解释器执行过程

    实例:

    public int calc(){ int a = 100; int b = 200; int c = 300; return (a+b) * c; } 通过javap命令查看其字节码指令 public int calc(); Code: Stack=2,Locals=4,Args_size=1 0: bipush 100 2: istore_1 3: sipush 200 6: istore_2 7: sipush 300 10: istore_3 11: iload_1 12: iload_2 13: iadd 14: iload_3 15: imul 16: ireturn

    javap提示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间,其执行过程中的代码、操作数栈和局部变量表的变化情况如下

    转载请注明原文地址: https://ju.6miu.com/read-155467.html

    最新回复(0)