带您看懂字节码指令(三):同一行加法,为什么有四种指令

0 阅读8分钟

带您看懂字节码指令(三):同一行加法,为什么有四种指令

a + b 在源码里只有一行,编译出来的指令却取决于类型:int 是 iadd,long 是 ladd,float 是 fadd,double 是 dadd。

上一篇的 spin 已经出现过 iadd 和 iinc;这一篇从规范 3.3 节的算术例子出发,把"指令名里的类型前缀"这件事讲清楚。

3.3 的例子:算术在操作数栈上做

3.3 节开头说 ⬇️

The Java Virtual Machine generally does arithmetic on its operand stack. (The exception is the iinc instruction, which directly increments the value of a local variable.)

例子是把一个整数按 2 的幂对齐 ⬇️

int align2grain(int i, int grain) {
    return ((i + grain - 1) & ~(grain - 1));
}

javac 编出来是 12 条指令 ⬇️

  static int align2grain(int, int);
    Code:
       0: iload_0
       1: iload_1
       2: iadd
       3: iconst_1
       4: isub
       5: iload_1
       6: iconst_1
       7: isub
       8: iconst_m1
       9: ixor
      10: iand
      11: ireturn

一步一步看栈 ⬇️

  • iload_0、iload_1 压入 i 和 grain,iadd 弹出两个,压回 i + grain
  • iconst_1、isub 得到 i + grain - 1
  • 再压一次 grain,iconst_1、isub 得到 grain - 1
  • iconst_m1 压入 -1,ixor 算出 ~(grain - 1)(因为 ~x == -1 ^ x)
  • iand 把两半按位与,ireturn 返回

每个子计算的结果都留在栈上,供外层使用。这就是"栈式机器":没有寄存器编号,只有压栈和弹栈。

iadd 之后:i 和 grain 已经合成一个值,深度回到 1(配套 demo,i = 5、grain = 4)

第二次 iconst_1 之后:栈上同时有三个值,深度到 3,这正是 max_stack 的来源

第二张是关键:第一个子表达式的结果 8 停在栈底,第二个子表达式在它上面继续搭,深度因此到 3。iand 收拢之后只剩一个 8,ireturn 把它交给调用方。注意栈上的 1 不是从局部变量来的——常量直接由指令压栈,后面的 -1 同理。

同一行 a + b,四种指令

换一个更小的例子,四种类型各写一个 add ⬇️

public class Arith {
    public int add(int a, int b) { return a + b; }
    public long add(long a, long b) { return a + b; }
    public float add(float a, float b) { return a + b; }
    public double add(double a, double b) { return a + b; }
}

四种类型的产物对比 ⬇️

方法指令max_stackmax_locals
add(int, int)iload_1、iload_2、iadd、ireturn23
add(long, long)lload_1、lload_3、ladd、lreturn45
add(float, float)fload_1、fload_2、fadd、freturn23
add(double, double)dload_1、dload_3、dadd、dreturn45

形状完全一样,只是前缀不同:i、l、f、d。

add(long, long) 的完整输出 ⬇️

  public long add(long, long);
    descriptor: (JJ)J
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=4, locals=5, args_size=3
         0: lload_1
         1: lload_3
         2: ladd
         3: lreturn

float 和 double 的产物只有前缀不同,javap -c 的输出 ⬇️

  public float add(float, float);
       0: fload_1
       1: fload_2
       2: fadd
       3: freturn

  public double add(double, double);
       0: dload_1
       1: dload_3
       2: dadd
       3: dreturn

注意 dload_3 和上面的 lload_3 一样跳过了 slot 2:double 也占两个 slot,第二个参数从 3 号开始。

规范 2.2 节解释了为什么不能只有一条加法指令 ⬇️

The instruction set of the Java Virtual Machine distinguishes its operand types using instructions intended to operate on values of specific types. For instance, iadd, ladd, fadd, and dadd are all Java Virtual Machine instructions that add two numeric values and produce numeric results, but each is specialized for its operand type: int, long, float, and double, respectively.

JVM 里的值不带类型标签。运行时不会检查这个值是 int 还是 float;类型信息在编译期就编码进了操作码。iadd 就是"两个 int 相加",ladd 就是"两个 long 相加",不存在"通用加法"。

严格说,类型信息并没有消失,只是提前用掉了:类加载时验证器(4.10 节)按字节码静态推断每个位置的类型,推断失败就拒绝加载。运行期的操作数栈上确实没有标签,正确性由指令本身保证。

类型不同时,先转换

iadd 只接受两个 int,ladd 只接受两个 long。如果一个是 int、一个是 long 呢 ⬇️

long add(int i, long l) {
    return i + l;
}
  long add(int, long);
    Code:
       0: iload_1
       1: i2l
       2: lload_2
       3: ladd
       4: lreturn

i2l 把 int 转成 long,ladd 才有两个 long 可以相加。转换指令也带前缀,格式是"源类型 + 2 + 目标类型":i2l、l2i、i2f、d2i 都是这个规则。编译器必须显式插入转换,这是类型化指令集的直接代价。

