JVM学习笔记(五):运行时栈帧结构、方法调用、基于栈的字节码解释执行引擎

7 篇文章 0 订阅

虚拟机字节码执行引擎

概述

执行引擎是Java虚拟机核心的组成部分之一。物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎是由软件自行实现的,因此可以不受物理条件制约,定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式

Java虚拟机通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备。但是从外观上,所有的Java虚拟机的执行引擎输入和输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。

运行时栈帧结构

Java虚拟机以方法作为最基本的执行单位,**栈桢(Stack Frame)**则是用于支持虚拟机进行方法的调用和方法执行背后的数据结构。每一个方法从调用开始至执行结束的过程,都对应着一个栈桢在虚拟机中从入栈到出栈的过程。对于执行引擎来说,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈桢才是生效的。

每一个栈桢都包括局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。同时在编译Java程序源码的时候,栈桢需要多大的局部变量表和多深的操作数栈已经被分析计算出来,并写到方法表的Code属性中。
在这里插入图片描述

一、局部变量表

局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。方法表的Code属性的max_locals数据项定义了该方法局部变量表的最大容量。

局部变量表的容量以变量槽(Variable Slot)作为最小单位。boolean、byte、char、short、int、float 、reference或者returnAddress都是使用一个变量槽存放。long、double使用两个连续的变量槽存放,虚拟机不允许采用任何方式单独访问其中一个,如果出现这种情况会抛出异常。

其中引用reference有两个作用:

  • 从根据引用直接或者间接地查找到对象在Java堆中的数据存放的起始地址或者索引
  • 根据引用直接或者间接的查找到对象所属数据类型在方法区中存储的类型信息

当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。

如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以使用this关键字来访问这个隐藏的参数。

局部变量表在使用的过程中有两个需要注意的地方:

1、为了节省空间,局部变量表中的变量槽是可以重用的

方法体中定义的变量,其作用域并一定是覆盖整个方法的,如果当前字节码PC计数器的值已经超过了其中某个变量的作用域,那么这个变量对应的变量槽就可以交给其他变量重用。但是需要注意的是重用不是立即发生,下面使用一个例子进行解释,运行时加上-verbose:gc来显示GC过程:

public class VariableTableTest {

    public static void main(String[] args) {
        byte[] placeHolder = new byte[64 * 1024 * 1024];
        System.gc();
    }
}

结果如下,可以看出placeHolder没有被回收,因为System.gc()在placeHolder引用变量的作用域中,这很好理解。

在这里插入图片描述

再看看下面这段代码:

public class VariableTableTest {

    public static void main(String[] args) {
        {
            byte[] placeHolder = new byte[64 * 1024 * 1024];
        }
//        int i = 0;
        System.gc();
    }

}

在这里插入图片描述

结果如上图所示,按道理说placeHolder作用域已经过了,为什么还是没有被回收呢?先别急,咱们先看看下面这个代码:

public class VariableTableTest {

    public static void main(String[] args) {
        {
            byte[] placeHolder = new byte[64 * 1024 * 1024];
        }
        int i = 0;
        System.gc();
    }

}

在这里插入图片描述

这个代码中仅仅是增加了int i = 0这个操作,从GC结果就可以看出placeHolder引用的变量被回收了。这是为什么呢?这是因为第一次修改时,代码虽然已经离开了placeHolder的作用域,但是在此之后,在没有发生过任何对局部变量表的读写操作,placeHolder原本占用的变量槽还没有被其他变量所复用,所以GC Roots一部分的局部变量表仍然保持对他的关联。所以说变量槽的复用不是及时。

2、局部变量必须赋初值之后才能使用

对于类字段来时有两次赋初始值的过程,一次在准备阶段,赋予系统默认值;另一次在初始化阶段,赋予程序定义的初始值。而对于局部变量,并没有赋予系统默认值这个过程,所以局部变量如果没有赋初值是不能使用的,编译期就会报错。

二、操作数栈

操作数栈(Operand Stack)也常被称为操作栈,它是一个先进先出(LIFO)的栈。操作数栈的最大深度在编译的时候写入到code属性中max_stacks数据项中。

方法刚开始执行时,操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈和出栈操作。

操作数栈中元素的数据类型必须字节码指令的序列严格匹配

大多数虚拟机在实现的时候都会对栈桢做一定的优化,让下面栈桢的部分操作数栈与上面部分的局部变量表重叠在一起,如下图所示。这样做的好处有:一、节约空间,二、方法调用时可以直接共用一部分数据,无须进行额外的参数复制转递。

在这里插入图片描述

三、动态链接

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

四、方法返回地址

