JVM字节码执行引擎

1.概述

字节码执行引擎是java虚拟机的核心组成之一,虚拟机与物理机都有代码执行能力,物理机执行引擎是建立在处理器、硬件、指令集和操作系统层面上的,虚拟机的执行引擎则是自己实现可以自行指定指令集与虚拟机的结构体系,并且执行那些不被硬件直接支持的指令集格式。
不同java虚拟机实现的执行引擎在执行代码时不一样,有解释执行和编译执行两种选择,也有可能两者都使用。整体上所有的执行引擎都是一致的:输入字节码文件,处理过程是解析字节码,输出是执行结果。

2.运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机栈中的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接、方法返回地址等信息。每个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机里面从入栈到出栈的过程。而栈帧中需要多大的局部变量表、多深的操作数栈都已经在编译期确定了,并且写入到了方法表的Code属性中,因此一个栈帧分配多少内存不会受到程序运行期影响。

2.1.局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽(Variable Slot)为最小单位,一个Slot可以存放一个32位以内的数据类型。
        java中占用32位以内的数据类型有boolean、byte、char、short、int、float、reference或returnAddress类型。reference是对象的引用,虚拟机实现至少都能够从此引用中直接或间接的找到对象在java堆中的起始索引地址和方法区中的对象类型数据。returnAddress是为字节码指令jsr、jsr_w和ret服务的,它指向了一条字节码指令的地址。
        对于64位数据类型,虚拟机会在高位以在前的方式为其分配两个连续的Slot空间,java中的64位数据类型只有long和double两种,由于局部变量表是建议在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否是原子操作都不会引起数据安全问题。
局部变量表的几个特点如下:

  • 虚拟机通过索引定位的方式使用局部变量表,索引值范围从0开始到局部变量表的最大Slot数量。如果是32位数据类型的变量,索引n就代表使用了第n个Slot,如果 是64位则索引n表示要使用第n和n+1两个Slot。
  • 在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程,如果是实例方法(非static方法),那么局部变量表中的第0位索引的Slot默认是用于传递方法所属对象实例的引用,即在方法中可以通过“this”关键字来访问这个隐含的参数。其余参数按照参数表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域来分配其余的Slot。
  • 局部变量表中的Slot是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那么这个变量对应的Slot就可以交给其他变量使用,这样的设计不仅仅是为了节省栈空间,有些情况下也会影响到垃圾收集的行为。如下:

代码示例1:

package com.glt.bytecodeExeEngine;

/**
 * VM args:
 * -verbose:gc
 */
public class SlotTest {
    public static void main(String[] args) {
        byte[] pg = new byte[64 * 1024 * 1024];
        System.gc();
    }
}

