虚拟机字节码执行引擎 JVM笔记4

目录

 

概述

运行时栈帧结构

局部变量表

操作数栈

方法返回地址

附加信息

方法调用

解析

分派

静态分派

动态分派

单分派与多分派

虚拟机动态分派的实现

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


概述

  • 输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

    运行时栈帧结构

  • 栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构。它是虚拟机运行时数据区中的虚拟机栈的栈元素。
  • 存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。
  • 在编译程序代码时,栈帧中需要多大的局部变量表、多深的操作数栈已经完全确定,并且写入方法表的Code属性中。因此栈帧需要分配多少内存,不会受到程序运行期变量数据的影响。仅仅取决于具体的虚拟机实现。

    局部变量表

  • 是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。
  • 最小单位为变量槽(Variable Slot)。虚拟机规范中说明每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAdderss类型的数据。每个Slot可以存放一个32位以内的数据类型。对于64位的数据类型(long、double),虚拟机会以高位在前的方式为其分配两个连续的Slot空间。
  • 虚拟机采用索引定位的方式来使用局部变量表。
  • 在方法执行时,虚拟机使用局部变量表完成参数值到参数变量列表的传递过程,如果是实例方法,那么局部变量表第0位索引的Slot默认是用于传递方法所属对象的引用,在方法中可以通过“this”来访问这个隐含参数。
  • 类变量因为有两次赋初始值的过程,所以有准备阶段的默认初始,所以在初始化阶段即使不赋值也没关系。但是局部变量必须要有一个初始值,否则编译会报错。
  • Slot是可重用的,并不一定会覆盖整个方法体,如果PC计数器超出某个变量的作用域,那么这个变量对应的Slot就可以交给其他变量使用。

    操作数栈

  • 也被称为操作栈,是一个后进先出栈,其最大深度在编译时便已经确定。
  • 32位数据栈容量为1,64位数据栈容量为2。
  • 在方法刚开始进行时,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令向操作数栈中写入和提取内容,也就是出栈入栈操作。例如在进行算术运算和调用其他方法的时候时通过操作数栈来进行的。 
    例子

    整数加法的字节码指令iadd在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。

  • 操作数栈中的元素类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证。
  • 大多数虚拟机实现中会让两个栈帧出现一部分重叠,让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,状态在进行方法调用时就可以公有一部分数据,而无需进行额外的参数传递。
  • Java虚拟机的解释引擎称为基于栈的执行引擎,这里的就是指操作数栈。

    方法返回地址

  • 当一个方法被执行后,有两种方法退出这个方法:
    1. 正常完成出口:(Normal Method Invacation Completion)执行引擎遇到任意一个方法返回的字节码指令,这时可能会有返回值传递给上层的方法调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定。
    2. 异常完成出口:(Abrupt Method Invocation Completion)在方法执行过程中遇到了异常,并且这个异常没有在方法体得到异常处理,在本方法的异常表没有搜索到匹配的异常处理器,就会导致方法退出,不会给它的上层调用者返回任何返回值。
  • 一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回值,栈帧中很可能保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。
  • 方法退出的过程实际上等同于当前栈帧出栈,因此退出可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

    附加信息

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

    方法调用

  • 方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法)。

    解析

  • 方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。这类方法的调用称为解析(Resolution)。
  • 满足以上条件的方法主要有静态方法私有方法两大类。前者与类型直接关联,后者在外部不可被方法。它们都不可能通过继承或别的方式重写出其他版本。
  • 只要能被invokestatic和invokespecial指令调用的方法,都能在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器和父类方法四类。这些方法被称为非虚方法。其他方法被称为虚方法(除去final方法)。在Java语言规范中明确说明final方法也是一种非虚方法。
  • 解析调用一定是一个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期才去完成。

分派

  • 分派调用可能是静态也可能是动态的,根据分派的宗量数可分为单分派和多分派。综合分为四种分派情况。

    静态分派

  • 所有依赖静态类型来定位方法执行版本的分派动作,都被称为静态分派。最典型的应用便是方法重载。
