深入理解java虚拟机笔记(6)虚拟机字节码执行引擎1
执行引擎是Java虚拟机核心的组成部分之一。 “虚拟机”是一个相对于“物理机”的概念, 这两种机器都有代码执行能力, 其区别是物理机的执行引擎是直接建立在处理器、 缓存、 指令集和操作系统层面上的, 而虚拟机的执行引擎则是由软件自行实现的, 因此可以不受物理条件制约地定制指令集与执行引擎的结构体系, 能够执行那些不被硬件直接支持的指令集格式。 在不同的虚拟机实现中, 执行引擎在执行字节码的时候, 通常会有解释执行(通过解释器执行) 和编译执行(通过即时编译器产生本地代码执行) 两种选择[1], 也可能两者兼备, 还可能会有同时包含几个不同级别的即时编译器一起工作的执行引擎。
二、运行时栈帧结构
Java虚拟机以方法作为最基本的执行单元, “栈帧” 则是用于支持虚拟机进行方法调用和方法执行背后的数据结构, 它也是虚拟机运行时数据区中的虚拟机栈的栈元素。 栈帧存储了方法的局部变量表、 操作数栈、 动态连接和方法返回地址等信息。
每一个方法从调用开始至执行结束的过程, 都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。每一个栈帧都包括了局部变量表、 操作数栈、 动态连接、 方法返回地址和一些额外的附加信息。
在编译Java程序源码的时候, 栈帧中需要多大的局部变量表, 需要多深的操作数栈就已经被分析计算出来, 并且写入到方法表的Code属性之中。 换言之, 一个栈帧需要分配多少内存, 并不会受到程序运行期变量数据的影响, 而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
一个线程中的方法调用链可能会很长, 以Java程序的角度来看, 同一时刻、 同一条线程里面, 在调用堆栈的所有方法都同时处于执行状态。 而对于执行引擎来讲, 在活动线程中, 只有位于栈顶的方法才是在运行的, 只有位于栈顶的栈帧才是生效的, 其被称为“当前栈帧”, 与这个栈帧所关联的方法被称为“当前方法” 。 执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。
2.1.局部变量表
局部变量表(Local Variables Table) 是一组变量值的存储空间, 用于存放方法参数和方法内部定义的局部变量。 在Java程序被编译为Class文件时, 就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。 局部变量表的容量以变量槽为最小单位, 一个变量槽可以存放一个32位以内的数据类型, Java中占用不超过32位存储空间的数据类型有boolean、 byte、 char、 short、 int、float、 reference[1]和returnAddress这8种类型。 前面6种不需要多加解释,而第7种reference类型表示对一个对象实例的引用, 《Java虚拟机规范》 既没有说明它的长度, 也没有明确指出这种引用应有怎样的结构。
一般来说, 虚拟机实现至少都应当能通过这个引用做到两件事情, 一是从根据引用直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引, 二是根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息
第8种returnAddress类型目前已经很少见了, 它是为字节码指令jsr、 jsr_w和ret服务的, 指向了一条字节码指令的地址, 某些很古老的Java虚拟机曾经使用这几条指令来实现异常处理时的跳转, 但现在也已经全部改为采用异常表来代替了。
对于64位的数据类型, Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。 Java语言中明确的64位的数据类型只有long和double两种, 由于局部变量表是建立在线程堆栈中的, 属于线程私有的数据, 无论读写两个连续的变量槽是否为原子操作, 都不会引起数据竞争和线程安全问题。
Java虚拟机通过索引定位的方式使用局部变量表, 索引值的范围是从0开始至局部变量表最大的变量槽数量。 如果访问的是32位数据类型的变量, 索引N就代表了使用第N个变量槽, 如果访问的是64位数据类型的变量, 则说明会同时使用第N和N+1两个变量槽。
对于两个相邻的共同存放一个64位数据的两个变量槽, 虚拟机不允许采用任何方式单独访问其中的某一个, 《Java虚拟机规范》 中明确要求了如果遇到进行这种操作的字节码序列, 虚拟机就应该在类加载的校验阶段中抛出异常。
当一个方法被调用时, Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。 如果执行的是实例方法 , 那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用, 在方法中可以通过关键字“this”来访问到这个隐含的参数。
其余参数则按照参数表顺序排列, 占用从1开始的局部变量槽, 参数表分配完毕后, 再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。
为了尽可能节省栈帧耗用的内存空间, 局部变量表中的变量槽是可以重用的, 方法体中定义的变量, 其作用域并不一定会覆盖整个方法体, 如果当前字节码PC计数器的值已经超出了某个变量的作用域, 那这个变量对应的变量槽就可以交给其他变量来重用。 不过, 这样的设计除了节省栈帧空间以外, 还会伴随有少量额外的副作用, 例如在某些情况下变量槽的复用会直接影响到系统的垃圾收集行为
2.2 操作数栈
操作数栈(Operand Stack) 也常被称为操作栈, 它是一个后入先出栈。 同局部变量表一样, 操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。 操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。 32位数据类型所占的栈容量为1, 64位数据类型所占的栈容量为2。 Javac编译器的数据流分析工作保证了在方法执行的任何时候, 操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
当一个方法刚刚开始执行的时候, 这个方法的操作数栈是空的, 在方法的执行过程中, 会有各种字节码指令往操作数栈中写入和提取内容, 也就是出栈和入栈操作。 譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的, 又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。 举个例子, 例如整数加法的字节码指令iadd, 这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值, 当执行这个指令时, 会把这两个int值出栈并相加, 然后将相加的结果重新入栈。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配, 在编译程序代码的时候, 编译器必须要严格保证这一点, 在类校验阶段的数据流分析中还要再次验证这一点。
2.3.动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用, 持有这个引用是为了支持方法调用过程中的动态连接 。 我们知道Class文件的常量池中存有大量的符号引用, 字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。 这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用, 这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用, 这部分就称为动态连接。
2.4 方法返回地址
当一个方法开始执行后, 只有两种方式退出这个方法。 第一种方式是执行引擎遇到任意一个方法返回的字节码指令, 这时候可能会有返回值传递给上层的方法调用者 , 方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定, 这种退出方法的方式称为“正常调用完成” 。
另外一种退出方式是在方法执行的过程中遇到了异常, 并且这个异常没有在方法体内得到妥善处理。 无论是Java虚拟机内部产生的异常, 还是代码中使用athrow字节码指令产生的异常, 只要在本方法的异常表中没有搜索到匹配的异常处理器, 就会导致方法退出, 这种退出方法的方式称为“异常调用完成 ”。 一个方法使用异常完成出口的方式退出, 是不会给它的上层调用者提供任何返回值的。
无论采用何种退出方式, 在方法退出之后, 都必须返回到最初方法被调用时的位置, 程序才能继续执行, 方法返回时可能需要在栈帧中保存一些信息, 用来帮助恢复它的上层主调方法的执行状态。
一般来说, 方法正常退出时, 主调方法的PC计数器的值就可以作为返回地址, 栈帧中很可能会保存这个计数器值。 而方法异常退出时, 返回地址是要通过异常处理器表来确定的, 栈帧中就一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈, 因此退出时可能执行的操作有: 恢复上层方法的局部变量表和操作数栈, 把返回值(如果有的话) 压入调用者栈帧的操作数栈中, 调整PC计数器的值以指向方法调用指令后面的一条指令等。 笔者这里写的“可能”是由于这是基于概念模型的讨论, 只有具体到某一款Java虚拟机实现, 会执行哪些操作才能确定下来 .