带您看懂字节码指令(二):空循环里有什么

9 阅读10分钟

javap -v 的输出里,spin 方法的 Code 长这样 ⬇️

  void spin();
    descriptor: ()V
    flags: (0x0000)
    Code:
      stack=2, locals=2, args_size=1
         0: iconst_0
         1: istore_1
         2: iload_1
         3: bipush        10
         5: if_icmpge     14
         8: iinc          1, 1
        11: goto          2
        14: return

stack 和 locals 还是 2.6 节「帧」 里的两个大小。上一篇用 add 的四条顺序指令看了它们;这一篇换成规范 3.2 节 的 spin,看一个空循环如何变成跳转。

例子:Spin.java 里的 spin

本文的示例代码 ⬇️

public class Spin {
    public static void main(String[] args) {
        new Spin().spin();
    }

    void spin() {
        int i;
        for (i = 0; i < 10; i++) {
            ; // Loop body is empty
        }
    }
}

规范 3.2 节 的例子使用循环条件 i < 100。为了手动点 Step 时不用点上几百次,这里改成了 10,编译出来的指令结构完全一样。

javac -g Spin.java 后,javap -v Spin 里 spin 方法的完整输出(JDK 21)⬇️

  void spin();
    descriptor: ()V
    flags: (0x0000)
    Code:
      stack=2, locals=2, args_size=1
         0: iconst_0
         1: istore_1
         2: iload_1
         3: bipush        10
         5: if_icmpge     14
         8: iinc          1, 1
        11: goto          2
        14: return
      LineNumberTable:
        line 8: 0
        line 11: 14
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      15     0  this   LSpin;
            2      13     1     i   I

复现这段输出:

javac -g Spin.java   # -g 用于生成 LocalVariableTable
javap -v Spin.class

locals=2:0 号槽是 this,1 号槽是 i。stack=2:整个方法里操作数栈最多同时放两个值。LineNumberTable 里的两条记录对应源码第 8 行(循环)和第 11 行(方法结尾的 })。

它的帧结构 ⬇️

spin-frame.png

左边是 locals=2 对应的两个槽,槽号标在格子下方;右边是 stack=2 对应的两个深度单位。方法刚被调用,i 还没有赋值(显示为 —),操作数栈为空。

以下截图来自配套的 SpinDemo.java,可单步执行这 8 条指令,本例共 56 步。蓝色表示本步写入或压入的值,琥珀色边框表示本步读取的槽。

指令的记法:偏移也是跳转目标

3.1 节 规定本章示例的格式 ⬇️

Each instruction takes the form:

<index> <opcode> [ <operand1> [ <operand2>... ]] [<comment>]

index 是指令在方法字节码数组里的位置,也就是从方法开头算起的字节偏移。它可以作为跳转目标,例如 goto 2 跳到索引 2 的 iload_1。控制转移指令真正的操作数是相对偏移,javap 会换算成目标索引显示,所以清单里的数字可以直接对照。

偏移不是连续的:iload_1、istore_1 这些是 1 个字节,bipush 带一字节立即数共 2 个字节,if_icmpge、iinc、goto 各 3 个字节,return 1 个字节,正好铺满 0~14。

和规范里的清单不完全一样

3.2 节说“编译器可能把它编译成”下面这样 ⬇️

0 iconst_0      // Push int constant 0
1 istore_1      // Store into local variable 1 (i=0)
2 goto 8        // First time through don't increment
5 iinc 1 1      // Increment local variable 1 by 1 (i++)
8 iload_1       // Push local variable 1 (i)
9 bipush 100    // Push int constant 100
11 if_icmplt 5  // Compare and loop if less than (i < 100)
14 return       // Return void when done

javac 21 选的是另一种等价写法:先判断、后自增。两者用到的指令种类几乎相同,差别在条件跳转的方向和偏移 ⬇️

规范示意javac 21
首次进入循环goto 8,跳过自增直接 iload_1 判断
条件跳转if_icmplt 5,小于则回跳if_icmpge 14,大于等于则退出
自增位置条件判断之前条件判断之后
回跳无,只有首次进入时的 goto 8goto 2,每轮结束回到判断

执行的操作序列一致:初始化一次;每次判断 i 是否小于上界,成立就自增并回到判断,不成立就 return。后面的截图和说明都用 javac 的实际输出。

