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

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

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 的幂对齐 ⬇️

java 复制代码
int align2grain(int i, int grain) {
    return ((i + grain - 1) & ~(grain - 1));
}

javac 编出来是 12 条指令 ⬇️

text 复制代码
  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 返回

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

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

同一行 a + b,四种指令

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

java 复制代码
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_stack max_locals
add(int, int) iload_1、iload_2、iadd、ireturn 2 3
add(long, long) lload_1、lload_3、ladd、lreturn 4 5
add(float, float) fload_1、fload_2、fadd、freturn 2 3
add(double, double) dload_1、dload_3、dadd、dreturn 4 5

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

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

text 复制代码
  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 的输出 ⬇️

text 复制代码
  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 呢 ⬇️

java 复制代码
long add(int i, long l) {
    return i + l;
}
text 复制代码
  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 改的不只是类型,还有宽度------这正是下一节要说的。

指令名的前缀

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

前缀 类型 例子
i int iload_1、istore_1、iadd、ireturn
l long lload_1、lstore_1、ladd、lreturn
f float fload_1、fstore_1、fadd、freturn
d double dload_1、dstore_1、dadd、dreturn
a reference aload_1、astore_1、areturn
b byte baload、bastore
c char caload、castore
s short saload、sastore

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

前缀 加法 减法 乘法 除法 取余
i iadd isub imul idiv irem
l ladd lsub lmul ldiv lrem
f fadd fsub fmul fdiv frem
d dadd dsub dmul ddiv drem

光这五种运算就有 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) iadd 2 两个 int,各占 1 个单位
add(long, long) ladd 4 两个 long,各占 2 个单位

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

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

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

iinc 只对 int 局部变量有效

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

看两个 i++ ⬇️

java 复制代码
void inc(int i) { i++; }
void inc(long i) { i++; }
text 复制代码
  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 ⬇️

bash 复制代码
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 为什么走不同的指令。

参考资料

相关推荐
jyOverQ1 小时前
从零学 JVM(五):垃圾回收机制——对象存活判断与 GC Roots
jvm
文丑颜不良啊4 小时前
LeetCode2821~2840题目解析
java·leetcode
apcipot_rain5 小时前
C++转Java日记(10)
java·开发语言
简 洁 冬冬5 小时前
泛型方法写法全面辨析:从「能编译」到「能跑」到「能用」
java·泛型
LGL6030A6 小时前
Java学习历程31——初识Spring Boot
java·spring boot·学习
金銀銅鐵6 小时前
一个 4 个常量的枚举,Kotlin 在字节码里生成了什么
jvm·kotlin
Lansonli8 小时前
大数据Spark(八十八):Spark任务资源之Standalone集群测试
java·服务器·spark
2301_808414388 小时前
Linux线程的同步与互斥
jvm
budingxiaomoli8 小时前
Spring AOP
java·spring·java-ee