五 虚拟机字节码执行引擎
 
1 运行时栈帧结构
    ---栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区中的虚拟机栈的栈元素。
    ---栈帧中存储了方法的局部变量表、操作数栈、动态连接、方法返回地址和一些附加信息。
    ---每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
    ---在编译程序代码的时候,栈帧中需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中。
    (1)局部变量表
            ---是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。
            ---局部变量表的最大容量在编译时写入到Code属性的max_locals数据项中,方法执行的任意时刻,局部变量表的容量都不会大于max_locals数据项中设定的最大容量。
            ---容量以变量槽(Slot)为最小单位,每个Slot能存放一个32位以内的数据类型,包括boolean、byte、char、short、int、float、reference或returnAddress类型的数据。
            ---reference类型表示对一个对象实例的引用。虚拟机规范既没有说明它的长度,也没有指出它的结构,但虚拟机实现至少能通过这个引用做到:
                · 从此引用中直接或间接查找到对象在Java堆中的数据存放的起始地址索引;
                · 从此引用中直接或间接查找到对象所属数据类型在方法区中的存储的类型信息。
            ---returnAddress类型指向了一条字节码指令的地址。
            ---对于64位数据类型(long、double),虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。
            ---虚拟机通过索引定位的方式使用局部变量表,范围为0至局部变量表最大的Slot数量。        
            ---在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法,那局部变量表的第0位索引的Slot默认用于传递方法所属对象实例的引用(“this”),其余参数按照参数表顺序排列,占用从1开始的局部变量Slot。参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
            ---局部变量表中的Slot是可以重用的。重用可以节省栈空间,但可能带来一些副作用,如可能会影响系统的垃圾收集行为。
            ---方法中,当一个变量不再使用时,在对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到JIT的编译条件的情形下,应将该变量手动赋值为null。
            ---局部变量必须在赋值后才能使用。
    (2)操作数栈
            ---操作数栈中的每一个元素可以是任意的Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。
            ---操作数栈的最大深度在编译时写入到Code属性的max_stack数据项中,方法执行的任意时刻,操作数栈的深度都不会超过在max_stack数据项中设定的最大值。
            ---操作数栈中元素的数据类型必须与字节码指令的序列严格匹配。编译时,编译器要严格保证这一点;在类校验阶段的字节码校验阶段还要再次验证这一点。
    (3)动态连接
                ---每个栈帧中都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
    (4)方法返回地址
                ---当一个方法开始执行后,只有两种方式可以退出这个方法:
                    · 正常完成出口:执行引擎遇到任意一个方法返回的字节码指令。可能会有返回值传递给上层的方法调用者。
                    · 异常完成出口:方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理。不会给上层调用者产生任何返回值。
                ---方法退出后,需要返回到方法被调用的位置。一般来说,
                    · 方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能保存该计数器值;
                    · 而方法异常退出时,返回地址要通过异常处理器表来确定,栈帧中一般不会保存这部分信息。
                ---方法退出时可能执行的操作:
                    · 恢复上层方法的局部变量表和操作数栈;
                    · 把返回值(如果有的话)压入调用者栈帧的操作数栈中;
                    · 调整PC计数器的值以指向方法调用指令后面的一条指令等。
 