输出如下:
![在这里插入图片描述](https://img-blog.csdnimg.cn/20190629155502975.png

结果中看到执行GC之后没有回收掉此变量,因为变量还在作用域之内,GC就不会回收到这块内存。

代码示例2

package com.glt.bytecodeExeEngine;

/**
 * VM args:
 * -verbose:gc
 */
public class SlotTest {
    public static void main(String[] args) {
        {
            byte[] pg = new byte[64 * 1024 * 1024];
        }
        System.gc();
    }
}

输出如下:
在这里插入图片描述

结果中看到内存还是没有被回收掉,这是因为虽然pg对象已经脱离了作用域但是之后没有任何对局部变量表的读写操作,pg原本所占用的Slot没有被其他变量所引用,所以GC Roots还保持着对这个对象关联,造成GC时候内存不会被回收。如果在脱离作用域之后对局部变量表进行一次读写,就会顺利回收掉内存,如“代码示例3”。

代码示例3

package com.glt.bytecodeExeEngine;

/**
 * VM args:
 * -verbose:gc
 */
public class SlotTest {
    public static void main(String[] args) {
        {
            byte[] pg = new byte[64 * 1024 * 1024];
        }
        int a = 0;
        System.gc();
    }
}

输出如下:
在这里插入图片描述

结果看到GC之后内存被回收了,

2.2.操作数栈

操作数栈也称为操作栈,它的最大深度也是在编译时候就被写入到了Code属性中。操作数栈的每个元素可以是任意的数据类型,包括long和double,32位的数据类型占用栈容量为1,64位数据类型所占的栈容量为2,在方法执行的任何时候,操作数栈的深度都不会超过在编译阶段设定的最大值。
        当方法开始执行时候,这个方法的操作数栈是空的,方法执行过程中会有各种字节码指令想操作数栈中写入和提取内容,也就是入栈和出栈操作。
        例如,在算术运算的时候就是通过操作数栈来进行的,整数加法的字节码执行iadd在运行时候要求操作数栈中最接近站定的两个元素已经存入了两个int型的数值,当这个指令执行时候,会将这两个int值出栈并进行相加后将结果重新入栈。
        在概念模型中,两个栈帧作为虚拟机栈的元素相互之间是完全独立的,但是大多数虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠,让下面栈帧的部分操作数栈和上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,而无需进行额外的参数复制传递了。java虚拟机中的解释执行引擎称为“基于栈的执行引擎”,其中的栈就是操作数栈。

2.3.动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化成为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

2.4.方法返回地址

一个方法被执行后,有两种方式退出这个方法:

  • 在执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值的类型将根据遇到和追踪方法返回指令来决定,这种退出方法的方式成为正常完成出口。
  • 在方法执行过程中遇到了异常,并且这个异常没有在方法内得到处理,无论是java虚拟机内部产生的异常,还是代码中使用thorw字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口。

方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个值。方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中不会保存这部分信息。
        方法退出的过程等于是把当前栈帧出栈,因此退出时可能执行的操作有:

  • 恢复上层方法的局部变量表和操作数栈
  • 把返回值(如果存在的话)压入调用者栈帧的操作数栈中
  • 调整PC计数器的值以指向方法调用指令后面的一条指令

2.5.附加信息

虚拟机规范中允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息。实际开发中一般把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

3.方法调用

方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用的哪一个方法),暂时还涉及方法的具体运行过程。
        所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种调用的前提是调用目标在程序代码写好、编译器进行编译时就必须确定下来,这类方法的调用称为解析。符合这个要求的方法主要有静态方法和私有方法两大类,前者与类型有直接关联,后者在外部不可被访问,这两种方法都不可能通过继承或者别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。
        解析调用是个静态过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为直接引用,不会延迟到运行期再去完成。而分派(Dispact)调用则可能是静态的也可能是动态的,根据分依据的宗量数可分为单分派和多分派,两类分派组合又构成了静态单分派、静态多分派、动态单分派、动态多分派等。

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

java虚拟机的执行引擎在执行java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生的本地代码执行)两种选择。

4.1.解释执行

在出现即时编译器之前java都是解释执行,在即时编译器出现之后class文件中代码到底是编译执行还是解释执行只有虚拟机执行时候才知道了。
无论是解释还是编译,无论是虚拟机还是物理机,对于应用程序,大部分的程序代码到物理机的目标代码或者虚拟机能执行的指令集之前,都需要经过如下几个步骤:
在这里插入图片描述
其中中间一行为解释执行,下面一行为编译执行。对于java来说,javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在java虚拟机之外进行的,而解释器是在虚拟机的内部,所以java程序的编译就是半独立的实现。

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

java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流里面的指令大部分都是零地址指令,他们依赖操作数栈进行工作。而另外一种就是基于寄存器的指令集。
基于栈的指令集特点:

  • 可移植性,不依赖具体硬件,只依赖虚拟机,由虚拟机实现来决定把操作频繁的数据放在寄存器中以获取更高性能。
  • 代码更加紧凑,字节码中每个字节就对应一个指令,而多地址指令集还需要存放参数
  • 编译器实现更加简单,不需要考虑空间分配问题,所有空间都在栈上操作。
  • 执行速度相对于基于寄存器的指令集来说稍慢。
    基于寄存器的指令集特点:
  • 可移植性查,直接依赖于寄存器,寄存器由硬件实现,不可避免受到硬件约束。
  • 执行速度相对来说稍快。

4.3.基于栈的解释器执行过程

执行运算的整个过程及中间变量都是以操作数栈的出栈和入栈为信息交换途径,如1+2=3

  • 先将1推入操作数栈顶,后跟一个参数指明常量值
  • 将操作数栈顶的值出栈并存入第一个局部变量Slot中
  • 将2推入操作数栈顶,后跟一个参数指明常量值
  • 将操作数栈顶的值出栈并存入第一个局部变量Slot中
  • 将第一个Slot的值复制到操作数栈顶
  • 将第二个Slot的值复制到操作数栈顶
  • 将操作数栈顶的两个栈顶元素出栈,做整型加法,然后重新入栈
  • 执行方法返回指令,将操作数栈顶的整型值返回给此方法的调用者,执行结束。

上面的过程仅是一种概念模型,虚拟机最终会对执行过程做出一些优化来提高性能,如虚拟机的解析器和即时编译器都会对输入的字节码进行优化。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值