当一个方法开始执行之后,只有两种方式退出这个方法

  • 正常调用完成(Normal Method Invocation Completion):当执行引擎遇到任意一个方法返回的字节码指令时,这个时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法)。此时主调方法的PC计数器的值可以作为返回地址,栈桢中很可能保存这个计数器值
  • 异常调用完成(Abrupt Method Invocation Completion):当方法在执行过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理,也就是本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。一个方法使用异常调用完成退出是不会给他的上层调用返回任何值。此时返回地址是要通过异常处理器表来确定,栈桢一般不会保存这部分信息。

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

五、附加信息

《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈桢中,例如与调试、性能收集相关的信息。

方法调用

方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还没涉及方法内部的执行过程。

Class文件的编译过程中不包含传统程序语言编译的连接过程,一切方法调用在Class文件中都是符号引用,而不是方法在实际运行时内存布局的入口地址(也就是直接引用)。这样做虽然增加了复杂度,但是给Java带来了更强大的动态扩展能力。

调用不同类型的方法,字节码指令集中设计了不同的指令

  • invokestatic:用于调用静态方法
  • invokespecial:用于调用实力构造方法<init>()方法、私有方法和父类中的方法
  • invokevirtual:用于调用所有的虚方法
  • invokeinterface:用于调用接口方法,会在运行时在确定一个实现该接口的对象
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后在执行该方法
一、解析

所有方法调用的目标方法在Class文件中都只是一个常量池中的引用符号,在类的解析阶段,会将其中一部分符号引用转化为直接引用,前提是:方法在程序真正运行之前就有一个可以确定的调用版本,并且这个方法的调用版本在运行期间时不可改变的。换句话说,调用目标在程序代码写好,编译器进行编译那一刻就已经确定下来了。这来方法的调用称为解析(Resolution)

上面提到的5种调用指令中,只要被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,Java语言中符合这个要求的有静态方法、私有方法、实例构造器、父类方法这4种。除此之外还有final方法,虽然final方法使用invokevirtual调用,但是它无法被覆盖,没有其他版本的可能。

解析调用一定是个静态过程,在编译期间就完全确定,在类加载的解析阶段就会把设计符号引用转化为明确的直接引用

示例:将下面的StaticResolution编译得到Class,使用javap -verbose StaticResolution.class查看字节码文件的内容,可以发现,invokestatic后面的方法已经被解析出来了

public class StaticResolution {

    public static void sayHello(){
        System.out.println("Hello world!");
    }

    public static void main(String[] args) {
        StaticResolution.sayHello();
    }

}

在这里插入图片描述

二、分派

1、静态分派

所有依赖静态类型来决定方法执行版本的分派动作都成为静态分派。静态分派最典型的应用是方法重载(overload)。下面使用一个例子来解释上面的定义:

public class StaticDispatch {

    static abstract class Human{}

    static class Man extends Human {}

    static class Woman extends Human {}

    public void sayHello(Human human){
        System.out.println("Hello guy!");
    }

    public void sayHello(Man man){
        System.out.println("Hello guy!");
    }

    public void sayHello(Woman woman){
        System.out.println("Hello guy!");
    }

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

上面代码的结果是:

在这里插入图片描述

在解释为什么之前,先介绍两个关键概念,对于Human man = new Man()这段代码中

  • 前面的Human称为变量的静态类型(Static Type),或者称为外观类型(Apparent Type)
  • 后面的Man称为变量的实际类型(Actual Type),或者称为运行时类型(Runtime Type)

变量本身的静态类型不会改变,并且最终的静态类型在编译期是可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型

虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判断依据,由于静态类型在编译器可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定了会使用哪个重载版本

对于字面零类型(char)重载时的方法匹配优先级从高到低依次是:

  • 自动类型转换:char > int > long > float > double
  • 自动装箱到Character
  • 自动装箱后转型为父类或者实现的接口
  • 转变为可变长参数数组

2、动态分派

在运行期间根据实际类型确定方法执行的分派过程称为动态分派。动态分派与多态性的另外一个体现**重写(override)**模切相关

我们使用下面的代码进行讲解:

public class DynamicDispatch {

    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 Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("woman say hello");
        }
    }

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

在这里插入图片描述