栈式机器:每一步的栈深

3.2 节对执行模型的描述是 ⬇️

The Java Virtual Machine is stack-oriented, with most operations taking one or more operands from the operand stack of the Java Virtual Machine's current frame or pushing results back onto the operand stack.

除了少数直接操作局部变量的指令,大部分指令都要经过操作数栈。把 spin 的每条指令执行后的状态列出来 ⬇️

指令栈(自底向上)深度
0: iconst_0[0]1
1: istore_1[]0
2: iload_1[i]1
3: bipush 10[i, 10]2
5: if_icmpge 14[]0
8: iinc 1 1[]0
11: goto 2[]0
14: return[]0

栈深峰值是 2,出现在 bipush 之后:i 和上界同时在栈上,正好等于 stack=2 ⬇️

spin-bipush.png

上一轮的 i 先由 iload_1 压栈,bipush 10 再把上界压上去,深度到 2。if_icmpge 接着把这两个值都弹出来比较,深度回到 0 ⬇️

spin-if.png

这一步不成立(0 >= 10 为假),所以不跳转,继续执行 8 号指令。被读取的 i 槽描成琥珀色,栈已经空了。

常量 0 和 10:为什么不用同一条指令

3.2 节指出,spin 里的两个常量用了两条不同的入栈指令 ⬇️

The 0 is pushed using an iconst_0 instruction, one of the family of iconst_<i> instructions. The 100 is pushed using a bipush instruction, which fetches the value it pushes as an immediate operand.

iconst_<i> 系列覆盖 int 常量 -1、0、1、2、3、4、5,值直接隐含在操作码里,不需要额外的操作数字节。bipush 则带一个字节的立即数。

所以 0 用 iconst_0,一条指令、一个字节;10 不在 -1~5 里,用 bipush 10,两个字节。把 0 写成 bipush 0 也能工作,只是整段代码多占一个字节。

i 为什么在 1 号槽

3.2 节的解释是 ⬇️

The int i in spin is stored as Java Virtual Machine local variable 1. Because most Java Virtual Machine instructions operate on values popped from the operand stack rather than directly on local variables, instructions that transfer values between local variables and the operand stack are common...

实例方法的 0 号槽要放 this,所以 i 从 1 号开始。istore_1、iload_1 里的 _1 就是槽号,不用再带操作数。LocalVariableTable 里 i 的 Start=2:它的作用域从第一次赋值之后开始,到方法结束。

iinc:不经过操作数栈的自增

3.2 节对 iinc 的说明是 ⬇️

The iinc instruction increments the contents of a local variable by a one-byte signed value. The iinc instruction in spin increments the first local variable (its first operand) by 1 (its second operand).

iinc 1 1 的两个操作数分别是槽号和增量:把 1 号槽里的 i 加 1,直接写回局部变量表,操作数栈完全不动 ⬇️

spin-iinc.png

这是它和 iadd 的关键区别:iadd 要从栈上取两个值、把结果压回;iinc 不需要压栈出栈,所以这一步的 depth 仍是 0。增量是一个字节的有符号数,范围 -128~127。

循环靠四条指令

3.2 节把 spin 的循环归结为四条指令 ⬇️

5 iinc 1 1      // Increment local variable 1 by 1 (i++)
8 iload_1       // Push local variable 1 (i)
9 bipush 100    // Push int constant 100
11 if_icmplt 5  // Compare and loop if less than (i < 100)

换成 javac 21 的偏移,一轮循环是 ⬇️

  1. 2: iload_1:把 i 压栈
  2. 3: bipush 10:把上界压栈
  3. 5: if_icmpge 14:弹出两个值比较,i >= 10 就跳到 14
  4. 8: iinc 1 1:没有退出时,i 加 1
  5. 11: goto 2:回到判断,开始下一轮

goto 2 把控制权交回 iload_1,栈此时是空的,所以下一轮从干净的状态开始 ⬇️

spin-goto.png

边界可以手算一遍:i = 9 时 9 >= 10 不成立,自增到 10,回到判断;10 >= 10 成立,跳到 14 返回。所以循环体执行 10 次,条件判断执行 11 次。

延伸:换成 double 和 short 会怎样

3.2 节在 spin 之后还给了两个变体,只改了循环变量的类型。先看 double 版 ⬇️

