欢迎光临
我们一直在努力

【DIY系列:Java虚拟机】第49篇:方法调用总览——5 种 invoke 指令的分工

上一篇【第48篇】类和对象测试——HelloWorld 终于"活"了 下一篇【第50篇】方法符号引用解析与参数传递——方法调用的"准备"


摘要

第 6 章结束时,jvmgo 已经能创建对象、读写字段了,但它有个致命缺陷:它没有调用栈。INVOKE_SPECIAL 只是把 this 弹掉假装调用过,INVOKE_VIRTUAL 靠识别描述符来 hack System.out.println。

第 7 章要一次性解决这个问题。这一章的主角是 5 条方法调用指令:

指令用途绑定方式
invokestatic 静态方法 静态绑定
invokespecial 构造器 / 私有方法 / super. 调用 静态绑定
invokevirtual 实例方法 动态绑定
invokeinterface 接口方法 动态绑定
invokedynamic Lambda / 动态语言 本书不实现

本文先把全景图画清楚:方法能怎么分类、5 条指令各自负责什么、JVM 调用一个方法到底要走哪几步。具体的解析算法、指令实现和类初始化,留给后面三篇。

读完这篇你会明白:为什么 super.equals(null) 用 invokespecial 而不是 invokevirtual(用后者会死循环),以及为什么接口方法不能和 invokevirtual 合并(vtable 的前提是继承层次固定)。

一、方法的三组分类维度

在讨论指令之前,先把"方法"这件事分清楚。书里用了三个不同的维度,混着看容易乱,我整理成下面这张表。

1.1 按调用方式分:静态方法 vs 实例方法

public class Demo {
public static void s() {} // 静态方法:通过类调用,Demo.s()
public void i() {} // 实例方法:通过对象调用,obj.i()
}

  • 静态方法(类方法):属于类,不依赖任何对象实例。调用它不需要 this。
  • 实例方法:属于对象。调用它需要一个隐藏的第 0 号参数 this。

这个区别直接影响栈帧布局:静态方法 main 的局部变量表第 0 个 slot 就是 args,而实例方法 test 的第 0 个 slot 是 this,参数从 1 开始。

1.2 按实现方式分:三类

类别说明本章处理
抽象方法 只有签名没有实现(abstract) 解析/查找到它要抛 AbstractMethodError
Java 方法 用 Java(或 Groovy/Scala 等 JVM 语言)实现,有 Code 属性 本章主角
本地方法 用 C/C++ 实现(native),无 Code 属性 第 9 章,本章遇到就 panic

注意一条规则:静态方法和抽象方法互斥。Java 8 之前接口里只能有抽象方法;Java 8 为支持 Lambda 放宽了限制,允许接口有 static 方法和 default 方法。本章不实现接口的静态方法和默认方法,代码是按 JVM 规范第 7 版写的,这点在第 7 章反复被强调——如果你拿 JDK 8 的 rt.jar 跑某些接口-heavy 的代码,可能会遇到意料之外的行为。

1.3 按绑定时机分:静态绑定 vs 动态绑定

这是本章最核心的概念。

静态绑定(static binding / early binding):编译期就能确定最终调用哪个方法。

  • 静态方法:没有多态,Demo.s() 永远调用 Demo.s。
  • 私有方法:子类看不见,不可能被重写。
  • 构造器:<init> 不会被"重写"。
  • final 方法:语法上禁止重写(不过 JVM 层面 HotSpot 对 final 仍走 invokevirtual)。

动态绑定(dynamic binding / late binding):编译期只知道"方法名 + 描述符",运行期才知道对象的真实类型,从而决定调哪个实现——这就是多态。

Animal a = new Dog(); // 编译期类型 Animal,运行期类型 Dog
a.speak(); // 实际调用 Dog.speak()

a.speak() 编译出的字节码是 invokevirtual Animal.speak:()V,但执行时 JVM 看的是栈顶那个对象的真实类(Dog),从 Dog 开始往上找 speak 的实现。

