美文网首页Java 杂谈
深入JVM:(十一)字节码执行引擎

深入JVM:(十一)字节码执行引擎

作者: 小村医 | 来源:发表于2019-04-23 11:34 被阅读0次

    执行引擎在不同的jvm实现中是不同的,jvm可以选择解释执行和编译执行还是两者兼具,或者不同级别的编译器执行指令

    一、栈帧

    1、运行时栈帧结构

    栈帧是虚拟机栈中的元素,是用于支持方法调用和执行的数据结构,

    栈帧储存了方法的局部变量表,操作数栈,动态链接和方法返回地址等信息。

    每个方法从调用到完成的过程,就对应着一个栈帧在栈里从入栈到出栈的过程。

    在程序编译为字节码时,栈帧中有多大的局部变量表,多深的操作数栈已经完全确定了,写入到了方法表的Code属性之中了。

    在活动线程中,只有位于栈顶的栈帧才是有效的,被称为当前栈帧。这个栈帧关联的方法叫当前方法。

    2、局部变量表

    用于储存方法参数和方法内部定义的局部变量,所需最大容量已经定义在方法的Code属性的max_locals中了。

    局部变量表以变量槽(slot)为最小单位,一般为32bit,对于64位类型的数据(long和double)分配两个slot空间。

    这里操作64位数据分割为操作两次32位数据。但因为栈是线程私有的,所有不会引起数据安全问题。

    虚拟机使用索引定位的方式使用局部变量表,索引值从0到最大slot。

    jvm使用局部变量表完成从参数值到参数变量列表的传递过程,若执行的是实例方法,那第0个索引的slot默认是this,其余参数列表按照顺序排列,然后再根据方法体内部的变量顺序和作用域来分配其余slot。

    slot是可重用的!但这种复用是有副作用的:在退出代码块之后,如果没有变量覆写slot,那原本的slot并不会被删除。通过在代码块结束后赋null值可以解决这种情况。

    但没必要主动赋null,因为一般这种情况少见,代码块完成之后一般有其他代码,还有,JIT编译器会对代码进行优化,自动做到优化

    另外,局部变量不像类变量那样有"准备阶段",它在使用前必须初始化!若没初始化就使用,字节码校验的时候会被虚拟机发现而导致类加载失败。

    3、操作数栈

    操作数栈是后入先出栈,其大小在编译阶段就储存在Code属性的max_stacks数据项中。

    当方法刚开始执行时,操作数栈是空的,然后执行时,字节码指令往操作数栈中写入和提取内容,又或者用在调用其他方法时通过操作数栈来进行参数传递。

    操作数栈的元素的数据类型必须和字节码指令的序列严格匹配。javac编译器和类加载时的验证阶段都要严格保证这一点。

    在概念模型中,两个栈帧是完全独立的,但在虚拟机实现中会做一些优化处理,让两个栈出现一部分重叠。

    jvm的解释执行引擎称为"基于栈的执行引擎",其中所指的栈就是操作数栈。

    4、动态链接

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

    Class文件中的常量池中有大量的符号引用,这些常量池在类加载完成后会方法运行时常量池中,字节码中的方法调用指令就是以运行时常量池中的方法符号引用作为参数。

    这些符号引用的一部分在类加载阶段或第一次使用的时候转化为直接引用,这种成为静态解析。另一部分将在每一次运行期间转化为直接引用,这成为动态链接(多态?)。

    5、方法返回地址

    方法有两种退出方式,一是遇到退出指令,二是遇到异常,并且这个异常无法在方法内处理

    方法退出后,会返回到方法被调用的位置。方法返回是需要保存一些信息,用来帮助他的上层方法恢复执行状态。栈帧需要保存调用者的PC计数器的值作为返回地址。

    6、附加信息

    栈帧可以附加一下调试相关的信息

    二、方法调用

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

    Class文件中只是储存的符号引用,而不是直接引用。这样就有了灵活性,但也使得方法调用变得复杂,需要在类加载期间,甚至运行期间才能确定目标方法的直接引用

    1、解析

    在类加载阶段有一部分符号引用就能转化为直接引用了,这中转化的前提是:方法在程序运行之前就有一个可确定的调用版本,并且这个方法的调用版本是运行起不可改变的。换句话说,调用目标在程序代码写好,编译器进行编译时就必须确定下来。这类方法调用称为解析

    java中符合"编译器可知,运行期不变"这个要求的方法,主要是静态方法和私有方法两大类。

    jvm中提供了5条方法调用字节码指令

    invokestatic:调用静态方法
    invokespecial:调用实例构造器<init>方法,私有方法,父类方法
    invokevirtual:调用所有虚方法
    invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象
    invokedynamic:现在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条指令,分配逻辑是固话在jvm内的,而这个指令的分配逻辑是用户所设定的引导方法决定的。
    只有被invokestatic和invokespecial调用的方法,才能在解析阶段中确定唯一的调用阿不能本,这四种方法被称为非虚方法,其他方法成为虚方法(除去final方法)。final方法无法被重写,没有其他版本,所以无法进行多态,所以final方法为非虚方法。

    解析调用一定是一个静态过程,在编译期间就能确定。

    而分派调用则可能是静态分派,也可能是动态分派。

    根据分派的宗量数可以分为单分派和多分派,

    2、分派

    分派调用将会揭示多态性的原理!激动,又能学习了。

    多态包括重载和重写,重载和重写是如何实现的那?

    静态分派

    例如: Human man = new Man(); Human是接口

    这里Human成为变量的静态类型,Man称为实际类型。

    静态类型和实际类型都可以在程序运行时都可以发生一下变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是编译器可知的。而实际类型是在运行起才能确定。编译器在编译程序时并不知道一个对象的实际类型是什么。

    实际类型变化:

    Human man = new Man();

    man = new Woman();

    静态类型变化:

    (Man) man;

    (Woman) man;

    编译器在重载时使用参数的静态类型而不是动态类型做为判定依据。因为静态类型是编译期可知的,所以在编译阶段,javac会根据参数的静态类型决定使用那个重载版本。选择了重载版本之后,会使用invokevirtual指令在方法的字节码中把选择的重载版本作为参数。

    所以利用静态类型的定位方法执行版本的分派动作成为静态分派。

    静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作不是由jvm执行的。

    这里还需要注意的是,方法重载时,为了匹配参数可能会发生自动类型转换。

    char >> int >> long >> Character >> Serializable >> Object >> char ...

    这就是闹着玩的,在开发时避免自动类型转换!

    静态方法也是有重载版本的,所以静态方法也可以有静态分派

    动态分派

    动态分派和另一大多态特性---重写有紧密关联!

    重写时如何选择版本那?

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

    找到操作数栈顶的第一个元素所指向的对象的实际类型,记做C
    如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,若通过校验则返回这个方法的直接引用,查找过程结束,若不通过,则抛出错误。
    否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
    若最后没有找到合适的方法,则抛出错误。
    由于invokevirtual指令执行的第一步是在运行期确定接受者的实际类型,所以调用中的invokevirtual指令把常量池中类方法符号引用解析到了不同的直接引用上。

    这个过程是java语言重写的本质。这种在运行期间根据实际类型确定执行版本的分派过程称为动态分派。

    单分派与多分派

    方法的接受者与方法的参数统称为方法的宗量。

    目前的java是一个静态多分派,动态单分派的语言!

    虚拟机动态分配的实现

    jvm会对动态分配优化,不会那么频繁的搜索。

    常用的稳定优化手段是为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。

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

    具有相同签名的方法,在子类父类的虚方法表中都应当具有一样的索引号,这样当类型转换时,仅需变更查找的方法表,而不需要变索引。

    方法表在类加载的连接阶段进行初始化,当类变量初始化后,会初始化虚方法表。

    3、动态类型语言支持

    java从诞生以来就一直是静态类型语言,但从jdk7之后就出现了一个指令---invokedynamic,这条指令是为了支持"动态类型语言"而进行的改进之一,为jdk8实现Lambda表达式做技术准备。

    动态类型语言是是啥?

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

    变量无类型而变量的值有类型也是动态类型语言的重要特征。

    jdk7与动态类型。

    现在,一些动态语言已经可以运行在jvm之上了,比如scala

    动态类型的支持需要在jvm层面上解决才最合适,因此,jdk7引入了invokedynamic指令和java.lang.invoke包

    java.lang.invoke包的目的就是除了依靠符号引用来确定调用目标方法这种方式以外,提供一种新的动态确定目标方法的机制,叫做MethodHandle

    java没办法把一个函数作为参数进行传递,普遍的做法是设计一个接口,然后以实现了这个接口的对象作为参数

    public class Ser {

    static class ClassA {
        public void println(String s) {
            System.out.println(s);
        }
    }
    
    public static void main(String... strings) throws Throwable {
         Object obj = System.currentTimeMillis() % 2  == 0 ? System.out : new ClassA();
         getPrintlnMH(obj).invokeExact("cadasdasda");
    }
    
    private static MethodHandle getPrintlnMH( Object obj) throws Throwable {
        MethodType mt = MethodType.methodType(void.class, String.class);
        return MethodHandles.lookup().findVirtual(obj.getClass(), "println",mt).bindTo(obj);
    }
    

    }
    上面代码就是使用的invoke包实现的。

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

    x86上就是基于栈的指令集,大多数jvm是基于栈的指令集

    基于栈的指令集的优点是可移植,缺点是执行速度慢

    https://www.jianshu.com/p/1ed1795c9b4a

    相关文章

      网友评论

        本文标题:深入JVM:(十一)字节码执行引擎

        本文链接:https://www.haomeiwen.com/subject/gdkmmqtx.html