带您看懂字节码指令(三):同一行加法,为什么有四种指令
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 + grainiconst_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 个单位)。前缀换了,栈上的形状不变,再补两个方法也只是重复同一种画法。
小结
- 算术在操作数栈上做 :弹出操作数,压回结果;
iinc是唯一直接改局部变量的例外。 - 指令名是「类型 + 运算」 :前缀
i、l、f、d对应int、long、float、double,后半截才是运算,所以有isub、lmul、drem。 - 没有通用加法 :
iadd、ladd、fadd、dadd是四条指令,类型在编译期定死。 - 栈深由类型决定 :
int占 1 个单位,long/double占 2 个,所以add(long, long)的max_stack是 4。 iinc只对int局部变量有效 :long的i++要拆成ladd+lstore。
下一篇看 3.4 节的运行时常量池访问:ldc、ldc_w、ldc2_w 分别取什么,以及 100 和 100000 为什么走不同的指令。