操作数栈

在解释执行过程中,每当为 Java 方法分配栈桢时,Java 虚拟机往往需要开辟一块额外的空间作为操作数栈,来存放计算的操作数以及返回结果。具体来说便是:执行每一条指令之前,Java 虚拟机要求该指令的操作数已被压入操作数栈中。在执行指令时,Java 虚拟机会将该指令所需的操作数弹出,并且将指令的结果重新压入栈中。

注下图中的?可能是提前push的数据,当前是以后push的1,2为示例。

image.png
以加法指令 iadd 为例。假设在执行该指令前,栈顶的两个元素分别为 int 值 1 和 int 值 2,那么 iadd 指令将弹出这两个 int,并将求得的和 int 值 3 压入栈中。
image.png
由于 iadd 指令只消耗栈顶的两个元素,因此,对于离栈顶距离为 2 的元素,即图中的问号,iadd 指令并不关心它是否存在,更加不会对其进行修改。Java 字节码中有好几条指令是直接作用在操作数栈上的。最为常见的便是 dup: 复制栈顶元素,以及 pop:舍弃栈顶元素。

dup 指令常用于复制 new 指令所生成的未经初始化的引用。例如在下面这段代码的 foo 方法中,当执行 new 指令时,Java 虚拟机将指向一块已分配的、未初始化的内存的引用压入操作数栈中。

  1. public class Operators {
  2. public Operators foo() {
  3. return new Operators();
  4. }
  5. }
  6. public jvm.Operators foo();
  7. descriptor: ()Ljvm/Operators;
  8. flags: ACC_PUBLIC
  9. Code:
  10. stack=2, locals=1, args_size=1
  11. 0: new #2 // class jvm/Operators
  12. 3: dup
  13. 4: invokespecial #3 // Method "<init>":()V
  14. 7: areturn
  15. LineNumberTable:
  16. line 9: 0
  17. LocalVariableTable:
  18. Start Length Slot Name Signature
  19. 0 8 0 this Ljvm/Operators;

接下来需要用new出来的引用调用对象的构造方法,即invokespecial指令。由于invokespecial会消耗掉当前栈里的元素,因此原对象的引用不见了,因此需要再new之后 执行dup指令,复制一份引用。

因此,我们需要利用 dup 指令复制一份 new 指令的结果,并用来调用构造器。当调用返回之后,操作数栈上仍有原本由 new 指令生成的引用,可用于接下来的操作,调用其他方法。

pop 指令则常用于舍弃调用指令的返回结果。例如在下面这段代码的 foo 方法中,我将调用静态方法 bar,但是却不用其返回值。由于对应的 invokestatic 指令仍旧会将返回值压入 foo 方法的操作数栈中,因此 Java 虚拟机需要额外执行 pop 指令,将返回值舍弃。

  1. public class Operators {
  2. public void opt() {
  3. foo();
  4. }
  5. public Operators foo() {
  6. Operators o = new Operators();
  7. return o;
  8. }
  9. }
  10. public void opt();
  11. descriptor: ()V
  12. flags: ACC_PUBLIC
  13. Code:
  14. stack=1, locals=1, args_size=1
  15. 0: aload_0
  16. 1: invokevirtual #2 // Method foo:()Ljvm/Operators;
  17. 4: pop
  18. 5: return
  19. LineNumberTable:

需要注意的是,上述两条指令只能处理非 long 或者非 double 类型的值,这是因为 long 类型或者 double 类型的值,需要占据两个栈单元。当遇到这些值时,我们需要同时复制栈顶两个单元的 dup2 指令,以及弹出栈顶两个单元的 pop2 指令.

加载常量

在 Java 字节码中,有一部分指令可以直接将常量加载到操作数栈上。以 int 类型为例,Java 虚拟机既可以通过 iconst 指令加载 -1 至 5 之间的 int 值,也可以通过 bipush、sipush 加载一个字节、两个字节所能代表的 int 值。Java 虚拟机还可以通过 ldc 加载常量池中的常量值,例如 ldc #18 将加载常量池中的第 18 项。这些常量包括 int 类型、long 类型、float 类型、double 类型、String 类型以及 Class 类型的常量。
image.png

局部变量区

Java 方法栈桢的另外一个重要组成部分则是局部变量区,字节码程序可以将计算的结果缓存在局部变量区之中。实际上,Java 虚拟机将局部变量区当成一个数组,依次存放 this 指针(仅非静态方法),所传入的参数,以及字节码中的局部变量。
和操作数栈一样,long 类型以及 double 类型的值将占据两个单元,其余类型仅占据一个单元。

  1. public void foo(long l, float f) {
  2. {
  3. int i = 0;
  4. }
  5. {
  6. String s = "Hello, World";
  7. }
  8. }

