栈帧
Java虚拟机栈
是程序执行所必须的数据结构,每个Java线程
都有一个JVM栈, JVM栈中以栈帧为存储单元,栈帧中存储执行方法必要的局部表量表、操作数栈、动态链接、方法返回值等信息。每次调用方法都会创建一个新的栈帧,方法执行结束或退出,栈帧即消亡。JVM栈结构如下∶
以下面代码为例,来分析栈帧的结构:
public static int add(){
int a = 1;
int b = 2;
return a+b;
}
通过 javap
得出可视化的方法结构:
局部变量表(local)
局部变量表中存储方法参数和方法体内局部变量,在编译为 class 阶段,就确定其大小,和操作数栈一并记录在Code属于中。
局部变量表以槽(Solt)为存储单位,double和long类型使用2个Slot大小,其余类型占用1个Slot。方法中声明了多少变量和多少个参数,局部变量表就多大(double,long +1),成员方法比静态方法多了 1Solt大小
用于指向当前类 this。
操作数栈(stack)
操作数栈又叫做 求值栈
,和寄存器性质一样,是执行指令必要的数据区,栈的深度也在编译期间就确定。还是以上面的程序为例,简要分析栈帧结构和字节码指令的执行:
- 第一条
iconst_1
指令 ,从名字就可得知为常量,后面跟的是字面量,i代表常量类型。指令为:将一个int类型的常量1
加载到操作数栈。 - 第二条指令
istore_0
,将操作数栈栈顶的 int 类型的值存储到局部变量表索引为 0 的位置上。 - 第三、四条指令 和上面前两条指令一样,
- 第五、六条的
load指令
分别将局部变量表,索引 0 和 1 的值压入栈。 - 第七条
iadd
指令, 将栈顶两个 int 类型的值相加,并且将结果压入栈。 - 第八条
ireturn
指令,返回栈顶 int 类型的值,方法执行结束。
看到这儿,不禁产生疑问,计算一个如此简单的加法运算,竟然花费了整整 7 条指令(ireturn不算),前面 4 条指令都是,先把一个已知的值压入栈,再赋值给局部变量表,接着再从局部变量表压入栈,用作计算使用。难道不能在将 1 压入栈后,接着直接把 2 压入栈,再执行 add 命令,3条就搞定了?想了一下,前面的几条指令都是为了初始化局部变量表,假如后面还需要使用 a 变量,则需要从局部变量表中读取。虽然此处方法,后面已经没有代码再引用 a 变量,只能说 javac 还不够先进,或许给 javac 编译器定位的就是简单的解析翻译。
方法的参数,在方法调用时就已经加载到局部变量表了。因为方法调用传递的是实参,直接可以赋值。
动态链接
每一个栈帧都包含对运行时常量池的引用。很好理解,无论是执行的方法,还是方法体内的引用类型,都需要通过符号引用去解析确定在内存中的偏移量(指针、位置)。
方法返回地址
如果方法正常完成执行,根据返回指令来决定是否有返回值和返回值类型。方法正常完成执行,恢复调用者的状态,包括局部变量表、操作数栈,并增加调用者的 PC寄存器
跳过方法的调用指令,如果有返回值,则将返回值压入调用者的操作数栈。
如果在方法执行过程中抛出异常,并且该异常未捕获导致访问退出,永远不会给上层调用者产生任何返回值。
基于寄存器的执行引擎
下面带大家学几个汇编指令,有利于加深对 Java 字节码指令的理解汇编环境搭建工具
DOSBox0.74-win32-installer.exe一直next安装完成后,在安装目录下打开DOSBox 0.74 Options.bat
文件,直接双击用记事本打开,我这边用NotePad++打开有问题。在文件最下面加上 MASM 的路径
mount c: E:/MASM
c:
将E:/MASM
更改为自己的磁盘路径即可。打开DOSBox.exe
出现如下页面,8086 汇编环境即搭建成功。
接下来输入debug
换行执行后输入r
即可看到14个寄存器。
此时做一个 1+1=2
加法运算,接着上面的步骤输入执行a
命令,输入mov ax,0001
,mov bx 0001
,add ax,bx
三条汇编指令。 再按下 Enter件,输入t
执行命令。三次t
后 ax 寄存器值为 2。
mov
称为传送指令,可以理解为赋值,三条汇编指令对应 Java 中的伪代码:
short ax = 1;
short bx = 1;
ax = (short) (ax+bx);
我们输入的三条汇编指令,都会先存到内存当中,CS+IP 寄存器指向下一条执行指令位的地址,也就是大家常说的PC寄存器
同时也可以使用 mov 指令,将寄存器中的值,回写到内存当中。接着上面的步骤输入执行a
命令,输入汇编指令 mov [0],ax
,即可给 ds 寄存器指向的 073F 段内存地址的第 0 个字节赋值为 ax 上的 2。
反之给寄存器赋值也是一样 mov ax,[0]
。现在我们模拟的是x86的16位架构 8086CPU,x86处理器是二地址指令,一般形式为:
op dest,src
它要支持二元操作,就只能把其中一个源同时也作为目标。就像上面的 add ax,bx
指令,ax 寄存器做源又做计算后的存储目标地址。
arm处理器的主要指令集是三地址形式,一般形式为:
op dest, src1, src2
三地址指令正好可以指定两个源和一个目标,能非常灵活的支持二元操作与赋值的组合。
再来看上面的 Java字节码指令
后面没有附带任何的源地址和目标地址 ( 零地址指令 ) ,执行完全依赖操作数栈。零地址形式的指令集一般是通过 基于栈的架构
来实现的,由于指令的源与目标都是隐含的,零地址指令的密度
可以非常高——可以用更少空间放下更多条指令。因此在空间紧缺的环境中,零地址指令是种可取的设计。但零地址指令要完成一件事情,一般会比二地址或者三地址指令许多更多条指令。
基于栈与基于寄存器架构的VM,用哪个好?
实现简单
基于栈架构的指令集生成代码的编译器更容易实现。
移植性
在第一篇文章 粗谈Java虚拟机1_开山篇 就提到过硬件平台的差异性,arm 寄存器 本身就比 x86 架构的多。假如一个VM采用基于寄存器的架构(它接受的指令集大概就是二地址或者三地址形式的),为了高效执行,一般会希望能把源架构中的寄存器映射到实际机器上寄存器上。但是VM里有些很重要的辅助数据会经常被访问,例如一些VM会保存源指令序列的程序计数器(program counter,PC),为了效率,这些数据也得放在实际机器的寄存器里。如果源架构中寄存器的数量跟实际机器的一样,或者前者比后者更多,那源架构的寄存器就没办法都映射到实际机器的寄存器上;这样VM实现起来比较麻烦,与能够全部映射相比效率也会大打折扣。像Dalvik VM的解释器实现,就是把虚拟寄存器全部映射到栈帧(内存)里的,这跟把局部变量区与操作数栈都映射到内存里的JVM解释器实现相比实际区别不太大。
如果一个VM采用基于栈的架构,则无论在怎样的实际机器上,都很好实现——它的源架构里没有任何通用寄存器,所以实现VM时可以比较自由的分配实际机器的寄存器。于是这样的VM可移植性就比较高。作为优化,基于栈的VM可以用编译方式实现,求值栈
实际上也可以由编译器映射到寄存器上,减轻数据移动的开销。
回到主题,基于栈与基于寄存器的架构,谁更快?看看现在的实际处理器,大多都是基于寄存器的架构,从侧面反映出它比基于栈的架构更优秀。
而对于VM来说,源架构的求值栈或者寄存器都可能是用实际机器的内存来模拟的,所以性能特性与实际硬件又有点不同。一般认为基于寄存器的架构对VM来说也是更快的,原因是:虽然零地址指令更紧凑,但完成操作需要更多的load/store指令,也意味着更多的指令分派(instruction dispatch)次数与内存访问次数;访问内存是执行速度的一个重要瓶颈,二地址或三地址指令虽然每条指令占的空间较多,但总体来说可以用更少的指令完成操作,指令分派与内存访问次数都较少。
摘自RednaxelaFX大大的 虚拟机随谈(一):解释器,树遍历解释器,基于栈与基于寄存器,大杂烩