读写局部变量表与操作数栈
读写局部变量表与操作数栈
读写局部变量表与操作数栈就是将局部变量push进操作数栈与将操作数栈的栈顶元素存储到局部变量表的操作。
将局部变量表中的元素放入操作数栈只能放入栈顶,而将操作数栈的栈顶元素存到局部变量表是可以指定存到局部变量表的位置的,这个过程其实就是给局部变量赋值。
与汇编语言有相似之处就是字节码指令不能直接将局部变量表的某个元素赋值给局部变量表的另一个元素,必须通过操作数栈完成。这也是为什么说字节码指令集是基于栈的指令集。
局部变量表的大小与操作数栈的深度是在Java代码编译成class字节码文件时就已经确定,使用javap -v命令可以查看当前class文件中每个方法的操作数栈深度与局部变量表大小。如图3.4所示。

图3.4 局部变量表与操作数栈大小
以一个给局部变量赋值的例子理解读写操作数栈与局部变量表,如代码清单3-4所示。
代码清单3-4 Java给局部变量赋值
public static void main(String[] args) { int a=10,b=20; int c=b; b=a; }
使用javap命令输出这段代码的字节码如下。
public static void main(java.lang.String[]); Code: stack=1, locals=4, args_size=1 0: bipush 10 2: istore_1 3: bipush 20 5: istore_2 6: iload_2 7: istore_3 8: iload_1 9: istore_2 10: return
结果显示,局部变量表的大小为4,操作数栈的大小是1。局部变量表的每个Slot分别用于存储main方法中类型为String数组的参数的引用,以及变量a、b、c的值。为什么局部变量表的大小为4,操作数栈的大小只是1呢?我们带着这个疑问分析这些字节码指令的执行过程。
通过javap查看字节码,我们发现,在字节码指令的前面都会标有数字,如代码清单3-4编译后的字节码所示。这些数字是每条指令在Code属性中code[]数组的索引,也可称为下标或者偏移量。把这些指令的索引连在一起看,发现不是连续的,这是因为有些指令需要操作数,在需要操作数的指令后面会存储该指令执行所需的操作数,所以指令前面的数字不是连续的。
现在我们分析代码清单3-4编译后的字节码指令的执行过程。偏移量为0的指令为bipush指令,该指令是将一个立即数10放入操作数栈顶。该指令执行完后,操作数栈与局部变量表的变化如图3.5所示。

图3.5 偏移量为0的指令执行完成后
偏移量为2的指令是istore_1,该指令是将当前操作数栈顶的元素存储到局部变量表索引为1的Slot(第二个Slot)。该指令执行完成后,局部变量表索引为1的Slot存储整数10,操作数栈顶的元素已经出栈,此时操作数栈为空。如图3.6所示。