编译期 运行期
┌────────────────────┐ ┌────────────────────┐
│ invokevirtual │ │ 栈顶 ref 指向的对象 │
│ Animal.speak:()V │ ───────▶ │ 真实类 = Dog │
│ (符号引用) │ │ → 查 Dog.speak() │
└────────────────────┘ └────────────────────┘
只知道"叫 speak" 才知道"是狗在叫"

二、JVM 的 5 条方法调用指令

Java 7 之前有 4 条,Java 7 为支持动态类型语言加了第 5 条。

2.1 分工表

┌─ invokestatic 静态方法(static binding)
│
┌─ 静态绑定 ────────┼─ invokespecial 构造器 <init>
│ │ 私有方法
│ │ super.xxx() 超类方法
│ └─ (final 方法仍走 invokevirtual)
方法调用│
│ ┌─ invokevirtual 类类型的实例方法(多态分派)
└─ 动态绑定 ────────┤
├─ invokeinterface 接口类型的实例方法(多态分派)
└─ invokedynamic Lambda / 动态语言(本章不实现)

用一个 Java 类把前四条全演示一遍——这是第 7 章的测试类 InvokeDemo:

package jvmgo.book.ch07;

public class InvokeDemo implements Runnable {
public static void main(String[] args) {
new InvokeDemo().test(); // invokespecial(<init>) + invokevirtual(test)
}
public void test() {
InvokeDemo.staticMethod(); // invokestatic
InvokeDemo demo = new InvokeDemo(); // invokespecial <init>
demo.instanceMethod(); // invokespecial(私有方法!)
super.equals(null); // invokespecial(super 调用)
this.run(); // invokevirtual
((Runnable) demo).run(); // invokeinterface
}
public static void staticMethod() {}
private void instanceMethod() {}
@Override public void run() {}
}

javap -c 一下 test() 就能看到编译器的选择:

0: invokestatic #2 // Method staticMethod:()V
3: new #3 // class InvokeDemo
6: dup
7: invokespecial #4 // Method "<init>":()V
10: astore_1
11: aload_1
12: invokespecial #5 // Method instanceMethod:()V ← 私有方法也用 invokespecial
15: aload_0
16: aconst_null
17: invokespecial #6 // Method java/lang/Object.equals:(Ljava/lang/Object;)Z
20: pop
21: aload_0
22: invokevirtual #7 // Method run:()V ← this.run()
25: aload_1
26: invokeinterface #8, 1 // InterfaceMethod java/lang/Runnable.run:()V
31: return

