《Java 虚拟机原理与性能调优》2.3 字节码执行模型:栈帧、局部变量表与操作数栈

方法调用在 JVM 里就是一个栈帧。本节用 javap -c 的真实输出拆开局部变量表、操作数栈、动态链接与异常表,让五种调用指令各出现一次,并逐指令走完 FinePolicy.DEFAULT.fineCents(20) 的 1000 分计算过程。

本节目标:看到一个方法时,能在纸上画出它的局部变量槽和操作数栈变化。
适用版本: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 里的 lambda v -> 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。逐条走:

偏移指令操作数栈(底 → 顶)说明
0lconst_0[0L]压入常量 0
1lload_1[0L, 20L]读槽 1、2
2aload_0[0L, 20L, this]读槽 0
3getfield graceDays[0L, 20L, 0]弹出 this,压入 int 0
6i2l[0L, 20L, 0L]int 扩成 long
7lsub[0L, 20L]弹出 20 和 0,压入 20-0=20
8invokestatic Math.max[20L]弹出两个 long,压回较大值
11aload_0[20L, this]读槽 0
12getfield centsPerDay[20L, 50]压入 int 50
15i2l[20L, 50L]int 扩成 long
16lmul[1000L]弹出两个 long,压入乘积
17lreturn[]返回栈顶 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 运行时数据区:堆、栈、元空间与代码缓存 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发