图3.6 偏移量为2的指令执行完成
Java虚拟机执行字节码指令并不关心局部变量表索引为1的元素在源码中叫什么名字。那我们怎么知道这个位置是局部变量a、b、c的哪个呢?这就需要通过查看LocalVariableTable属性了。使用javap命令输出此例子的LocalVariableTable属性如下。
LocalVariableTable: Start Length Slot Name Signature 0 11 0 args [Ljava/lang/String; 3 8 1 a I 6 5 2 b I 8 3 3 c I
第一行:局部变量的作用范围为[0,11)[1],使用局部变量表中的第一个Slot存储,该局部变量的名称为“args”,变量的类型签名为“[Ljava/lang/String”;
第二行:局部变量的作用范围为[3,11),使用局部变量表中的第二个Slot存储,该局部变量的名称为“a”,类型签名为“I”;
第三行:局部变量的作用范围为[6,11),使用局部变量表中的第三个Slot存储,该局部变量的名称为“b”,类型签名为“I”;
第四行:局部变量的作用范围为[8,11),使用局部变量表中的第四个Slot存储,该局部变量的名称为“c”,类型签名为“I”。
继续分析代码清单3-4编译后的字节码指令的执行过程。偏移量为3的字节码指令为bipush指令,该指令的作用是将立即数20放入操作数栈顶。该指令执行完成后,局部变量a的值还是10,操作数栈顶存储立即数20。如图3.7所示。

图3.7 偏移量为3的指令执行完成
偏移量为5的字节码指令为istore_2,该指令不需要操作数,作用是将当前操作数栈的栈顶元素存储到局部变量表索引为2的Slot。该指令执行完成后,a=10,b=20,操作数栈顶的元素出栈,操作数栈为空。如图3.8所示。

图3.8 偏移量为5的指令执行完成
偏移量为6的字节码指令为aload_2,该指令不需要操作数,作用是将局部变量表索引为2的元素放入操作数栈的栈顶。该指令执行完成后,a=10,b=20,操作数栈的栈顶存储整数20。如图3.9所示。

图3.9 偏移量为6的指令执行完成
偏移量为7的字节码指令为istore_3,该指令的作用是将当前操作数栈的栈顶元素存储到局部变量表索引为3的Slot。偏移量为6和7的两条指令完成将局部变量b赋值给局部变量c。该指令执行完成后,a=10,b=20,c=20,操作数栈顶元素出栈,操作数栈为空,如图3.10所示。

图3.10 偏移量为7的指令执行完成
偏移量为8和9的两条字节码指令分别为iload_1和istore_2,这两条字节码指令的作用是完成局部变量a赋值给局部变量b的操作,这两条指令执行完成后,局部变量与操作数栈的变化如图3.11所示。

图3.11 偏移量为8和9的两条指令执行过程
从整个方法的字节码指令执行过程来看,该方法执行所需要占用操作数栈的Slot最多只有一个,因此该方法的操作数栈的大小被编译器设置为1,不浪费任何空间。而方法参数args和方法体内声明的局部变量a、b、c它们的作用域是整个方法,因此需要为args、a、b、c都分配一个局部变量槽位,局部变量表的大小被编译器设置为4。
我们通过这个例子了解了局部变量表和操作数栈的读写,其中iload_xx指令就是将局部变量表的元素放入栈顶,istore_xx指令就是将当前操作数栈的栈顶元素存储到局部变量表。xx是局部变量表的索引,局部变量表是一个数组,需要通过索引访问数组中的元素。iload_xx和istore_xx对应的字节码指令如表3-6所示[2]。
表3-6 iload_xx指令与istore_xx指令


iload_xx和istore_xx只能访问局部变量表索引为0到3的元素,那假如局部变量表的长度超过4呢,没有iload_4指令?是的,没有iload_4指令,只能使用iload和istore指令。
其实不管访问局部变量表的哪个位置,都可以通过iload和istore指令访问,那为什么还要iload_xx和istore_xx指令呢。因为iload和istore指令需要操作数,而iload_xx和istore_xx不需要操作数,在编译后能减少Code属性的code[]字节数组的大小,而且大多数方法都不会超过3个参数。因为非静态方法的局部变量表的下标0用于保存this引用,所以是4减1个参数。
例子中的iload_xx指令和istore_xx指令只能操作Java中int类型的变量,与之对应的还有操作float类型的fload_xx和fstore_xx指令,操作long类型的lload_xx和lstore_xx指令,操作double类型的dload_xx和dstore_xx指令,以及操作引用类型的aload_xx和astore_xx指令,还有fload、lload、dload、aload指令。详情见表3-7和表3-8。
表3-7 各类型的加载指令


表3-8 各类型的存储指令


代码清单3-4的例子中还用到了bipush指令。bipush用于将一个int型的立即数放入操作数栈的栈顶,该指令属于操作常量与立即数入栈一类的指令。除bipush之外还有将null放入操作数栈栈顶的iconst_null指令、将常量池中的常量值放入操作数栈顶的指令ldc。还有iconst_xx指令,xx可取值为-1到5,作用是将-1~5的立即数放入操作数栈顶。还有fconst_xx、dconst_xx、lconst_xx,xx代表0或1,这些指令分别是将立即数1、2作为浮点数或者双精度浮点数、长整型放入操作数栈顶,不过这几条指令不常用。
在使用将立即数放入操作数栈栈顶的这类指令时,如果立即数大于等于-1且小于等于5,可使用对应的iconst_xx指令,如果立即数超过5,只能使用bipush指令。这也是很多人第一次接触字节码指令时很是不理解的,为什么int a=3与int a=10反编译后字节码指令会不同的原因。
注释:
[1] 表示在偏移量为0至偏移量为11的字节码指令范围内,指定的Slot存储的变量的变量名为“args”,也是限定局部变量“args”的作用范围。
[2] 本书字节码指令表均参考:https://www.cnblogs.com/longjee/p/8675771.html