深入理解JVM(八)——字节码执行引擎

不用虚拟机,执行引擎在执行Java代码时,会有解释执行(通过解释器执行)和编译执行(通过及时编译器产生本地代码执行)两种选择。

运行时栈帧结构

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

一个线程中的方法调用链可能很长,很多方法都同时处于执行状态,对于执行引擎来说,在活动的线程中,栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。

局部变量表

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

局部变量表以变量槽slot为最小单位,一般为4个字节,64位虚拟机使用64位的物理内存空间,采用对齐和补白的手段解决位数不够的问题。

Java中占用32位以内的数据类型为boolean,byte,char,short,int,float,reference,returnAddress 8种数据类型,reference是对对象实例的引用,一般至少通过这个引用做到两点,从此引用中直接或间接找到对象在Java堆中的数据存放的起始地址索引,直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息。

虚拟机采用索引定位的方式使用局部变量表,从0到最大的slot数量。在方法执行时,虚拟机使用局部变量表完成参数变量列表的传递过程,对于非static方法,slot[0]即为this引用,参数表分配完毕后,再根据方法内部定义的变量顺序和作用域分配其余的slot。为了节省栈帧空间,slot可重用,当当前字节码PC计数器的值以及超出了某个变量的作用域,那么对应的slot可以重用。

局部变量表中的slot是否还存在关于对象的引用,关系到GC。

局部变量和类变量不一样,局部变量定义了但是没有赋初始值是不能使用的。

操作数栈

操作数栈的最大深度也是编译的时候写到Code属性的max_stacks数据项中,元素就可以是任意的Java数据类型,32位占一个栈容量,6位占两个。

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

如果iadd指令,从占中取两个int型的数值,相加后将结果入栈。字节码指令必须与操作数栈数据类型一致,编译时必须保证,并且类校验阶段的数据流分析也会再次验证。

概念模型中两个栈帧是完全独立的,但是虚拟机的实现都会做一些优化,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠,这样方法调用时就可以共用一部分数据。

动态连接

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

方法返回地址

一个方法开始执行后只有两种方式退出该方法,执行引擎遇到任意一个方法返回的字节码指令,这个时候会有返回值传递给上层的调用者,,这种为正常完成出口。另一种是方法执行过程中遇见异常,异常没在方法中处理,,产生athrow指令,导致方法退出,称为异常完成出口,不会给上层调用返回任何值。

无论怎么退出,退出时,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要栈帧中保持的一些信息,来恢复方法执行。一般方法正常退出,调用者的PC计数器的值可以作为返回地址,栈帧中会保留这个计数器的值。异常退出时,返回地址通过异常处理器表确定,栈帧中不保存这部分信息。

方法退出实际上就是当前栈帧出栈,退出时可能执行,恢复上层方法的局部变量表和操作数栈,把返回值压入调用者操作数栈,跳转PC计数器的值以指向方法调用指令后面的一天指令

方法调用

重点介绍方法调用,也就是虚拟机多态的原理

前面已经讲到,一切的方法调用在Class文件里面存储的只是符号引用,而不是方法在实际运行时内存布局中的入口程序。

所有方法调用中的目标方法在class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化成直接引用。这种解析成立的前提是:方法在程序真正运行之前就有一个可确定的版本,并且这个版本在运行期间是不可变的。即编译时就能确定。

Java中“编译期可知,运行期不可知”编译期可知主要包括静态方法,私有方法两大类,前者直接与类型关联;后者在外部不可访问。

与之相对应的5条字节码指令
invokestatic——调用静态方法
invokespecial——调用实例构造器< init>方法,私有方法,父类方法
invokevirtual——调用所有的虚方法
invokeinterface——调用接口方法,会在运行时确定一个实现该接口的对象
invokedynamic——先在运行时动态解析出调用点限定符所引用的方法

解析阶段能确定的只有invokestatic和invokespecial。他们在类加载的时候就会把符号引用解析为该方法的直接引用,这种方法称为非虚方法。
与之相反的称为虚方法(除去final方法),final方法也是使用invokevirtual指令来执行,但是它无法被覆盖,没有其它版本,所以不需要进行多态选择,也就是非虚方法。

分派

Java虚拟机如何实现重载和重写,如何确定正确的目标方法

  • 静态分派
public Class StaticDispatch{
    static abstract Class Human{}
    static abstract Class Man extends Human{}
    static abstract Class Woman extends Human{}
    public void sayhello(Human guy){System.out.printlln("hello,guy!")}
    public void sayhello(Man guy){System.out.printlln("hello,man!")}
    public void sayhello(Woman guy){System.out.printlln("hello,lady!")} 
    public static void main(String[] args){
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch dispatch = new StaticDispatch();
        dispatch.sayhello(man);
        dispatch.sayhello(woman);
    }
}

