本节目标:看到一个方法时,能在纸上画出它的局部变量槽和操作数栈变化。
适用版本:JDK 25(Temurin 25.0.5);class 文件 major version 69。
承接的问题
2.2 解决了类怎么进入 JVM,但类里的方法还没有执行。libraryd 的罚金计算是 FinePolicy.DEFAULT.fineCents(20),一行 Java:
return Math.max(0, overdueDays - graceDays) * centsPerDay;
它编译成 13 条字节码指令。如果不知道 lload_1 读的是哪两个槽、lsub 弹出的是哪两个值、Math.max 调用前后栈里剩什么,就只能背「JVM 是基于栈的」这句话,遇到 VerifyError 或反编译差异时无从下手。
这一节把执行模型拆成三块:栈帧的结构、五种调用指令的分工、分支与异常表。最后用 fineCents(20) 逐指令走一遍,结果应该是 1000 分。
栈帧:局部变量表、操作数栈、动态链接与返回地址
每次方法调用都会创建一个栈帧(frame),压在当前线程的 Java 栈顶。一个栈帧有四部分:
- 局部变量表:按槽(slot)编号,
int/float/引用占 1 个槽,long/double占 2 个槽。实例方法的槽 0 是this。 - 操作数栈:指令的输入输出都走这里。压栈、弹栈的深度由
Code属性的max_stack限制。 - 动态链接:指向当前方法在运行时常量池里的引用,用于把符号引用解析成直接引用。
- 返回地址:方法返回后继续执行的位置;异常时由异常表决定跳转。
javap -c 每个方法头部的 stack、locals、args_size 就是这三个数字:
public long fineCents(long);
descriptor: (J)J
flags: (0x0001) ACC_PUBLIC
Code:
stack=6, locals=3, args_size=2
args_size=2 是 this 加一个 long 参数;long 占两个槽,所以 locals=3 是槽 0(this)、槽 1 和槽 2(overdueDays)。stack=6 是操作数栈的最大深度,编译器算出来的,JVM 校验时会检查每条指令是否越界。
局部变量表的槽号不是变量个数。两个 long 参数会占四个槽;this 在实例方法里永远占槽 0。写字节码或读反编译时,lload_1 读的是槽 1 和槽 2,不是「第一个 long 变量」这么简单。
五种调用指令
先写一个类,让五种调用指令各出现一次:
package com.plume.library.lab;
public final class InvokeKinds {
interface Doubler {
int apply(int x);
}
static int staticAdd(int a, int b) {
return a + b;
}
int instanceMul(int x) {
return x * 3;
}
private int privateAdd(int x) {
return x + 5;
}
String describe(int x) {
return "x=" + x;
}
int viaInterface(Doubler doubler, int x) {
return doubler.apply(x);
}
int callAll(int x) {
return staticAdd(instanceMul(x), privateAdd(viaInterface(v -> v + 10, x)));
}
}
javap -c -p 的关键输出:
static int staticAdd(int, int);
Code:
0: iload_0
1: iload_1
2: iadd
3: ireturn
int instanceMul(int);
Code:
0: iload_1
1: iconst_3
2: imul
3: ireturn
java.lang.String describe(int);
Code:
0: iload_1
1: invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:(I)Ljava/lang/String;
6: areturn
int viaInterface(com.plume.library.lab.InvokeKinds$Doubler, int);
Code:
0: aload_1
1: iload_2
2: invokeinterface #11, 2 // InterfaceMethod com/plume/library/lab/InvokeKinds$Doubler.apply:(I)I
7: ireturn
int callAll(int);
Code:
0: aload_0
1: iload_1
2: invokevirtual #17 // Method instanceMul:(I)I
5: aload_0
6: aload_0
7: invokedynamic #22, 0 // InvokeDynamic #1:apply:()Lcom/plume/library/lab/InvokeKinds$Doubler;
12: iload_1
13: invokevirtual #25 // Method viaInterface:(Lcom/plume/library/lab/InvokeKinds$Doubler;I)I
16: invokevirtual #29 // Method privateAdd:(I)I
19: invokestatic #32 // Method staticAdd:(II)I
22: ireturn
五种指令的分工:
| 指令 | 调用目标 | 分派方式 |
|---|---|---|
invokestatic | 静态方法 | 编译期确定,无接收者 |
invokevirtual | 类的实例方法 | 运行时按接收者动态分派 |
invokeinterface | 接口方法 | 运行时按接收者与接口方法表分派 |
invokespecial | 构造器、私有方法、super 调用 | 编译期确定,不参与虚分派 |
invokedynamic | 引导方法生成的调用点 | 首次调用链接 CallSite |
两个细节和教科书不完全一样:
callAll里privateAdd的调用是invokevirtual,不是invokespecial。本机 JDK 25 的javac对同类私有实例方法调用生成invokevirtual;invokespecial出现在构造器(如Object.<init>、Record.<init>)和super调用上。describe的字符串拼接是invokedynamic+makeConcatWithConstants,不是StringBuilder。callAll里的 lambdav -> v + 10也是invokedynamic,引导方法生成Doubler实例。
invokedynamic 和另外四种的区别在于「第一次调用」:它先执行引导方法,得到一个 CallSite,后续调用直接走链接后的方法句柄。第一次链接的成本落在第一个调用者身上,不在类加载时。
分支、循环与异常表
分支和循环靠条件跳转与 goto。BytecodeLab.sum:
static int sum(int n) {
int total = 0;
for (int i = 0; i < n; i++) {
total += i;
}
return total;
}
本机 javap -c:
static int sum(int);
Code:
0: iconst_0
1: istore_1
2: iconst_0
3: istore_2
4: iload_2
5: iload_0
6: if_icmpge 19
9: iload_1
10: iload_2
11: iadd
12: istore_1
13: iinc 2, 1
16: goto 4
19: iload_1
20: ireturn
iinc 2, 1 直接对局部变量槽 2 加一,不经过操作数栈;if_icmpge 19 弹出两个 int,如果前者大于等于后者就跳到偏移 19;goto 4 回到循环头。注意 iinc 只适用于 int 局部变量,long 的 i++ 会编译成 lload、lconst_1、ladd、lstore。
类型检查有两个指令,语义不同:
static java.lang.String stateName(java.lang.Object);
Code:
0: aload_0
1: instanceof #7 // class com/plume/library/domain/LoanState$Overdue
4: ifeq 22
7: aload_0
8: checkcast #7 // class com/plume/library/domain/LoanState$Overdue
11: astore_1
instanceof 弹出一个引用,压入 0 或 1,不抛异常;checkcast 检查失败会抛 ClassCastException。模式匹配 candidate instanceof LoanState.Overdue overdue 编译成这两条:先判断,再转换,转换结果存进局部变量槽 1。
异常表把「受保护区间」映射到处理器:
static int parseOrMinusOne(java.lang.String);
Code:
0: aload_0
1: invokestatic #27 // Method java/lang/Integer.parseInt:(Ljava/lang/String;)I
4: ireturn
5: astore_1
6: iconst_m1
7: ireturn
Exception table:
from to target type
0 4 5 Class java/lang/NumberFormatException
from 0、to 4 是左闭右开区间,覆盖偏移 0 到 3 的指令;target 5 是处理器入口,异常对象被压栈后 astore_1 存进局部变量。to 不包含 4,所以 ireturn 本身不在保护范围内。
主动抛异常用 athrow:
static void requirePositive(int);
Code:
0: iload_0
1: ifgt 18
4: new #19 // class java/lang/IllegalArgumentException
7: dup
8: iload_0
9: invokedynamic #21, 0 // InvokeDynamic #1:makeConcatWithConstants:(I)Ljava/lang/String;
14: invokespecial #24 // Method java/lang/IllegalArgumentException."<init>":(Ljava/lang/String;)V
17: athrow
18: return
new 分配对象,dup 留一份引用给构造器,invokespecial 调 <init>,athrow 弹出 Throwable 并开始异常分派。构造器和 athrow 之间不能有别的栈操作把引用挤掉。
逐指令走一遍 fineCents(20)
FinePolicy.DEFAULT 是 new FinePolicy(50, 0),fineCents(20) 的字节码:
public long fineCents(long);
Code:
stack=6, locals=3, args_size=2
0: lconst_0
1: lload_1
2: aload_0
3: getfield #13 // Field graceDays:I
6: i2l
7: lsub
8: invokestatic #16 // Method java/lang/Math.max:(JJ)J
11: aload_0
12: getfield #7 // Field centsPerDay:I
15: i2l
16: lmul
17: lreturn
局部变量:槽 0 是 this,槽 1 和槽 2 是 overdueDays=20L。逐条走:
| 偏移 | 指令 | 操作数栈(底 → 顶) | 说明 |
|---|---|---|---|
| 0 | lconst_0 | [0L] | 压入常量 0 |
| 1 | lload_1 | [0L, 20L] | 读槽 1、2 |
| 2 | aload_0 | [0L, 20L, this] | 读槽 0 |
| 3 | getfield graceDays | [0L, 20L, 0] | 弹出 this,压入 int 0 |
| 6 | i2l | [0L, 20L, 0L] | int 扩成 long |
| 7 | lsub | [0L, 20L] | 弹出 20 和 0,压入 20-0=20 |
| 8 | invokestatic Math.max | [20L] | 弹出两个 long,压回较大值 |
| 11 | aload_0 | [20L, this] | 读槽 0 |
| 12 | getfield centsPerDay | [20L, 50] | 压入 int 50 |
| 15 | i2l | [20L, 50L] | int 扩成 long |
| 16 | lmul | [1000L] | 弹出两个 long,压入乘积 |
| 17 | lreturn | [] | 返回栈顶 long |
lsub 的顺序容易记反:栈顶是第二个操作数,栈顶下面是第一个操作数,结果是「第一个减第二个」。这里栈是 [0L, 20L],所以算的是 20 - 0,不是 0 - 20。Math.max(0, 20) 返回 20,20 * 50 得到 1000。
本机运行确认:
java -cp out FineRun
fineCents(20)=1000
Math.max 是 invokestatic,但 JIT 通常会把它内联成比较指令;字节码里看到的调用不等于运行时一定发生调用。这也是为什么「数调用次数」不能只看 javap。
在 libraryd 里落地
Loan.state 是 libraryd 借阅路径上调用最频繁的方法之一。它的字节码把三条分支摊开:
public com.plume.library.domain.LoanState state(java.time.LocalDate);
Code:
0: aload_0
1: invokevirtual #34 // Method active:()Z
4: ifne 43
43: aload_0
44: aload_1
45: invokevirtual #67 // Method overdueDays:(Ljava/time/LocalDate;)J
48: lstore_2
49: lload_2
50: lconst_0
51: lcmp
52: ifle 68
55: new #71 // class com/plume/library/domain/LoanState$Overdue
58: dup
59: aload_0
60: getfield #24 // Field dueOn:Ljava/time/LocalDate;
63: lload_2
64: invokespecial #73 // Method com/plume/library/domain/LoanState$Overdue."<init>":(Ljava/time/LocalDate;J)V
67: areturn
68: new #74 // class com/plume/library/domain/LoanState$Active
71: dup
72: aload_0
73: getfield #24 // Field dueOn:Ljava/time/LocalDate;
76: invokespecial #76 // Method com/plume/library/domain/LoanState$Active."<init>":(Ljava/time/LocalDate;)V
79: areturn
上面的节选跳过了偏移 7 到 42 的 Returned 分支:它先算 ChronoUnit.between(dueOn, returnedOn)、Math.max、乘 50,再用 new/dup/invokespecial 构造 LoanState.Returned。保留下来的 Overdue 分支先调 overdueDays,用 lcmp 与 0 比较,ifle 跳过逾期分支;Active 分支直接构造。三个分支各有一条 new + dup + invokespecial,也就是说每次调用 state 都会分配一个 LoanState 对象。
这段字节码解释了 1.3 的分配采样为什么 98.34% 落在 LoanState.Overdue:LabApp 的大多数 loan 是逾期的,每轮 2000 次调用就产生 2000 个 Overdue。第 3 章会算对象大小,第 4 章会讨论 JIT 能否把这些分配消掉,第 5 章会讲这些短命对象如何进入年轻代。
从执行模型的角度,Loan.state 还有两个可观察点:
invokevirtual active和invokevirtual overdueDays是虚调用,但Loan是final record,JIT 可以做去虚拟化,把调用变成直接调用甚至内联。new之后紧跟dup和invokespecial,这是标准的对象构造序列。如果构造器抛异常,new出来的对象不会被发布,栈上的引用由异常分派清理。
常见错误与边界
错误一:把局部变量表当成「变量列表」。 locals=3 是槽数,不是变量个数。long 占两个槽,this 占一个槽。lload_1 读的是槽 1 和槽 2,如果槽 2 被别的变量复用,结果会出乎意料。
错误二:把操作数栈顺序记反。 lsub、ldiv、lrem 都是「次顶值 操作 栈顶值」。栈 [0L, 20L] 执行 lsub 得到 20L,不是 -20L。画栈的时候把栈顶写在右边,能避免这类错误。
错误三:以为 iinc 适用于所有自增。 iinc 只操作 int 局部变量;long 自增会展开成 lload、lconst_1、ladd、lstore。看到 iinc 就默认是 int,不要套到 long 上。
错误四:把 instanceof 和 checkcast 混为一谈。 instanceof 失败压 0,不抛异常;checkcast 失败抛 ClassCastException。模式匹配先 instanceof 再 checkcast,两步都可能出现在字节码里。
边界一:异常表的 to 是开区间。 from 0, to 4 覆盖 0、1、2、3,不覆盖 4。手写字节码时把 to 写成最后一条指令的偏移,会少保护一条指令。
边界二:字节码不是最终执行形态。 JIT 会内联、去虚拟化、消除分配,javap 看到的是解释器入口的形态。用字节码推断性能时,只能推断「解释阶段」的成本;优化后的行为要看编译日志和基准测试。
小结
- 栈帧由局部变量表、操作数栈、动态链接和返回地址组成;
stack/locals/args_size是校验约束。 long/double占两个局部变量槽,lload_1读槽 1 和 2。- 五种调用指令:
invokestatic、invokevirtual、invokeinterface、invokespecial、invokedynamic;JDK 25 的同类私有实例方法调用编译成invokevirtual。 iinc只用于int;long比较用lcmp,分支用ifle/ifgt等。instanceof压 0/1,checkcast失败抛异常;异常表from/to左闭右开。FinePolicy.DEFAULT.fineCents(20)逐指令走完是 20 × 50 = 1000 分;Loan.state的每个分支都分配一个LoanState。
知道一个方法怎么执行之后,下一个问题是它占用多少内存。第 3 章从运行时数据区开始,再进入对象布局与 TLAB。
阅读导航:上一节:2.2 类加载过程:加载、链接、初始化与双亲委派 · 下一节:3.1 运行时数据区:堆、栈、元空间与代码缓存 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。