上一篇【第31篇】JVM 字节码指令总览——200 多条指令的"全家福" 下一篇【第33篇】常量指令——把数字推到栈上的艺术
摘要
上一篇我们俯瞰了 205 条指令的全貌。这一篇开始动手,先把指令系统的骨架搭起来。
骨架只有两部分:
此外,为了消除 150 多条指令中的重复代码,我们会按"操作数的形状"定义 4 个"抽象指令"结构体,再利用 Go 语言的**结构体嵌入(embedding)**机制模拟 Java 的继承——这是整个指令系统最巧妙的一处设计。
读完这一篇,你会理解为什么后面每一条指令的 Go 代码都只需要 2~4 行。
一、解释器的基本逻辑
在动手之前,先看看 JVM 规范 2.11 节给出的解释器伪代码:
do {
atomically calculate pc and fetch opcode at pc;
if (operands) fetch operands;
execute the action for the opcode;
} while (there is more to do);
每次循环都包含三个部分:
┌─────────────────────────────────────────────────────┐
│ 解释器主循环 │
├─────────────────────────────────────────────────────┤
│ │
│ ① 计算 pc → pc := frame.NextPC() │
│ │ │
│ ▼ │
│ ② 指令解码 → opcode := code[pc] │
│ │ inst := NewInstruction(opcode) │
│ │ inst.FetchOperands(reader) │
│ ▼ │
│ ③ 指令执行 → inst.Execute(frame) │
│ │ │
│ └──────────► 回到 ① │
│ │
└─────────────────────────────────────────────────────┘
1.1 两种实现路径的取舍
路径 A:巨型 switch-case
把这段逻辑用 Go 写成一个 for 循环,里面是一个巨大的 switch-case 语句:
// ❌ 不推荐:可读性的灾难
for {
pc := frame.NextPC()
opcode := code[pc]
switch opcode {
case 0x00:
// nop: 什么都不做
pc++
case 0x02:
// iconst_m1
frame.OperandStack().PushInt(–1)
pc++
case 0x03:
// iconst_0
frame.OperandStack().PushInt(0)
pc++
// … 还有 150 多个 case
case 0x84:
// iinc
index := uint(code[pc+1])
constVal := int32(int8(code[pc+2]))
// …
pc += 3
}
}
问题显而易见:
| 可读性极差 | 一个函数几百行,全是 case |
| 操作数解析重复 | 每个 case 都要手算 code[pc+1]、code[pc+2] 的偏移 |
| 难以测试 | 无法单独测试某一条指令 |
| 难以扩展 | 加一条指令要改主循环 |
路径 B:把指令抽象成接口(本书采用)
把指令抽象成接口,解码和执行逻辑写在具体的指令实现中:
for {
pc := calculatePC()
opcode := bytecode[pc]
inst := createInst(opcode)
inst.fetchOperands(bytecode)
inst.execute()
}
这样编写出的解释器和 JVM 规范里的伪代码一样简单。主循环永远只有这几行,新增指令完全不用改它。
🎯 设计模式:这就是经典的命令模式(Command Pattern) + 工厂方法(Factory Method)。NewInstruction(opcode) 是工厂,Instruction 是命令接口,Execute(frame) 是命令的执行入口。
二、Instruction 接口
在 ch05/instructions/base 目录下创建 instruction.go 文件,定义 Instruction 接口:
package base
import "jvmgo/ch05/rtda"
type Instruction interface {
FetchOperands(reader *BytecodeReader)
Execute(frame *rtda.Frame)
}
只有两个方法:
| FetchOperands(reader *BytecodeReader) | 从字节码中提取操作数,存入指令自己的字段 | 解码阶段,每条指令只调用一次(创建时) |
| Execute(frame *rtda.Frame) | 执行指令逻辑,操作数栈/局部变量表的读写都在这里 | 执行阶段 |
2.1 为什么要拆成两个方法?
这是性能与设计的双重考量:
┌──────────────────┐
字节码 code[] ──► │ FetchOperands │ ──► 指令实例(已填充操作数)
└──────────────────┘ │
│
┌──────────────────┐ │
栈帧 Frame ──► │ Execute │ ◄────────────┘
└──────────────────┘
▲ │
└─────────────────────┘
读写局部变量表 / 操作数栈
- FetchOperands 依赖字节码,不依赖运行时状态——操作数在 class 文件里就已经固定了。
- Execute 依赖运行时状态(Frame),不依赖字节码——操作数已经被"消化"进指令字段了。
这种拆分带来一个巨大的好处:对于循环体的字节码,FetchOperands 只需要执行一次,之后每次循环都直接复用同一个指令实例。真实 HotSpot 的解释器就是这么做的——它会在解释执行时构建"指令重写(instruction rewriting)"缓存。
不过我们的 jvmgo 为了简单,每次循环都会重新解码(第 041 篇会看到这一点)。设计上留了口子,优化留给读者。
三、4 个"抽象指令":用 Go 模拟 Java 的继承
有很多指令的操作数是类似的。比如:
- nop、iconst_0、iadd —— 都没有操作数
- goto、ifeq、if_icmpeq —— 都有一个 2 字节的跳转偏移量
- iload、istore、aload —— 都有一个 1 字节的局部变量表索引
- getstatic、ldc_w、new —— 都有一个 2 字节的常量池索引
如果每条指令都自己实现一遍 FetchOperands,会产生大量重复代码。解决办法是:按照操作数的形状定义一些结构体,并实现 FetchOperands 方法。
💡 这相当于 Java 中的抽象类。具体指令"继承"这些结构体,然后专注实现 Execute 方法即可。
Go 语言没有 extends 关键字,但可以用**结构体嵌入(struct embedding)**达到同样的效果。
3.1 四个抽象指令总览
┌─────────────────────────┐
│ <<interface>> │
│ Instruction │
│ ───────────────────── │
│ + FetchOperands(reader)│
│ + Execute(frame) │
└───────────┬─────────────┘
△
┌───────────┬───────────┼───────────┬────────────┐
│ │ │ │ │
┌───────┴──────┐ ┌──┴────────┐ ┌┴──────────┐ ┌───────────┴┐
│NoOperands │ │ Branch │ │ Index8 │ │ Index16 │
│Instruction │ │Instruction│ │Instruction│ │Instruction │
│──────────────│ │───────────│ │───────────│ │────────────│
│(无字段) │ │Offset int │ │Index uint │ │Index uint │
│──────────────│ │───────────│ │───────────│ │────────────│
│FetchOperands │ │FetchOper. │ │FetchOper. │ │FetchOper. │
│ 空实现 │ │ReadInt16()│ │ReadUint8()│ │ReadUint16()│
└───────┬──────┘ └─────┬─────┘ └─────┬─────┘ └──────┬─────┘
│ │ │ │
nop, iconst_*, goto, ifeq, iload, getstatic,
iadd, lreturn, if_icmpeq, istore, ldc_w,
dup, swap, … goto, … aload, … new, …
3.2 NoOperandsInstruction:没有操作数的指令
type NoOperandsInstruction struct {}
func (self *NoOperandsInstruction) FetchOperands(reader *BytecodeReader) {
// nothing to do
}
NoOperandsInstruction 表示没有操作数的指令,所以没有定义任何字段,FetchOperands 方法自然也是空空如也——一个字节也不用读。
适用范围:大部分指令。常量指令、数学指令、栈指令、转换指令都属于这一类,粗略估计占全部指令的 60% 以上。
3.3 BranchInstruction:跳转指令
type BranchInstruction struct {
Offset int
}
func (self *BranchInstruction) FetchOperands(reader *BytecodeReader) {
self.Offset = int(reader.ReadInt16())
}
BranchInstruction 表示跳转指令,Offset 字段存放跳转偏移量。FetchOperands 从字节码中读取一个 int16 整数,转成 int 后赋给 Offset 字段。
适用范围:goto、ifeq/ifne/iflt/ifle/ifgt/ifge、if_icmp<cond>、if_acmp<cond>、ifnull/ifnonnull。
⚠️ 为什么是 int16 而不是 uint16? 因为跳转偏移量可以是负数——while 循环的最后一条 goto 要往回跳。所以必须是有符号的 16 位整数,范围 [-32768, 32767]。
偏移量是相对于什么? 相对于当前指令的操作码地址(不是操作数地址,也不是下一条指令地址)。这一点在第 039 篇讲 tableswitch 时会变得非常关键。
3.4 Index8Instruction:1 字节索引
type Index8Instruction struct {
Index uint
}
func (self *Index8Instruction) FetchOperands(reader *BytecodeReader) {
self.Index = uint(reader.ReadUint8())
}
存储和加载类指令需要根据索引存取局部变量表,索引由单字节操作数给出。把这类指令抽象成 Index8Instruction 结构体,用 Index 字段表示局部变量表索引。
⚠️ 局部变量表索引是无符号的——没有"第 -1 个局部变量"。所以这里用 ReadUint8() 而不是 ReadInt8(),范围是 [0, 255]。
适用范围:iload、lload、fload、dload、aload 及对应的 xstore 指令。
那超过 255 个局部变量怎么办? 用 wide 指令扩展——第 040 篇会讲。
3.5 Index16Instruction:2 字节索引
type Index16Instruction struct {
Index uint
}
func (self *Index16Instruction) FetchOperands(reader *BytecodeReader) {
self.Index = uint(reader.ReadUint16())
}
有一些指令需要访问运行时常量池,常量池索引由两字节操作数给出。把这类指令抽象成 Index16Instruction 结构体,用 Index 字段表示常量池索引。
适用范围:getstatic/putstatic/getfield/putfield、invokevirtual/invokespecial/invokestatic/invokeinterface、new、checkcast、instanceof、ldc_w、ldc2_w。
📌 常量池最多 65535 项(constant_pool_count 是 u2),所以 2 字节索引刚好够用。
四、Go 的结构体嵌入:没有 extends 的"继承"
现在来看具体指令是怎么"继承"这些抽象结构体的。以 nop 指令为例:
type NOP struct{ base.NoOperandsInstruction }
func (self *NOP) Execute(frame *rtda.Frame) {
// 什么也不用做
}
就这么简单。NOP 结构体里嵌入了 base.NoOperandsInstruction,于是:
- NOP 自动获得了 FetchOperands 方法(方法提升 / method promotion)
- NOP 自己实现了 Execute 方法
- 于是 NOP 满足 Instruction 接口,可以被赋值给 base.Instruction 类型的变量
4.1 原理图解
type NOP struct{ base.NoOperandsInstruction }
┌─────────────────────────────────────────┐
│ NOP 实例 │
├─────────────────────────────────────────┤
│ NoOperandsInstruction (匿名字段) │
│ └─ 提供 FetchOperands() ← 方法提升 │
├─────────────────────────────────────────┤
│ NOP 自己的方法: │
│ └─ Execute(frame) ← 自己实现 │
└─────────────────────────────────────────┘
│
│ 两个方法都齐了
▼
满足 base.Instruction 接口 ✅
4.2 与 Java 继承的对比
| 关键字 | class NOP extends BaseInst | struct{ base.NoOperandsInstruction } |
| 方法复用 | 自动 | 自动(方法提升) |
| 方法重写 | @Override | 同名方法定义在外层结构体即可"覆盖" |
| 多态 | 父类引用指向子类对象 | 接口变量持有结构体指针 |
| 字段访问 | this.offset | self.Offset(提升) |
| 多重"继承" | ❌ 单继承 | ✅ 可嵌入多个结构体 |
"方法重写"的例子——IINC 指令就自带两个操作数,既不是 Index8 也不是 Branch,所以它自己实现 FetchOperands:
type IINC struct {
Index uint
Const int32
}
// 自己实现,覆盖掉任何嵌入的 FetchOperands
func (self *IINC) FetchOperands(reader *base.BytecodeReader) {
self.Index = uint(reader.ReadUint8())
self.Const = int32(reader.ReadInt8())
}
4.3 三个必须记住的 Go 陷阱
陷阱 1:嵌入的是值还是指针?
type ICONST_0 struct{ base.NoOperandsInstruction }
嵌入的是值类型。因为 NoOperandsInstruction 是空结构体(struct{}),在 Go 中不占任何内存(unsafe.Sizeof = 0)。所以 150 条指令用这种方式嵌入,内存开销为零。
陷阱 2:方法接收者必须是指针
func (self *ICONST_0) Execute(frame *rtda.Frame) { … } // ✅
func (self ICONST_0) Execute(frame *rtda.Frame) { … } // ❌
如果用值接收者,那 *ICONST_0 依然满足接口,但 ICONST_0(值)也满足——这看起来更宽松,实际上会导致工厂函数返回 &ICONST_0{} 时行为不一致。统一用指针接收者是 Go 社区的标准做法。
陷阱 3:嵌入字段的名字
struct{ base.NoOperandsInstruction } 是匿名嵌入,字段名就是类型名 NoOperandsInstruction。你也可以显式命名:
type ICONST_0 struct {
base base.NoOperandsInstruction // 命名字段,不推荐
}
但这样就不会发生方法提升,必须写 self.base.FetchOperands(reader)。用匿名嵌入。
五、BytecodeReader:字节码的"解码器"
指令接口和"抽象"指令定义好了,下面来看 BytecodeReader。
在 ch05/instructions/base 目录下创建 bytecode_reader.go 文件:
package base
type BytecodeReader struct {
code []byte
pc int
}
| code | []byte | 存放字节码(来自 Code 属性的 code[] 数组) |
| pc | int | 记录读取到了哪个字节 |
5.1 Reset 方法:复用实例,避免频繁分配
func (self *BytecodeReader) Reset(code []byte, pc int) {
self.code = code
self.pc = pc
}
🎯 为什么要有 Reset? 解释器主循环每执行一条指令都要解码一次。如果每次都 new 一个 BytecodeReader,会产生海量的堆分配,给 GC 造成压力。Reset 让一个实例反复复用——整个解释器只需要一个 BytecodeReader。
5.2 读取方法全家桶
ReadUint8——最简单,读一个字节:
func (self *BytecodeReader) ReadUint8() uint8 {
i := self.code[self.pc]
self.pc++
return i
}
ReadInt8——调用 ReadUint8,然后转成 int8 返回:
func (self *BytecodeReader) ReadInt8() int8 {
return int8(self.ReadUint8())
}
ReadUint16——连续读取两字节,大端字节序:
func (self *BytecodeReader) ReadUint16() uint16 {
byte1 := uint16(self.ReadUint8())
byte2 := uint16(self.ReadUint8())
return (byte1 << 8) | byte2
}
ReadInt16——同理:
func (self *BytecodeReader) ReadInt16() int16 {
return int16(self.ReadUint16())
}
ReadInt32——连续读取四字节:
func (self *BytecodeReader) ReadInt32() int32 {
byte1 := int32(self.ReadUint8())
byte2 := int32(self.ReadUint8())
byte3 := int32(self.ReadUint8())
byte4 := int32(self.ReadUint8())
return (byte1 << 24) | (byte2 << 16) | (byte3 << 8) | byte4
}
PC——获取当前读取位置(供解释器更新 nextPC):
func (self *BytecodeReader) PC() int {
return self.pc
}
5.3 为什么是"大端"?
JVM 规范明确规定:class 文件中的所有多字节数据项都按大端字节序(big-endian)存储,也就是高位字节在前。
内存中的字节序列: [0x00] [0x02]
│ │
│ └─ 低 8 位
└──────── 高 8 位
解读为 uint16: 0x0002 = 2
这在网络协议中又叫网络字节序(network byte order)。之所以选择大端,是因为:
对比一下小端(x86 CPU 的内存序):
| 00 02 | 2 | 512 |
| CA FE | 51966 | 65226 |
⚠️ 前面第 3 章我们用 encoding/binary.BigEndian 解析 class 文件,这里为什么手写位运算? 因为 binary.BigEndian.Uint16 需要一个完整的 []byte 切片,而我们这里要逐字节推进 pc。手写位运算 + pc++ 更直观,也更省一次切片边界检查。
5.4 两个特殊方法:SkipPadding 和 ReadInt32s
这两个方法是为 tableswitch / lookupswitch 准备的,先在这里给出:
SkipPadding——跳过 0~3 字节的填充:
func (self *BytecodeReader) SkipPadding() {
for self.pc%4 != 0 {
self.ReadUint8()
}
}
ReadInt32s——连续读取 n 个 int32:
func (self *BytecodeReader) ReadInt32s(n int32) []int32 {
ints := make([]int32, n)
for i := range ints {
ints[i] = self.ReadInt32()
}
return ints
}
为什么需要 padding? 因为 tableswitch 的操作数里有 int32,JVM 规范要求这些 32 位数据必须4 字节对齐。这样 CPU 读取时不会因为跨越字边界而变慢。
假设 tableswitch 的操作码在地址 5:
地址: 0 1 2 3 4 5 6 7 8 9 10 11
… … … … … [0xAA][pad][pad][defaultOffset …]
↑ ↑
操作码 pc 跳到 8(4 的倍数)
🔢 padding 从操作码地址算起,不是从 0 算起。因为指令流是连续的,对齐是相对于方法字节码的起始位置。第 039 篇会详细讨论这个容易踩坑的地方。
5.5 方法清单速查
| Reset(code, pc) | — | — | 解释器主循环 |
| ReadUint8() | 1 | uint8 | Index8Instruction、opcode |
| ReadInt8() | 1 | int8 | bipush、iinc 的 Const |
| ReadUint16() | 2 | uint16 | Index16Instruction |
| ReadInt16() | 2 | int16 | BranchInstruction、sipush |
| ReadInt32() | 4 | int32 | goto_w、switch 的偏移量 |
| ReadInt32s(n) | 4n | []int32 | tableswitch、lookupswitch |
| SkipPadding() | 0~3 | — | switch 系列 |
| PC() | — | int | 解释器更新 nextPC |
六、完整的 instruction.go 文件
把这一篇所有代码汇总,ch05/instructions/base/instruction.go 的完整内容如下:
package base
import "jvmgo/ch05/rtda"
// Instruction 是所有字节码指令的统一抽象
type Instruction interface {
FetchOperands(reader *BytecodeReader)
Execute(frame *rtda.Frame)
}
// NoOperandsInstruction: 没有操作数的指令
type NoOperandsInstruction struct{}
func (self *NoOperandsInstruction) FetchOperands(reader *BytecodeReader) {
// nothing to do
}
// BranchInstruction: 跳转指令,Offset 存放跳转偏移量
type BranchInstruction struct {
Offset int
}
func (self *BranchInstruction) FetchOperands(reader *BytecodeReader) {
self.Offset = int(reader.ReadInt16())
}
// Index8Instruction: 局部变量表索引由单字节给出
type Index8Instruction struct {
Index uint
}
func (self *Index8Instruction) FetchOperands(reader *BytecodeReader) {
self.Index = uint(reader.ReadUint8())
}
// Index16Instruction: 常量池索引由两字节给出
type Index16Instruction struct {
Index uint
}
func (self *Index16Instruction) FetchOperands(reader *BytecodeReader) {
self.Index = uint(reader.ReadUint16())
}
注意:这个文件里没有一行 Execute 的实现。因为 4 个抽象指令只是"半成品"——它们提供了 FetchOperands,但 Execute 必须由具体指令自己实现。这在 Go 的类型系统里是完全合法的:NoOperandsInstruction 不满足 Instruction 接口(缺 Execute),但 NOP(嵌入它并实现了 Execute)满足。
NoOperandsInstruction NOP
├─ FetchOperands ✅ ├─ FetchOperands ✅ (继承)
└─ Execute ❌ └─ Execute ✅ (自实现)
│ │
不满足接口 满足接口 ✅
七、从伪代码到真实代码的映射
把这一篇的产出串回第 1 节的解释器伪代码:
// JVM 规范伪代码 // jvmgo 真实代码(第 041 篇)
do { for {
atomically calculate pc; pc := frame.NextPC()
thread.SetPC(pc)
fetch opcode at pc; reader.Reset(bytecode, pc)
opcode := reader.ReadUint8()
create instruction; inst := instructions.NewInstruction(opcode)
if (operands) fetch operands; inst.FetchOperands(reader)
frame.SetNextPC(reader.PC())
execute the action for the opcode; inst.Execute(frame)
} while (there is more to do); }
对应关系一目了然:
| calculate pc | frame.NextPC() + thread.SetPC(pc) |
| fetch opcode | reader.Reset() + reader.ReadUint8() |
| create instruction | instructions.NewInstruction(opcode) |
| fetch operands | inst.FetchOperands(reader) ← 本篇的 4 个抽象指令 |
| execute | inst.Execute(frame) ← 后面 9 篇要写的 156 条指令 |
本篇小结
这一篇搭好了整个指令系统的骨架,三个核心产出:
本篇最值得记住的一句话:type ICONST_0 struct{ base.NoOperandsInstruction } + 一个 Execute 方法 = 一条完整的 JVM 指令。
下一篇开始,我们将按照常量 → 加载/存储 → 栈 → 数学 → 转换 → 比较 → 控制 → 扩展的顺序,逐个实现 156 条指令。先从最简单的常量指令开始。
上一篇【第31篇】JVM 字节码指令总览——200 多条指令的"全家福" 下一篇【第33篇】常量指令——把数字推到栈上的艺术