以上面这段代码中的 foo 方法为例,由于它是一个实例方法,因此局部变量数组的第 0 个单元存放着 this 指针。第一个参数为 long 类型,于是数组的 1、2 两个单元存放着所传入的 long 类型参数的值。第二个参数则是 float 类型,于是数组的第 3 个单元存放着所传入的 float 类型参数的值。
image.png
在方法体里的两个代码块中,我分别定义了两个局部变量 i 和 s。由于这两个局部变量的生命周期没有重合之处,因此,Java 编译器可以将它们编排至同一单元中。也就是说,局部变量数组的第 4 个单元将为 i 或者 s。
存储在局部变量区的值,通常需要加载至操作数栈中,方能进行计算,得到计算结果后再存储至局部变量数组中。这些加载、存储指令是区分类型的。例如,int 类型的加载指令为 iload,存储指令为 istore。

局部变量访问指令表

image.png

  1. public int foo(int i, int j) {
  2. // 0: iload_1 // 加载局部变量表0单元里的数据 // 加载局部变量表1单元里的数据
  3. int sum = i + j; // 3: istore_3 写入到三号索引里
  4. sum += sum + 100;
  5. return sum;
  6. }
  7. public int bar(int i) {
  8. int j = 100000; // 0: ldc #2 // int 100000 采用常量加载指令
  9. int sum = i + j;
  10. return sum;
  11. }

局部变量数组的加载、存储指令都需要指明所加载单元的下标。举例来说,aload 0 指的是加载第 0 个单元所存储的引用,在前面示例中的 foo 方法里指的便是加载 this 指针。

Java 字节码中唯一能够直接作用于局部变量区的指令是 iinc M N(M 为非负整数,N 为整数)。该指令指的是将局部变量数组的第 M 个单元中的 int 值增加 N,常用于 for 循环中自增量的更新。

  1. public void foo() {
  2. for (int i = 100; i>=0; i--) {}
  3. }
  4. // 对应的字节码如下:
  5. public void foo();
  6. 0 bipush 100
  7. 2 istore_1 [i]
  8. 3 goto 9
  9. 6 iinc 1 -1 [i] // i--
  10. 9 iload_1 [i]
  11. 10 ifge 6
  12. 13 return

综合示例

  1. public static int bar(int i) {
  2. return ((i + 1) - 2) * 3 / 4;
  3. }
  4. // 对应的字节码如下:
  5. Code:
  6. stack=2, locals=1, args_size=1
  7. 0: iload_0 // 静态方法,无this,加载1号单元变量 i 到操作数栈里
  8. 1: iconst_1 // 加载整形常量1 到操作数栈里
  9. 2: iadd // 将栈里的两个操作数弹出,并执行假发,最后写入操作数栈
  10. 3: iconst_2
  11. 4: isub
  12. 5: iconst_3
  13. 6: imul
  14. 7: iconst_4
  15. 8: idiv
  16. 9: ireturn

对应的字节码中的 stack=2, locals=1 代表该方法需要的操作数栈空间为 2,局部变量数组空间为 1。当调用 bar(5) 时,每条指令执行前后局部变量数组空间以及操作数栈的分布如下:
c57cb9c2222f0f79459bf4c58e1a4c32.png

数组访问指令表

数组相关指令,包括新建基本类型数组的 newarray,新建引用类型数组的 anewarray,生成多维数组的 multianewarray,以及求数组长度的 arraylength。另外,它还包括数组的加载指令以及存储指令。这些指令是区分类型的。例如,int 数组的加载指令为 iaload,存储指令为 iastore。
image.png

返回指令表

正常执行路径会有return,有返回值的是ireturn,areturn这些。异常执行路径会有athrow.
image.png

  1. public void foo(int i, int j) {
  2. }
  3. Code:
  4. stack=0, locals=3, args_size=3
  5. 0: return
  6. public void bar(int i) {
  7. throw new RuntimeException();
  8. }
  9. Code:
  10. stack=2, locals=2, args_size=2
  11. 0: new #2 // class java/lang/RuntimeException
  12. 3: dup
  13. 4: invokespecial #3 // Method java/lang/RuntimeException."<init>":()V
  14. 7: athrow

其他字节码

Java 相关指令,包括各类具备高层语义的字节码,即 new(后跟目标类,生成该类的未初始化的对象),instanceof(后跟目标类,判断栈顶元素是否为目标类 / 接口的实例。是则压入 1,否则压入 0),checkcast(后跟目标类,判断栈顶元素是否为目标类 / 接口的实例。如果不是便抛出异常),athrow(将栈顶异常抛出),以及 monitorenter(为栈顶对象加锁)和 monitorexit(为栈顶对象解锁)。
此外,该类型的指令还包括字段访问指令,即静态字段访问指令 getstatic、putstatic,和实例字段访问指令 getfield、putfield。这四条指令均附带用以定位目标字段的信息,但所消耗的操作数栈元素皆不同。
image.png
以 putfield 为例,在上图中,它会把值 v 存储至对象 obj 的目标字段之中。
其他方法调用指令为:
invokestatic (调用静态方法),invokespecial(调用构造方法),invokevirtual(调用实例方法),invokeinterface (调用接口方法)。