运行结果如上图所示,上面的这段代码就是多态的体现,那它在虚拟机内部是怎么实现的呢,咱们来看看上面这段代码的字节码:

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #2                  // class jvm/ch8/DynamicDispatch$Man
         3: dup
         4: invokespecial #3                  // Method jvm/ch8/DynamicDispatch$Man."<init>":()V
         7: astore_1
         8: new           #4                  // class jvm/ch8/DynamicDispatch$Woman
        11: dup
        12: invokespecial #5                  // Method jvm/ch8/DynamicDispatch$Woman."<init>":()V
        15: astore_2
        16: aload_1
        17: invokevirtual #6                  // Method jvm/ch8/DynamicDispatch$Human.sayHello:()V
        20: aload_2
        21: invokevirtual #7                  // Method jvm/ch8/DynamicDispatch$Human.sayHello:()V
        24: new           #4                  // class jvm/ch8/DynamicDispatch$Woman
        27: dup
        28: invokespecial #5                  // Method jvm/ch8/DynamicDispatch$Woman."<init>":()V
        31: astore_1
        32: aload_1
        33: invokevirtual #6                  // Method jvm/ch8/DynamicDispatch$Human.sayHello:()V
        36: return

0~15行的字节码是准备工作,作用是建立man和woman的内存空间,调用Man和Woman类型的实例构造器,将这两个实例的引用存放在第1、2个局部变量表的变量槽中,这些动作实际对应了Java源码中的这两行代码:

Human man = new Man();
Woman woman = new Woman();

接下来的16~21行是关键部分,16行和20行的load指令分别将刚刚创建的两个对象引用压入栈顶,这两个对象是将要执行的sayHello()方法的所有者,第17和21使用invokevirtual指令对方法进行了调用,invokevirtual指令的运行时解析过程为:

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

正是因为invokevirtual指令执行的第一步就是在运行期间确定接收者的实际类型,所以动态分派是根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。

同时对于字段来说,当子类中声明了与父类同名的字段时,虽然子类的存中两个字段都存在,但是子类的字段会掩蔽父类的同名字段。调用字段时是根据静态类型进行判断调用哪个变量

3、单分派和多分派

方法的接收者和方法的参数统称为综量。根据分派基于多少个综量,可以将分派划分为单分派和多分派两种。在Java语言中,静态分派属于多分派,动态分派属于单分派

4、虚拟机动态分派实现

动态分派是非常频繁的操作,为了提高效率,虚拟机会为类型在方法区建立一个虚方法表(Virtual Method Table,也成为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表-Interface Method Table,简称itable )使用虚方法表索引来代替元数据查找以提高性能。

在这里插入图片描述

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

动态类型语言支持

待补充

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

上面介绍完了Java虚拟机是如何调用方法、进行版本选择的。现在开始分析概念模型下的Java虚拟机解释执行字节码时,其执行引擎是如何工作的。大部分的代码程序转换成物理机的目标代码或虚拟机能执行的指令集之前都需要经过下图所示的各个步骤:

在这里插入图片描述

图中下面的那条分支是传统编译原理中程序代码代码到目标机器码的生成过程。而中间那条分支就是解释执行的过程。

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

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

Javac编译器输出的字节码指令流,基本上是一种基于栈的指令集框架(Instruction Set Architecture,ISA),字节码指令流里边的指令大部分都是零地址操作,他们是依赖操作数栈进行的工作的。与之对应的是基于寄存器的指令集,最典型的就是x86二地址指令集,它是主流PC机中物理硬件直接支持的指令集框架,这些指令依赖寄存器进行工作。下面使用如何计算1+1对这两种指令集分别举一个例子:

1、基于栈的指令集:

iconst_1
iconst_1
iadd
istore_0

两条iconst_1指令连续将两个常量1放入操作数栈中,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0指令把栈顶的值放到局部变量表的第0个变量槽中。

基于栈的指令集中的指令是不带参数的,使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存储在操作数栈中。

2、基于寄存器的指令集

mov  eax, 1
add  eax, 1

mov指令把EAX寄存器的指设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器中。这种指令中的每条指令都包含两个单独的输入参数,依赖于寄存器来访问和存储信息。

3、基于栈的指令集与基于寄存器的指令集对比的优缺点

优点:

  • 基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要收到硬件的约束
  • 代码相对更加紧凑(字节码中每个字节对应一条指令,而多地址指令集中还需要存放参数)
  • 编译器实现更加简单(不用考虑空间分配的问题)

缺点:

  • 基于栈的指令集主要缺点是理论上执行速度相对来说会稍微慢一点,栈实现在内存中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说内存始终是执行速度的瓶颈
  • 基于栈的指令的指令集的代码虽然紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构来的更多,因为出栈、入栈操作本身都产生了相当大量的指令
基于栈的解释器执行过程

使用下面Java代码对基于栈的解释器执行过程进行分析:

public int calc(){
    int a = 100;
    int b = 200;
    int c = 300;
    return (a + b) * c;
}

它对应的字节码指令为:

  public int calc();
    descriptor: ()I
    flags: ACC_PUBLIC
    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

码指令为:

  public int calc();
    descriptor: ()I
    flags: ACC_PUBLIC
    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

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值