8、JVM--虚拟机字节码执行引擎

时间:2024-04-14 10:34:30

8.1、概述

执行引擎是Java虚拟机最核心的组成部分之一。“虚拟机”是一个相对于“物理机”的概
念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬
件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制
定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种
虚拟机执行引擎的统一外观Facade)。

在不同的虚拟机实现里面,执行引擎在执行Java代
码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码
执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。
但从外观上看起来,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理
过程是字节码解析的等效过程,输出的是执行结果

8.2、运行时栈帧结构

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

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附
加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完
全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受
到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

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

执行引擎运行的所有字节码指令都只针对当前栈帧进行操作

栈帧的概念结构:

8、JVM--虚拟机字节码执行引擎

8.2.1、局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数方法
内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals
据项中确定了该方法所需要分配的局部变量表的最大容量。

局部变量表的容量以变量槽Variable Slot,下称Slot)为最小单位,虚拟机规范中并没
有明确指明一个Slot应占用的内存空间大小,只是很有导向性地说到每个Slot都应该能存放一
个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类
型,都可以使用32位或更小的物理内存来存放,但这种描述与明确指出“每个Slot占用32位长
度的内存空间”是有一些差别的,它允许Slot的长度可以随着处理器、操作系统或虚拟机的不
同而发生变化。

只要保证即使在64位虚拟机中使用了64位的物理内存空间去实现一个Slot,
虚拟机仍要使用对齐和补白的手段让Slot在外观上看起来与32位虚拟机中的一致。

一个Slot可以存放
一个32位以内的数据类型,Java中占用32位以内的数据类型有boolean、byte、char、short、
int、float、reference和returnAddress 8种类型。前面6种不需要多加解释,读者可以按照Java
语言中对应数据类型的概念去理解它们(仅是这样理解而已,Java语言与Java虚拟机中的基
本数据类型是存在本质差别的),而第7种reference类型表示对一个对象实例的引用,虚拟机
规范既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但一般来说,虚拟机
实现至少都应当能通过这个引用做到两点,一是从此引用中直接或间接地查找到对象在Java
堆中的数据存放的起始地址索引,二是此引用中直接或间接地查找到对象所属数据类型在方
法区中的存储的类型信息,否则无法实现Java语言规范中定义的语法约束约束。第8种即
returnAddress类型目前已经很少见了,它是为字节码指令jsr、jsr_w和ret服务的,指向了一条
字节码指令的地址,很古老的Java虚拟机曾经使用这几条指令来实现异常处理,现在已经由
异常表代替。

对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。Java
语言中明确的(reference类型则可能是32位也可能是64位)64位的数据类型只有long和double
两种。值得一提的是,这里把long和double数据类型分割存储的做法与“long和double的非原
子性协定”中把一次long和double数据类型读写分割为两次32位读写的做法有些类似,读者阅
读到Java内存模型时可以互相对比一下。不过,由于局部变量表建立在线程的堆栈上,是线
程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题

虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最
大的Slot数量。如果访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64
位数据类型的变量,则说明会同时使用n和n+1两个Slot。对于两个相邻的共同存放一个64位
数据的两个Slot,不允许采用任何方式单独访问其中的某一个,Java虚拟机规范中明确要求
了如果遇到进行这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常。

在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如
果执行的是实例方法(非static的方法),那局部变量表中第0位索引的Slot默认是用于传递方
法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参
数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体
内部定义的变量顺序和作用域分配其余的Slot。

为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,
其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的
作用域,那这个变量对应的Slot就可以交给其他变量使用。

8.2.2、操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出(Last In First
Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的
max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和
double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法执行的任
何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

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

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

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时
候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。再以上
面的iadd指令为例,这个指令用于整型数加法,它在执行时,最接近栈顶的两个元素的数据
类型必须为int型,不能出现一个long和一个float使用iadd命令相加的情况。

另外,在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚
拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数
栈与上面栈帧的部*部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数
据,无须进行额外的参数复制传递

两个栈帧之间的数据共享:

8、JVM--虚拟机字节码执行引擎

Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。

8.2.3、动态连接

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

8.2.4、方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法第一种方式是执行引擎遇到
任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当
前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来
决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。

第二种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得
到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异
常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出
方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常
完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能
继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行
状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可
能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈
帧中一般不会保存这部分信息。

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

8.2.5、附加信息

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

8.3、方法调用

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

在程序运行时,进行方法
调用是最普遍、最频繁的操作,但前面已经讲过,Class文件的编译过程中不包含传统编译中
的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行
时内存布局中的入口地址(相当于之前说的直接引用)。

这个特性给Java带来了更强大的动
态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运
行期间才能确定目标方法的直接引用。

8.3.1、解析

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

换句话说:

调用目标在程序代码写好、编译器进行编
译时就必须确定下来。这类方法的调用称为解析(Resolution)。

在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和
私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决
定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解

与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令,分别如下。
invokestatic:调用静态方法。
invokespecial:调用实例构造器<init>方法、私有方法和父类方法。
invokevirtual:调用所有的虚方法。
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方
  法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令
  的分派逻辑是由用户所设定的引导方法决定的。

只要能被invokestaticinvokespecial指令调用的方法,都可以在解析阶段中确定唯一的
调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类
加载的时候就会把符号引用解析为该方法的直接引用。

这些方法可以称为非虚方法,与之相
反,其他方法称为虚方法(除去final方法,后文会提到)。

实例:

8、JVM--虚拟机字节码执行引擎

静态方法sayHello()只可能属于类型StaticResolution,没有任
何手段可以覆盖或隐藏这个方法

使用javap命令查看这段程序的字节码,会发现的确是通过invokestatic命令来调用
sayHello()方法的。

8、JVM--虚拟机字节码执行引擎

Java中的非虚方法除了使用invokestaticinvokespecial调用的方法之外还有一种,就是被
final修饰的方法。

虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,
没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯
一的。在Java语言规范中明确说明了final方法是一种非虚方法。

解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉
及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派
(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和
多分派。这两类分派方式的两两组合就构成了静态单分派静态多分派动态单分派动态
多分派4种分派组合情况

。。。。