上一篇【第03篇】JDK 安装与环境准备——工欲善其事必先利其器
下一篇【第05篇】项目结构与第一行代码——jvmgo 项目骨架搭建
摘要
HotSpot 用 C++ 写了 150 万行,那我们用什么写?答案是 Go。
本文先给出用 Go 写 JVM 的五大理由(语法简洁、自带 GC、标准库齐全、跨平台、学习曲线低),并横向对比 C++/Java/Rust 的优劣。然后完成 Go 环境的安装、GOPATH 与 Go Modules 的配置、跑通第一个 Go 程序。最后,用最短的篇幅速成本系列需要的 Go 核心语法——结构体、接口、切片、指针、多返回值。看完这篇,你就能开始写 jvmgo 了。
一、为什么用 Go 写 JVM?
先说结论:Go 是写 JVM 这种"充满引用的复杂数据结构"程序的绝佳选择。
我们横评一下四种候选语言:
| 维度 | C++ | Java | Rust | Go |
|---|---|---|---|---|
| 语法复杂度 | 极高(模板/宏/头文件/内存管理) | 中 | 高(所有权/借用/生命周期) | 极低 |
| 内存管理 | 手动(new/delete,容易泄漏) | GC | 编译期所有权检查 | GC |
| 编译速度 | 慢(大型项目分钟级) | 中 | 很慢 | 极快(秒级) |
| 标准库 | 一般(STL 有限) | 强 | 中 | 强(zip/binary/文件开箱即用) |
| 写 VM 的痛苦度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐(鸡生蛋悖论) | ⭐⭐⭐⭐(借用检查器打架) | ⭐ |
| 性能 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 学习曲线 | 陡峭 | 平缓 | 陡峭 | 平缓 |
具体到写 JVM 这件事上,Go 的五大优势是:
优势 1:语法极简,没有"语言噪音"
C++ 写 JVM,你得跟模板、宏、头文件、多重继承搏斗;Go 只有 25 个关键字,没有继承(用组合替代),没有泛型(Go 1.18 前),没有异常(用 error 返回值)。
写一个指令接口,Go 是这么写的:
// Go:清爽typeInstructioninterface{FetchOperands(reader*BytecodeReader)Execute(frame*rtda.Frame)}对比 C++ 的写法,你得写头文件、声明虚基类、处理析构……代码量翻几倍。
优势 2:自带 GC,不用管内存
JVM 里到处都是对象引用、类引用、栈帧引用。用 C++ 你得小心翼翼管理内存;用 Go,直接交给 GC。
// 不用 new/delete,不用智能指针,不用 RAIIframe:=&rtda.Frame{localVars:newLocalVars(maxLocals),operandStack:newOperandStack(maxStack),}// 用完了?GC 会回收,你不用管重点:这一点太重要了。我们用 Go 写 JVM,内存管理交给 Go 的 GC,我们自己不用实现 GC——这直接砍掉了 JVM 最复杂的模块之一。
优势 3:标准库齐全,二进制处理开箱即用
写 JVM 有大量重复工作:读 zip(JAR 包)、解析二进制(class 文件)、处理字节序(Big-Endian)。Go 标准库全都有:
import("archive/zip"// 读 JAR 包"encoding/binary"// 大端序解析二进制"os"// 文件操作"path/filepath"// 跨平台路径处理)// 读大端序 uint32(class 文件的魔数),一行搞定magic:=binary.BigEndian.Uint32(data[0:4])这些在 C++ 里要么自己写,要么引入第三方库。
优势 4:跨平台,编译快
Go 原生支持交叉编译,一份代码编译出 Windows/Linux/macOS 三个平台的可执行文件。而且编译速度极快——jvmgo 这种万行级项目,编译只要几秒。
GOOS=windowsGOARCH=amd64 go build# 编 Windows 版GOOS=linuxGOARCH=amd64 go build# 编 Linux 版GOOS=darwinGOARCH=arm64 go build# 编 Mac (M系列) 版优势 5:学习曲线低,有 C/Java 基础几天上手
Go 的语法对 C/Java 程序员极其友好。如果你写过 Java,看 Go 代码基本没有障碍——只是把class换成struct,把interface的实现从implements改成隐式实现(duck typing)。
【语言选择决策树】 想写一个 JVM,选什么语言? │ ┌───────┴───────┐ │ │ 追求极致性能? 追求快速实现? │ │ ▼ ▼ C++ / Rust Go ← 我们在这儿 (HotSpot 同款) (快速验证原理) │ │ ▼ ▼ 150万行代码 1万行代码 学 3 个月 学 3 天 调试痛苦 调试轻松重点:我们的目标是理解 JVM 原理,不是造一个生产级 JVM。用 Go 能把精力 100% 放在原理本身,而不是跟语言细节搏斗。
二、Go 的下载与安装
Windows
- 访问 Go 官网下载页(golang.org/dl)或国内镜像(golang.google.cn/dl)
- 下载
go1.x.x.windows-amd64.msi - 双击安装,默认路径
C:\Go - 安装程序会自动把
C:\Go\bin加到 PATH
macOS
# 方式一:Homebrewbrewinstallgo# 方式二:下载 pkg 安装包Linux
# 下载并解压wgethttps://golang.google.cn/dl/go1.21.0.linux-amd64.tar.gzsudotar-C/usr/local-xzfgo1.21.0.linux-amd64.tar.gz# 配置 PATH(加到 ~/.bashrc 或 ~/.zshrc)exportPATH=$PATH:/usr/local/go/bin验证安装
go version# 输出:go version go1.21.0 windows/amd64如果提示go: command not found,检查 PATH 配置。
三、GOPATH 与 Go Modules
这是 Go 新手最容易懵的地方。先理清两个概念:
GOPATH(传统方式,Go 1.11 之前)
GOPATH 是 Go 的工作目录,所有 Go 项目都必须放在$GOPATH/src下:
【GOPATH 目录结构】 $GOPATH/ 例如 D:\go\workspace ├── src/ ← 源码目录(你的项目放这里) │ └── jvmgo/ │ ├── ch01/ │ ├── ch02/ │ └── ... ├── pkg/ ← 编译后的包文件(.a) └── bin/ ← 编译后的可执行文件《自己动手写 Java 虚拟机》原书用的是 GOPATH 模式,项目路径类似D:\go\workspace\src\jvmgo\ch01。
Go Modules(现代方式,Go 1.11+ 推荐)
Go Modules 是官方推荐的依赖管理方式,项目可以放在任意目录,通过go.mod文件管理依赖:
# 初始化模块go mod init jvmgo# 生成的 go.mod 文件module jvmgo go1.21重点:本系列采用Go Modules方式(更现代、更灵活),但目录组织上依然参考原书的
ch01 ~ ch11渐进式结构。两种方式都能跑,选你顺手的即可。
配置 GOPATH(可选)
# 查看当前 Go 环境配置goenv# 设置 GOPATH(如果用 Modules,不配也行)goenv-wGOPATH=D:\go\workspace# 设置国内代理(加速依赖下载,强烈推荐)goenv-wGOPROXY=https://goproxy.cn,direct提示:国内网络访问 Go 官方源比较慢,一定要配
GOPROXY=https://goproxy.cn,否则下载依赖会卡到怀疑人生。
四、第一个 Go 程序
按惯例,先跑个 Hello World 验证环境。
// main.gopackagemainimport"fmt"funcmain(){fmt.Println("Hello, jvmgo!")}运行:
# 方式一:直接运行(不生成可执行文件)go run main.go# 方式二:编译后运行go build-ojvmgo.exe main.go ./jvmgo.exe输出Hello, jvmgo!,说明环境 OK。
常用 Go 命令先记一下:
| 命令 | 作用 |
|---|---|
go run xxx.go | 直接运行源码(开发调试用) |
go build | 编译生成可执行文件 |
go build -o name | 指定输出文件名 |
go test | 运行单元测试 |
go fmt | 格式化代码(Go 强制统一格式) |
go mod init | 初始化模块 |
go get | 下载依赖 |
go env | 查看环境配置 |
五、Go 语法速成:写 JVM 需要的那些
如果你写过 Java 或 C,下面这些语法基本扫一眼就会。本系列用到的 Go 特性不多,掌握这些就够了。
1. 变量与类型
// 显式声明varversionuint32=52varnamestring="java/lang/Object"// 短声明(最常用,:= 自动推导类型)magic:=0xCAFEBABE// intclassName:="HelloWorld"// stringok:=true// bool// 批量声明var(maxStackuint=100maxLocalsuint=100)常用类型对照:
| Go 类型 | 说明 | Java 对照 |
|---|---|---|
int8/16/32/64 | 有符号整数 | byte/short/int/long |
uint8/16/32/64 | 无符号整数 | — |
byte | uint8别名 | — |
float32/64 | 浮点 | float/double |
string | 字符串 | String |
bool | 布尔 | boolean |
[]T | 切片(动态数组) | 数组/ArrayList |
map[K]V | 映射 | HashMap |
*T | 指针 | 引用 |
2. 结构体(对标 Java 的类)
// 定义结构体typeClassstruct{accessFlagsuint16namestringsuperClassNamestringinterfaceNames[]stringconstantPool*ConstantPool}// 创建实例c:=&Class{// & 取地址,得到指针accessFlags:0x0021,// ACC_PUBLIC | ACC_SUPERname:"HelloWorld",}// 访问字段fmt.Println(c.name)3. 方法(带接收者)
Go 没有 class,方法定义在结构体外面,通过"接收者"绑定:
// 指针接收者(可以修改结构体内容)func(c*Class)Name()string{returnc.name}// 值接收者(只读)func(c Class)String()string{return"Class: "+c.name}4. 接口(隐式实现,duck typing)
这是 Go 最优雅的设计之一——不需要implements关键字,实现了接口的所有方法,就自动实现了这个接口:
// 定义接口typeConstantInfointerface{readInfo(reader*ClassReader)}// 实现接口:只要实现了 readInfo 方法,就自动是 ConstantInfotypeConstantIntegerInfostruct{valint32}func(self*ConstantIntegerInfo)readInfo(reader*ClassReader){self.val=int32(reader.readUint32())}// 使用:可以直接放进接口切片varpool[]ConstantInfo pool=append(pool,&ConstantIntegerInfo{})重点:这个特性在解析 class 文件常量池时会被用到极致——14 种常量各写一个结构体,都实现
ConstantInfo接口,然后统一放进[]ConstantInfo数组里管理。
5. 切片(动态数组)
// 创建slots:=make([]Slot,10)// 长度 10 的切片varframes[]*Frame// nil 切片// 追加frames=append(frames,newFrame)// 切片操作top:=slots[0:2]// 取前 2 个(左闭右开)// 长度与容量len(slots)// 长度cap(slots)// 容量6. 指针
x:=10p:=&x// p 是指向 x 的指针*p=20// 通过指针修改值(解引用)fmt.Println(x)// 20// 结构体指针可以直接用 . 访问字段(Go 自动解引用)frame:=&Frame{}frame.nextPC=10// 等价于 (*frame).nextPC = 107. 多返回值与错误处理
// Go 惯用法:返回值 + errorfuncreadClass(namestring)([]byte,error){data,err:=os.ReadFile(name)iferr!=nil{returnnil,err// 出错返回 error}returndata,nil// 成功返回数据 + nil}// 调用data,err:=readClass("HelloWorld.class")iferr!=nil{panic(err)// 或者 log.Fatal(err)}重点:Go 没有 try-catch,错误处理靠显式返回 error 并检查。刚开始觉得啰嗦,习惯了会觉得比异常清晰。
8. 位运算(写 JVM 大量使用)
// 访问标志、类索引这些大量使用位运算const(ACC_PUBLIC=0x0001ACC_FINAL=0x0010ACC_SUPER=0x0020)// 判断某位是否设置ifflags&ACC_PUBLIC!=0{fmt.Println("是 public 类")}// 组合标志flags:=ACC_PUBLIC|ACC_SUPER// 0x0021// 移位majorVersion:=uint16(data[6])<<8|uint16(data[7])9. 类型转换
Go 的类型转换必须显式,不会自动转换:
varbbyte=0x12variint32=int32(b)// 必须显式转换varuuint32=52vari32int32=int32(u)// uint32 → int32 也要显式// 浮点数的特殊处理(解析 double 常量时会用到)bits:=binary.BigEndian.Uint64(data)val:=math.Float64frombits(bits)// 位模式 → float6410. 包与导入
packageclasspath// 声明包名import("fmt"// 标准库"jvmgo/ch06/classfile"// 本项目内的包(模块名 + 路径))// 大写字母开头的标识符才对外可见(导出)funcNewClasspath(...)*Classpath{...}// 可导出funcparseEntry(...){...}// 小写,包内私有重点:Go 用首字母大小写控制可见性——大写 = public,小写 = private。这点和 Java 的
public/private关键字不同,刚开始容易踩坑。
六、IDE 与调试
推荐两款:
| IDE | 优点 | 适合 |
|---|---|---|
| VS Code + Go 插件 | 轻量、免费、插件生态好 | 大多数人 |
| GoLand(JetBrains) | 功能最强、重构/调试体验好 | 专业 Go 开发者 / 追求效率 |
VS Code 配置很简单:
- 安装 VS Code
- 安装 Go 官方插件(golang.go)
- 打开 Go 文件,插件会提示安装工具链(gopls、dlv 等),点"Install All"
调试配置(.vscode/launch.json):
{"version":"0.2.0","configurations":[{"name":"Launch jvmgo","type":"go","request":"launch","mode":"auto","program":"${workspaceFolder}/ch01","args":["-cp","./test","HelloWorld"]}]}配置好后按 F5 就能断点调试我们的 jvmgo——后面调试字节码执行过程时,这个能力会非常有用。
本篇小结
我们选 Go 作为 jvmgo 的实现语言,五大理由:
- 语法极简:没有模板/宏/头文件地狱,25 个关键字搞定一切
- 自带 GC:不用自己实现垃圾回收,专注 JVM 逻辑本身
- 标准库齐全:archive/zip、encoding/binary 开箱即用
- 跨平台 + 编译快:秒级编译,一键交叉编译三平台
- 学习曲线低:有 C/Java 基础几天上手
环境方面完成了:Go 安装与验证、GOPATH/Go Modules 的概念梳理、国内代理 GOPROXY 配置、第一个 Go 程序跑通。语法方面速成了结构体、接口、切片、指针、多返回值、位运算等写 JVM 的核心语法。
下一篇,终于要动手写代码了!我们会创建 jvmgo 项目骨架,实现java命令的雏形——解析命令行参数、打印版本号、输出 usage 帮助。这是 ch01 的全部内容,也是我们 11 章旅程的第一步。
上一篇【第03篇】JDK 安装与环境准备——工欲善其事必先利其器
下一篇【第05篇】项目结构与第一行代码——jvmgo 项目骨架搭建