void dspin() {
    double i;
    for (i = 0.0; i < 100.0; i++) {
        ; // Loop body is empty
    }
}

规范给出的指令是 ⬇️

0 dconst_0      // Push double constant 0.0
1 dstore_1      // Store into local variables 1 and 2
2 goto 9        // First time through don't increment
5 dload_1       // Push local variables 1 and 2
6 dconst_1      // Push double constant 1.0
7 dadd          // Add; there is no dinc instruction
8 dstore_1      // Store result in local variables 1 and 2
9 dload_1       // Push local variables 1 and 2
10 ldc2_w #4    // Push double constant 100.0
13 dcmpg        // There is no if_dcmplt instruction
14 iflt 5       // Compare and loop if less than (i < 100.0)
17 return       // Return void when done

三处值得注意:

  1. i 占槽 1 和 2 两格,dstore_1 一次写两格,但只能用较小的索引 1 访问
  2. 没有 dinc 指令,i++ 拆成 dconst_1、dadd、dstore_1 三条
  3. 没有 if_dcmplt,比较要先用 dcmpg 压入 -1、0、1,再用 iflt 判断

再看 short 版 ⬇️

void sspin() {
    short i;
    for (i = 0; i < 100; i++) {
        ; // Loop body is empty
    }
}

它没有 short 专用的加载、存储、加法指令,只能提升成 int 来算,再截断回 short ⬇️

0 iconst_0
1 istore_1
2 goto 10
5 iload_1       // The short is treated as though an int
6 iconst_1
7 iadd
8 i2s           // Truncate int to short
9 istore_1
10 iload_1
11 bipush 100
13 if_icmplt 5
16 return

也不能像 int 那样直接用 iinc——它只做加法、不做 i2s 截断。控制流的骨架没变,变的只是搬运和运算的指令。

本文的 GUI 只演示 spin 的 int 版本;dspin 和 sspin 的逐帧状态留到后面讲类型支持时再展开。

return 之后

return 是最后一条指令,执行完帧就弹出,局部变量表和操作数栈都不存在了 ⬇️

spin-return.png

此时 i 已经是 10,return 把 void 交回调用者,绿色提示里写着 return: spin() finished with i = 10。整个执行一共 56 步:初始化 2 步,10 轮循环 50 步,最后判断加返回 4 步。

下载 SpinDemo.java 后运行(需 JDK 16 及以上,本文用 JDK 21)⬇️

javac SpinDemo.java
java SpinDemo

界面上可以调速度、单步或自动播放,观察 i 和栈深的变化。

数字对照

数字含义在 spin 里
locals=2局部变量表的槽数this 1 + i 1
stack=2操作数栈的最大深度iload_1、bipush 10 之后的 i 和上界
args_size=1参数个数(不是槽数)this 也算一个参数,spin 没有显式参数

args_size=1 与 locals=2 含义不同:spin 没有声明参数,但实例方法把 this 算一个参数;i 是方法内的局部变量,只占槽、不算参数。若把 spin 改成静态方法,this 不再占 0 号槽,locals 会变成 1,i 也会换到 0 号槽。

和 add 对比

add(int, int)spin()
控制流无,四条指令顺序执行if_icmpge 退出、goto 回跳
局部变量表this、a、bthis、i
locals / stack3 / 22 / 2
不经过操作数栈的指令无iinc
栈深峰值22

两篇合起来,帧的两个大小都对应上了:locals 是槽数,stack 是指令执行过程中操作数栈的最大深度。

小结

  1. 3.1 节的 index 是字节偏移,也是跳转目标;指令长度不同,偏移因此不连续。
  2. spin 由初始化、条件判断、自增、回跳组成。规范用 goto 8 + if_icmplt 5,javac 21 用 if_icmpge 14 + goto 2,两者等价。
  3. 常量入栈:-1~5 用 iconst_<i>,值隐含在操作码里;10 用 bipush,带一个字节的立即数。
  4. i 在 1 号槽,因为 0 号槽是 this。
  5. iinc 直接修改局部变量,不经过操作数栈。
  6. spin 的 locals=2、stack=2,栈深峰值出现在 bipush 之后。

下一篇看 3.3 节 的算术指令:为什么 int 和 long 的加法要用不同的字节码,以及它们在操作数栈上占的深度。

参考资料