2 方法调用
        ---确定被调用方法的版本。
    (1)解析
            ---要求:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。即“编译期可知,运行期不可变”。
            ---在类加载阶段进行解析,即在类加载的解析阶段,会将这类方法的符号引用转化为直接引用。
            ---主要包括:静态方法和私有方法。前者与类型相关联,后者在外部不可被访问,因此它们都不可能通过继承或者别的方式重写其他版本。
            ---其次还包括:实例构造器<init>()方法、父类方法以及被final修饰的方法。
            ---与之相对应的两条方法调用的字节码指令:invokestatic、invokespecial。
            ---虚方法和非虚方法:
                · 非虚方法:静态方法、私有方法、实例构造器、父类方法、被final修饰的实例方法;
                · 虚方法:没有被final修饰的实例方法。
    (2)分派
            ---可能是静态的,也可能是动态的。
            ---根据分派依据的宗量数可分为单分派和多分派。
            ---解析与分派两者之间的关系并不是二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。如静态方法会在类加载期就进行解析,而静态方法选择重载版本的过程也是通过静态分派完成的。
            1)静态分派
                ---所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。
                ---静态类型和实际类型的区分如下:Human man = new Man();其中Human是静态类型,Man是实际类型。
                ---静态类型和实际类型在程序中都可以发生一些变化,区别是:
                    · 静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;
                    · 实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
                        
                ---典型应用:方法重载。编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的,并且静态类型在编译器是可知的。
                ---静态分派发生在编译阶段,因此确定静态分派的动作实际上不是有虚拟机来执行的,而是通过编译器。
                ---编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。
                ---重载匹配优先级:字面量'a'的匹配:char-int-long-float-double-Character(Integer等版本不能匹配)-Serializable(或Comparable,接口版本,不能同时出现多个)-Object(父类版本)-char ...(变长参数版本)-int ...-long ...-float ...-double ...。
            2)动态分派
                ---在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
                ---典型应用:重写。
                ---invokevirtual指令的运行时解析过程:
                    · 找到操作数栈栈顶的的第一个元素所指向的对象的实际类型,记作C。
                    · 如果在类型C中找到与常量中的描述符和简单名称都一致的方法,则进行访问权限校验,若通过则返回这个方法的直接引用,查找过程结束;若不通过,则返回java.lang.IllegalAccessError异常。
                    · 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
                    · 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
                ---重写的本质:由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上。
            3)单分派和多分派
                ---方法的接收者与方法的参数统称为方法的宗量。
                ---根据分派基于多少种宗量,可以把分派划分为单分派和多分派两种。单分派是指根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
                ---静态多分派、动态单分派:Java语言的静态分派属于多分派类型,动态分派属于单分派类型。
            4)虚拟机动态分派的实现
                ---“稳定优化”手段:在类的方法区中建立一个虚方法表(相对应的有接口方法表),使用虚方法表索引来代替元数据查找以提高性能。
                ---方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
                ---“激进优化”手段:内联缓存和基于“类型继承关系分析”技术的守护内联。
 
3 动态类型语言支持
    (1)动态类型语言
            ---动态类型语言的关键特征:它的类型检查的主体过程是在运行期而不是编译期,如Python、Ruby等。
            ---动态类型语言的另一个重要特征:变量无类型而变量值才有类型。
            ---静态类型语言的关键特征:在编译器就进行类型检查过程,如C++、Java等。
            ---动态类型语言优点:清晰和简洁。
            ---静态类型优点:利于稳定性及代码达到更大规模。
    (2)java.lang.invoke包
            ---该包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为MethodHandle。
            ---从Java语言的角度来看,MethodHandle的使用方法和效果与Reflection有众多相似之处,它们的区别是:
                · 从本质上讲,Reflection和MethodHandle的机制都是在模拟方法调用,但Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。
                · Reflection是重量级,而MethodHandle是轻量级。前者包含了方法的签名、描述符、以及方法属性表中各种属性的Java端表示方式,还包含了执行权限等运行期信息;而后者仅仅包含与执行该方法相关的信息。
                · 理论上,虚拟机在字节码层次的方法指令调用上做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持;而通过反射去调用方法则不行。
                · Reflection API的设计目标是只为Java语言服务的,而MethodHandle则设计成可服务于所有Java虚拟机之上的语言。
    (3)invokedynamic指令
            略
 
4 基于栈的字节码解释执行引擎
​    ---在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。
    ---Java虚拟机的解释执行引擎称为“基于栈的执行引擎”。
    (1)解释执行
            
    (2)基于栈的指令集与基于寄存器的指令集
            ---Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。
            ---基于栈的指令集
                · 主要的优点是:可移植、代码相对更加紧凑、编译器实现更加简单(不需要考虑空间分配问题,所需空间都在栈上操作)等。
                · 主要缺点:执行速度相对来说会稍慢一些(指令数量多、内存访问慢)。
 

Copyright © 2024 zy学习专用
Powered by .NET 9.0 on Kubernetes