顺带提一句栈深:iload_1 压入 1 个单位,i2l 执行后那一个单位变成 2 个,所以 lload_2 读完 l 之后深度是 4。i2l 改的不只是类型,还有宽度——这正是下一节要说的。

指令名的前缀

看多了会发现,指令名的第一个字母就是类型 ⬇️

前缀类型例子
iintiload_1、istore_1、iadd、ireturn
llonglload_1、lstore_1、ladd、lreturn
ffloatfload_1、fstore_1、fadd、freturn
ddoubledload_1、dstore_1、dadd、dreturn
areferenceaload_1、astore_1、areturn
bbytebaload、bastore
ccharcaload、castore
sshortsaload、sastore

第一个字母说完了,剩下的部分说的是运算。同一套前缀配上五种算术,就是一张完整的矩阵 ⬇️

前缀加法减法乘法除法取余
iiaddisubimulidivirem
lladdlsublmulldivlrem
ffaddfsubfmulfdivfrem
ddadddsubdmulddivdrem

光这五种运算就有 20 条指令。它们的关系比名字看上去更近:iadd 和 isub 的栈行为完全一样——都弹出两个、压回一个,只是中间做的运算不同。所以两个维度各管一件事:类型决定每个值占几个单位、深度怎么算,运算只决定弹出之后算什么。

算术也不止这五种:取负是 ineg、lneg,移位是 ishl、lshl,位运算是 iand、ior、ixor,命名规则一模一样。完整的清单在 6.5 节,这里只取最常见的五种做对照。

但不是每种类型都有全套指令。2.11.1 节的类型支持表里,int 一列最满;byte、char、short 没有标量 load、store、add,它们先提升成 int 再算,结果用 i2b、i2c、i2s 截回去。这也是为什么没有 badd、cadd、sadd。

还有一处容易看错:bipush、sipush 里的 b / s 指的是"一个字节宽度的立即数",不是 byte、short 类型——它们压进栈的仍然是 int。

栈深:long 和 double 占两个单位

前缀换了,栈深也跟着换。2.6.2 节定义过 ⬇️

At any point in time, an operand stack has an associated depth, where a value of type long or double contributes two units to the depth and a value of any other type contributes one unit.

所以同样是 a + b:

方法指令max_stack最深时栈上是什么
add(int, int)iadd2两个 int,各占 1 个单位
add(long, long)ladd4两个 long,各占 2 个单位

iadd 弹出 2 个单位、压回 1 个,净效果是深度减 1;ladd 弹出 4 个、压回 2 个,净效果是减 2。

配套 demo 把同一段代码的两种类型放在一起 ⬇️

add(int, int) 执行到 iload_2 之后:两个 int 在栈上,深度为 2

add(long, long) 执行到 lload_3 之后:两个 long 在栈上,深度为 4

两张图执行的是同一行 a + b,只是类型不同:左边局部变量表里,long 的参数占两个 slot;右边操作数栈里,long 的值也占两个单位。

iinc 只对 int 局部变量有效

3.3 节把 iinc 单独拿出来,就是因为它是算术指令里唯一不经过操作数栈的。

看两个 i++ ⬇️

void inc(int i) { i++; }
void inc(long i) { i++; }
  void inc(int);
    Code:
       0: iinc          1, 1
       3: return

  void inc(long);
    Code:
       0: lload_1
       1: lconst_1
       2: ladd
       3: lstore_1
       4: return

int 版一条 iinc 就够:slot 1 加 1,不碰栈。long 版没有 linc,只能把值压栈、加 1、再存回去。

配套 demo

ArithmeticDemo.java 是一个单文件 Swing 程序,用下拉框在 add(int, int)、add(long, long) 和 align2grain(int, int) 之间切换,单步观察 slot 和操作数栈的变化。前两项对照类型前缀,第三项就是 3.3 节的例子。需要 JDK 16 及以上(用到了 record),本文用 JDK 21 ⬇️

javac ArithmeticDemo.java
java ArithmeticDemo

切换方法后,指令、栈深和源码高亮都会跟着换。前面 3.3 节和「栈深」一节的四张图,都是这个程序截的。

前两项只取 int 和 long,因为宽度只有两种:float 与 int 同宽(1 个单位),double 与 long 同宽(2 个单位)。前缀换了,栈上的形状不变,再补两个方法也只是重复同一种画法。

小结

  1. 算术在操作数栈上做:弹出操作数,压回结果;iinc 是唯一直接改局部变量的例外。
  2. 指令名是「类型 + 运算」:前缀 i、l、f、d 对应 int、long、float、double,后半截才是运算,所以有 isub、lmul、drem。
  3. 没有通用加法:iadd、ladd、fadd、dadd 是四条指令,类型在编译期定死。
  4. 栈深由类型决定:int 占 1 个单位,long/double 占 2 个,所以 add(long, long) 的 max_stack 是 4。
  5. iinc 只对 int 局部变量有效:long 的 i++ 要拆成 ladd + lstore。

下一篇看 3.4 节的运行时常量池访问:ldc、ldc_w、ldc2_w 分别取什么,以及 100 和 100000 为什么走不同的指令。

参考资料