/**
 *Woman和Man是继承Human的子类
 */
puclic class Test{
    public void sayHello(Human guy){
        System.out.println("Hello,guy!");
    }
    public void sayHello(Woman guy){
        System.out.println("Hello,woman!");
    }
    public void sayHello(Man guy){
        System.out.println("Hello,man!");
    }
    Human man = new Man();
    Human women = new Women();

    public static void main(String args){
        Test sr;
        sr.sayHello(man);
        sr.sayHello(woman);
    }
    

}

实际输出结果为:
hello,guy!
hello,guy!

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

//静态类型变化
sr.sayHello((Man) man)
sr.sayHello((Women) man)
  • 在方法确认是对象“sr”的前提下,使用哪个重载版本,完全取决于传入参数的数量和数据类型。编译器在重载是通过参数的静态类型而不是实际类型作为判断依据。原因在于静态类型在编译器可知。

动态分派

  • 我们把在运行期根据实际类型确定方法执行版本的分派过程称为动态分派
/**
 *动态分派演示
 */
puclic class Test{

    static abstract class Human{
        protected abstract void sayHello();
    }    

    static class Man extends Human{
        @Override
        protected void sayHello(){
            System.out.println("man say hello");
        }
    }

    static class Women extends Human{
        @Override
        protected void sayHello(){
            System.out.println("women say hello");
        }
    }

    public static void main(String args){
        Human man = new Man();
        Human women = new Women();
        man.sayHello(man);
        women.sayHello(woman);
        man = new Women();
        man.sayHello();
    }   
}

运行结果:
man say hello
women say hello
women say hello

  • 在这里不是通过静态类型来决定的,导致这个现象的原因在于两个变量的实际类型不同。
  • Java虚拟机根据实际类型来分派的方法,单从字节码的角度来看,都是用invokevirtual指令,步骤如下:
    1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
    2. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验(比如是否为private),如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
    3. 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证。
    4. 如果始终没有找到合适的方法,则抛出java.lang.AbractMethodError异常。
  • 由于invokevirtual执行的第一步就是在运行期确定接收者的实际类型,所以在上面的代码演示中,解析到了不同的直接引用上,这个过程就是Java语言中方法重写的实质。

单分派与多分派

  • 方法的接收者与方法的参数统称为方法的宗量单分派是根据一个宗量对目标进行选择,多分派则是根据多于一个的宗量对目标方法进行选择。
  • 今天的Java语言是一门静态多分派、动态单分派的语言。

虚拟机动态分派的实现

  • 虚拟机的实际实现中基于性能的考虑,大部分实现都不会进行繁琐的搜索。最常用的方法是为类在方法区中建立一个虚方法表
  • 虚方法表中存放着各个方法的实际入口地址。如果某个没有被重写,则子类的虚方法表里面的地址入口和父类相同方法的地址入口一致,如果子类重写了这个方法,那么子类方法表的地址将会被替换为指向子类实现版本的入口地址。
  • 除了方法表这种“稳定优化”手段,还会使用内联缓存(Inline Cache)和基于类型继承关系分析(Class Hiserachy Analysis,CHA)技术的守护内联(Guarded Inlining)两种非稳定的“激进优化”手段来获取更高的性能。

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

  • 基于栈的指令集最主要的优点就是可移植性,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地受到硬件地约束。使代码相对更紧凑,编译器实现更加简单等。主要缺点是执行速度相对来说稍慢一些。
  • 基于栈的指令集和基于寄存器的指令集的不同,以计算“1+1”为例:
    基于栈的指令集
    inconst_1
    inconst_1
    iadd
    istore_0
    连续将1压入栈,取出栈顶两个值出栈并相加,然后将结果放回栈顶。
    基于寄存器的指令集
    mov eax,1 add eax,1
    将寄存器的值设为1,然后再把这个值加1,结果就保存在EAX寄存器里面。

  •  

猜你喜欢

转载自blog.csdn.net/zhuochuyu7096/article/details/85119523
今日推荐