上一篇【第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
两个值得注意的点:
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
}
}
举例:
| ()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 章开篇,我们把方法调用的全景图铺开了:
下一篇(第 050 篇)进入第一块硬骨头:方法符号引用解析与参数传递。我们会实现 ResolvedMethod()、lookupMethod()、InvokeMethod(),并回答一个有意思的问题:为什么传参要倒序循环。
上一篇【第48篇】类和对象测试——HelloWorld 终于"活"了 下一篇【第50篇】方法符号引用解析与参数传递——方法调用的"准备"