运行结果是
hello,guy!
hello,guy!

我们把上面的Human称为静态类型,,后面的Man称为变量的实际类型。静态类型的变化只在使用时发生,变量本身的静态类型不会改变,即最终的静态类型编译器可知;但是实际类型的变化只有运行期才能确定。

重载的时候使用哪个版本完全取决于传入参数的数量和数据类型,编译器通过参数的静态类型而不是实际类型作为判断依据,因此编译期,编译器就更加静态类型决定使用哪个重载的版本。

所有依赖静态类型来定位执行版本的分派动作称为静态分派,典型的场景为方法重载。

但是重载的版本并不是唯一的,而是更加合适的版本
如果重载一个参数’a’,向上转型:
byte->short->char->int>long->float->double->装箱类->Serializable->Object->变长参数

  • 动态分派
    典型体现为重写
public Class StaticDispatch{
    static abstract Class Human{
        protected abstract void sayhello()
    }
    static abstract Class Man extends Human{
        protected void sayhello(){
            System.out.printlln("hello,guy!")
        }
    }
    static abstract Class Woman extends Human{
        protected void sayhello(){
            System.out.printlln("hello,lady!")
        }
    }

    public static void main(String[] args){
        Human man = new Man();
        Human woman = new Woman();
        man.sayhello();
        woman.sayhello();
        man = new Woman();
        man.sayhello();
    }
}

执行结构
hello,guy!
hello,lady!
hello,lady!

通过javap可以看到字节码,
Human man = new Man();
Human woman = new Woman();
对应为建立man和woman的内存空间,调用Man和Woman类型的实例构造器,将两个实例的引用放在第1,2个局部变量表slot中。

任何将两个对象引用压入栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接受者,然后通过invokevirtual指令调用sayhello方法。
invokevirtual指令的运行时解析过程分为以下步骤
1.找到操作数栈顶的第一个元素所指向的对象的实际类型,记为C
2.如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行权限校验,通过则返回该方法的直接引用,否则异常
3.否则,按照继承关系从下往上搜索

由于invokevirtual指令第一步就是在运行时确定接受者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言方法重写的本质。

单分派和多分派

方法的接受者与方法的参数统称为方法的宗量,单分派是根据一个宗量对目标方法进行选择的,多分派是根据多个宗量对目标方法进行选择的。

编译阶段编译器的选择过程,也就是静态分派的过程,是根据多个宗量进行选择的,静态多分配,动态单分配。

动态分配的实现

动态分配的方法版本需要在运行时在类的方法元数据中搜索合适的目标方法,考虑到性能,大部分实现都不会如此频繁的搜索。最常见的手段是为类在方法区中建立一个虚方法表(Virtual Method Table,与之对应的,在invokeinterface执行的时候也会调用到接口方法表,Interface Method Table)。使用虚方法表索引来代替元数据查找以提供性能。

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

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

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

解释执行

虚拟机如何调用方法的内容已经介绍完毕,现在探讨虚拟机如何执行方法中的字节码指令。当然Java虚拟机的执行引擎包括解释执行和编译执行,本节主要探讨虚拟机是如何解释执行的字节码的。

编译过程
程序源码->词法分析->单词流->语法分析->抽象语法树->指令流->解释器->解释执行

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

Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,依赖操作数栈进行工作。

与之相对的是基于寄存器的指令集,最典型的就是x86的二地址指令集,即传统PC机中直接支持的指令集架构。

以1+1为例子说明:
基于栈的指令集是
iconst_1
iconst_1
iadd
istore_0
两个iconst_1指令连续将两个常量1压入栈后,iadd指令把栈顶的两个值出栈,相加,然后放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个slot中。

基于寄存器的指令集:
mov eax, 1
add eax, 1
mov指令把eax寄存器的值设为1,然后add指令再把这个值加1,结果就放在寄存器eax中

基于栈的指令集优点是可移植,但是执行速度相对慢。寄存器由硬件直接提供,程序依赖寄存器则不可避免受到硬件的约束。

下面以Java代码为例子进行说明

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  100
6:  istore_2
7:  sipush  300
10: istore_3
11: iload_1
12: iload_2
13: iadd
14: iload_3
15: imul
16: ireturn
}

深度为2的操作数栈,4个slot的局部变量表,一个this参数

猜你喜欢

转载自blog.csdn.net/rickey17/article/details/75782585