两个值得注意的点:

  • demo.instanceMethod() 是私有方法,编译成 invokespecial 而不是 invokevirtual。因为私有方法不参与多态,静态绑定更快。
  • 同一个 run() 方法,this.run() 是 invokevirtual,((Runnable) demo).run() 是 invokeinterface——指令的选择取决于引用的静态类型,而不是对象本身。
  • 2.2 为什么需要单独的 invokeinterface?

    这是面试高频题。书里的解释很精炼:

    统一使用 invokevirtual 完全可以,但是可能会影响效率。

    原因在于 vtable(虚函数表) 优化的前提:

    invokevirtual 的情况:
    this 引用指向某个「类」(或其子类)的实例
    → 类的继承层次是「线性固定」的
    → 虚拟机可以预先为每个类算好一张 vtable
    → 每个虚方法在表里有一个「固定下标」
    → 调用时:obj.vtable[固定下标] → O(1)

    invokeinterface 的情况:
    this 引用可以指向「任何实现了该接口的类」的实例
    → Cat 实现 Runnable,Dog 也实现 Runnable,它俩毫无继承关系
    → 无法给 Runnable.run() 分配一个全 JVM 通用的固定下标
    → 只能用 itable(接口方法表),先按接口找到表,再在表里线性/二分查找
    → 调用时:O(1) ~ O(n)

    用图表示:

    Object Runnable (interface)
    │ ▲
    Animal ┌──────┴──────┐
    ╱ ╲ │ │
    Dog Cat Dog Car
    (毫无共同父类)

    invokevirtual: Dog 的 vtable 里 speak 永远在下标 3
    Animal 的 vtable 里 speak 也永远在下标 3
    → 父类和子类下标对齐,查一次就够

    invokeinterface: Runnable.run 在 Dog 的 itable 里可能在下标 0,
    在 Car 的 itable 里可能在下标 5
    → 下标不对齐,必须先"按接口名"做一次搜索

    jvmgo 作为教学实现没有做 vtable/itable 优化,两条指令都是老老实实地调 LookupMethodInClass 做继承链遍历。但指令本身的区分被保留下来了——这正是 JVM 规范的设计意图:给实现者留出优化空间。

    2.3 invokedynamic 为什么不用管

    invokedynamic 是 Java 7 为动态类型语言(JRuby、Groovy)加的,Java 8 的 Lambda 表达式是它在 Java 语言层面的第一个大规模应用。

    它和前四条有个本质区别:前四条的分派逻辑由 JVM 硬编码在规范里,而 invokedynamic 的分派逻辑由用户代码(Bootstrap Method)决定。

    Runnable r = () -> System.out.println("hi");
    // 编译后:
    // 0: invokedynamic #2, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
    // ↑ 指向 BootstrapMethods 属性里的一个方法

    实现它需要:方法句柄(MethodHandle)、调用点(CallSite)、LambdaMetafactory……一整套 java.lang.invoke 基础设施。本书没实现,我们这个系列跟着书的节奏走,也不实现。

    但 invokedynamic 是 JVM 8 里最值得单独写一篇的指令,等 70 篇主线走完,如果有精力可以补一篇番外。

    三、JVM 调用一个方法的完整流程

    不管哪条 invoke 指令,骨架都是一样的。书里给了这段伪代码,堪称第 7 章的"纲":

    func (self *INVOKE_XXX) Execute(frame *rtda.Frame) {
    // ① 拿常量池
    cp := frame.Method().Class().ConstantPool()

    // ② 通过索引取方法符号引用
    methodRef := cp.GetConstant(self.Index).(*heap.MethodRef)

    // ③ 解析符号引用 → 得到一个"直接可引用"的方法
    resolved := resolveMethodRef(methodRef)

    // ④ 检查:static? 访问权限? 抽象?
    checkResolvedMethod(resolved)

    // ⑤ 查找最终要调用的方法(动态绑定的关键!)
    toBeInvoked := findMethodToInvoke(methodRef)

    // ⑥ 创建新栈帧,压入 JVM 栈
    newFrame := frame.Thread().NewFrame(toBeInvoked)
    frame.Thread().PushFrame(newFrame)

    // ⑦ 从调用者操作数栈弹出参数,填入新帧的局部变量表
    passArgs(frame, newFrame)
    }

    七步对应的后文:

    步骤内容在哪一篇
    ①②③ 方法符号引用解析 第 050 篇
    ⑥⑦ 建帧与参数传递 第 050 篇
    ④ 各类 Error 检查 第 051 篇
    ⑤ invokestatic/special/virtual/interface 各自的分派 第 051 篇
    — 方法返回(6 条返回指令) 第 051 篇
    — 栈空检测、<clinit> 触发 第 052 篇

    画成时序图:

    调用者帧 JVM 栈 被调用者帧
    ──────── ──────── ──────────
    操作数栈:
    [arg2]
    [arg1]
    [this ] ← 栈顶
    │
    │ ①②③④⑤ 解析符号引用 + 查找目标方法
    ▼
    找到 Method M
    │
    │ ⑥ newFrame := thread.NewFrame(M)
    │ thread.PushFrame(newFrame)
    ├────────────────────▶ ┌──────────┐
    │ │ 新帧 M │ ◀── 当前帧切换
    │ ├──────────┤
    │ ⑦ passArgs: │ 新帧 main│
    │ 从调用者栈 pop └──────────┘
    │ 从 argSlotCount-1
    │ 倒序到 0
    ├──────────────────────▶ LocalVars:
    │ [0] = this
    │ [1] = arg1
    │ [2] = arg2
    ▼
    loop() 继续,但
    CurrentFrame() 已经是新帧
    → 从 M 的字节码第 0 条开始执行

    关键点:invoke 指令执行完就结束了,它不阻塞等待返回。返回是由被调用方法的最后一条返回指令(return / ireturn / …)完成的——把当前帧弹掉,返回值推进调用者帧的操作数栈顶。loop() 每轮都取 thread.CurrentFrame(),所以"切换"是自动发生的。

    这个设计非常优雅:方法调用和返回被完全解耦成两条独立指令。

    四、操作数:n+1 个从哪来

    书里有句话容易看漏但很重要:

    方法调用指令需要 n+1 个操作数,其中第 1 个操作数是 uint16 索引……剩下的 n 个操作数是要传递给被调用方法的参数,从操作数栈中弹出。

    也就是说:

    字节码里: [opcode][index_hi][index_lo]
    └──── 1 个显式操作数(常量池索引)

    运行时栈上: … , this, arg1, arg2
    └──── n 个隐式操作数(参数,在操作数栈上)

    n = 参数的 slot 数(argSlotCount)。对实例方法,n 还要 +1(算上 this)。

    4.1 argSlotCount 是怎么算出来的

    参数占多少 slot,完全由方法描述符决定:

    func (self *Method) calcArgSlotCount() {
    parsedDescriptor := parseMethodDescriptor(self.descriptor)
    for _, paramType := range parsedDescriptor.parameterTypes {
    self.argSlotCount++
    if paramType == "J" || paramType == "D" {
    self.argSlotCount++ // long 和 double 占 2 个 slot
    }
    }
    if !self.IsStatic() {
    self.argSlotCount++ // 实例方法额外加一个 this
    }
    }

    举例:

    方法描述符静态?参数 slotthisargSlotCount
    ()V static 0 0 0
    (I)V static 1 0 1
    (J)V static 2 0 2
    (IJD)V static 1+2+2=5 0 5
    ()V 实例 0 1 1
    (Ljava/lang/String;)V 实例 1 1 2
    (Ljava/lang/String;J)V 实例 1+2=3 1 4

    为什么 argSlotCount 要缓存在 Method 结构体里? 因为每次方法调用都要用它——既要用来从操作数栈上"定位 this"(GetRefFromTop(argSlotCount-1)),又要用来循环传参。每次都重新解析一遍描述符太浪费,所以在 newMethods() 里算一次存下来。

    五、本章要抛的那些 Error

    方法调用是 JVM 里抛异常种类最多的操作之一。提前列个清单,第 051 篇看到具体代码时就不懵了:

    异常触发条件
    IncompatibleClassChangeError 类/接口方法符号引用解析到了错误种类的东西;invokestatic 遇到非静态方法;invokespecial/invokevirtual 遇到静态方法;invokeinterface 遇到 static 或 private 方法;对象没实现该接口
    NoSuchMethodError 解析不到方法;<init> 的声明类 ≠ 解析出的类
    NullPointerException this 引用为 null
    IllegalAccessError 访问控制不通过;protected 方法被非法的类调用;接口方法非 public
    AbstractMethodError 找到的方法是抽象的(没有实现)

    记忆技巧:

    • IncompatibleClassChangeError → “东西的种类不对”(静态/实例、类/接口)
    • NoSuchMethodError → “找不到”
    • IllegalAccessError → “找到了但没权限”
    • AbstractMethodError → “有权限但没实现”

    是一个从"存在性"到"可用性"的层层递进。

    六、本章的代码结构调整

    第 7 章相比第 6 章,改动的面比第 5 章还大,先列个清单心里有数:

    ch07/
    ├── rtda/
    │ ├── thread.go ← 新增 IsStackEmpty()、TopFrame()
    │ ├── jvm_stack.go ← 新增 isEmpty()
    │ ├── frame.go ← 新增 RevertNextPC()
    │ ├── local_vars.go ← 新增 SetSlot()
    │ └── heap/
    │ ├── class.go ← 新增 initStarted / InitStarted() / StartInit()
    │ │ 新增 GetClinitMethod()
    │ ├── method.go ← 新增 argSlotCount / ArgSlotCount() / calcArgSlotCount()
    │ ├── method_ref.go ← 新增 ResolvedMethod() / resolveMethodRef()
    │ ├── interface_method_ref.go ← 新增 ResolvedInterfaceMethod() 等
    │ ├── method_lookup.go ← 新增 lookupMethod / LookupMethodInClass / lookupMethodInInterfaces
    │ └── operand_stack.go ← 新增 GetRefFromTop()
    ├── instructions/
    │ ├── base/
    │ │ ├── method_invoke_logic.go ← 【新增】InvokeMethod()
    │ │ └── class_init_logic.go ← 【新增】InitClass()
    │ ├── control/return.go ← 【新增】6 条返回指令
    │ └── references/
    │ ├── invokestatic.go ← 【新增】
    │ ├── invokespecial.go ← 第 6 章有,本次重写
    │ ├── invokevirtual.go ← 第 6 章有,本次重写
    │ └── invokeinterface.go ← 【新增】
    ├── interpreter.go ← 大改:支持多帧循环
    ├── cmd.go ← 新增 verboseClassFlag / verboseInstFlag
    └── main.go ← 改 startJVM()

    两个新文件值得单独说:

    • method_invoke_logic.go:放 InvokeMethod()——建帧 + 压栈 + 传参的公共逻辑。四条指令都要用,抽出来避免重复。
    • class_init_logic.go:放 InitClass()——类初始化的公共逻辑。new / getstatic / putstatic / invokestatic 四条指令都要用。

    这是典型的**“提取公共逻辑到 base 包”**模式,和第 5 章把 Branch() 放到 base 包是一脉相承的。

    本篇小结

    第 7 章开篇,我们把方法调用的全景图铺开了:

  • 方法有三种分类维度——调用方式(静态/实例)、实现方式(抽象/Java/本地)、绑定时机(静态绑定/动态绑定)。动态绑定就是多态,是 invokevirtual 和 invokeinterface 存在的理由。
  • 5 条 invoke 指令的分工本质是"按绑定方式 + 引用类型"做二分:invokestatic(静态)、invokespecial(构造器/私有/super)、invokevirtual(类引用)、invokeinterface(接口引用)、invokedynamic(本书跳过)。
  • invokeinterface 不能并入 invokevirtual 不是功能问题而是性能问题——接口的"多实现"破坏了 vtable 的下标对齐前提,必须走 itable。
  • 方法调用七步走:取常量池 → 取符号引用 → 解析 → 检查 → 查找目标方法 → 建帧压栈 → 传参。调用与返回被解耦成两条独立指令,靠 thread.CurrentFrame() 自动切换。
  • argSlotCount 是贯穿全章的关键数字:既用来在操作数栈上定位 this,又用来循环传参,由方法描述符算出并缓存在 Method 上。
  • 下一篇(第 050 篇)进入第一块硬骨头:方法符号引用解析与参数传递。我们会实现 ResolvedMethod()、lookupMethod()、InvokeMethod(),并回答一个有意思的问题:为什么传参要倒序循环。


    上一篇【第48篇】类和对象测试——HelloWorld 终于"活"了 下一篇【第50篇】方法符号引用解析与参数传递——方法调用的"准备"


    赞(0)
    未经允许不得转载:171主机测评 » 【DIY系列:Java虚拟机】第49篇:方法调用总览——5 种 invoke 指令的分工
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址