上一篇【第04篇】Go 语言环境准备——用 Go 写 JVM 的 5 大理由
下一篇【第06篇】类路径(classpath)是什么鬼——JVM 怎么找到你的 class 文件
摘要
前面四篇都在做准备,这一篇终于要动手写代码了!
本章(ch01)的目标很朴素:写一个类似java命令的命令行工具,能解析-classpath、-version、-help这些参数,并把解析结果打印出来。虽然它还不能执行任何字节码,但这是整个 jvmgo 项目的起点。
本文会带你:规划 jvmgo 的 11 章目录结构、创建项目骨架、设计 Cmd 结构体、用 Go 的 flag 包解析命令行参数、实现版本号与帮助信息输出,最后完整测试 ch01。写完这篇,你就拥有了一个(还啥也干不了的)Java 虚拟机启动器。
一、jvmgo 的整体项目规划
在动手之前,先把整个项目的版图规划清楚。
jvmgo 采用渐进式演进的组织方式:整个项目分成ch01 到 ch11 共 11 个版本,每个版本是一个独立的 Go 程序,后一章在前一章的基础上增量开发。
【jvmgo 目录结构演进】 jvmgo/ ├── ch01/ 第1章:命令行工具 │ ├── cmd.go 命令行参数解析 │ └── main.go 程序入口 │ ├── ch02/ 第2章:搜索 class 文件 │ ├── classpath/ │ │ ├── entry.go Entry 接口 │ │ ├── entry_dir.go DirEntry │ │ ├── entry_zip.go ZipEntry │ │ ├── entry_composite.go CompositeEntry │ │ ├── entry_wildcard.go WildcardEntry │ │ └── classpath.go Classpath │ ├── cmd.go │ └── main.go │ ├── ch03/ 第3章:解析 class 文件 │ ├── classfile/ ★新增:class 文件解析 │ │ ├── class_reader.go 字节读取器 │ │ ├── class_file.go class 文件结构 │ │ ├── constant_pool.go 常量池 │ │ ├── constant_info.go 14 种常量 │ │ ├── attribute_info.go 属性表 │ │ └── member_info.go 字段/方法表 │ ├── classpath/ │ ├── cmd.go │ └── main.go │ ├── ch04/ 第4章:运行时数据区 │ ├── rtda/ ★新增:运行时数据区 │ │ ├── thread.go Thread │ │ ├── stack.go Stack │ │ ├── frame.go Frame 栈帧 │ │ ├── local_vars.go 局部变量表 │ │ ├── operand_stack.go 操作数栈 │ │ └── object.go Object(临时) │ ├── classfile/ │ ├── classpath/ │ └── main.go │ ├── ch05/ 第5章:指令集和解释器 │ ├── instructions/ ★新增:字节码指令(150+) │ │ ├── instruction.go Instruction 接口 │ │ ├── base/ │ │ ├── constants/ 常量指令 │ │ ├── loads/ 加载指令 │ │ ├── stores/ 存储指令 │ │ ├── stack/ 栈指令 │ │ ├── math/ 数学指令 │ │ ├── conversions/ 转换指令 │ │ ├── comparisons/ 比较指令 │ │ ├── control/ 控制指令 │ │ ├── references/ 引用指令 │ │ └── extended/ 扩展指令 │ ├── rtda/ │ ├── interpreter.go ★新增:解释器 │ └── main.go │ ├── ch06/ ~ ch11/ 后续章节持续增量 │ ├── rtda/heap/ ★ch06 新增:方法区、类加载器 │ └── native/ ★ch09 新增:本地方法每一章的目录变化:
| 章节 | 新增目录/文件 | 累计代码量(约) |
|---|---|---|
| ch01 | cmd.go, main.go | ~60 行 |
| ch02 | classpath/ | ~300 行 |
| ch03 | classfile/ | ~1200 行 |
| ch04 | rtda/ | ~1700 行 |
| ch05 | instructions/, interpreter.go | ~4700 行 |
| ch06 | rtda/heap/ | ~6500 行 |
| ch07 | (增量修改) | ~7500 行 |
| ch08 | (增量修改) | ~8500 行 |
| ch09 | native/ | ~10000 行 |
| ch10 | (增量修改) | ~10500 行 |
| ch11 | (增量修改) | ~11000 行 |
重点:每一章都在上一章基础上复制一份再改。比如从 ch02 到 ch03,你只需要
cp -r ch02 ch03,然后把 main.go 里的 import 路径ch02改成ch03,再新增 classfile 目录。这种"快照式"组织方式的好处是:每一章的代码都是完整可运行的,随时可以回头对比。
二、创建项目骨架
开工。先创建项目根目录和 ch01:
# 创建项目目录mkdirjvmgocdjvmgo# 初始化 Go 模块go mod init jvmgo# 创建 ch01 目录mkdirch01cdch01创建两个文件:cmd.go和main.go。
【ch01 的文件结构】 jvmgo/ ├── go.mod ← go mod init 生成 └── ch01/ ├── cmd.go ← 命令行参数解析 └── main.go ← 程序入口(main 函数)go.mod内容:
module jvmgo go 1.21三、先看看真正的 java 命令长什么样
在写代码之前,我们先研究一下要模仿的对象——JDK 的java命令。
在命令行里敲:
java-help会看到一大堆参数。我们不需要实现全部,只实现最核心的几个:
【java 命令的核心参数】 java [-options] class [args...] │ │ │ │ │ └─ 传给 main 方法的参数 │ │ (String[] args) │ │ │ └─ 要运行的主类全限定名 │ 例如:com.example.HelloWorld │ └─ JVM 选项 ├─ -version 打印版本信息并退出 ├─ -help / -? 打印帮助信息并退出 ├─ -classpath / -cp 指定类路径 │ 例如:-cp ".;lib/*" └─ -Xjre ★我们自己加的:指定 JRE 路径参数解析的优先级是这样的:
【java 命令参数解析流程】 启动 java │ ▼ 解析命令行参数 │ ├─ 有 -version? ── 是 ──► 打印版本号 ──► 退出 │ ├─ 有 -help / -? ── 是 ──► 打印帮助 ──► 退出 │ ├─ 主类为空? ── 是 ──► 打印帮助 ──► 退出 │ └─ 都不满足 ──► 启动 JVM,加载主类,调用 main()重点:
-Xjre这个参数在原版 java 命令里是没有的(至少不是标准用法),是我们为了指定 JRE 路径(以便加载 rt.jar)自己加的。JVM 里以-X开头的通常是非标准选项,所以我们遵循这个约定。
四、Cmd 结构体:命令行参数的"容器"
先定义存放解析结果的结构体。
ch01/cmd.go:
packagemainimport"flag"import"fmt"import"os"// Cmd 结构体,用于保存解析后的命令行参数typeCmdstruct{helpFlagbool// -help / -? 是否指定versionFlagbool// -version 是否指定cpOptionstring// -classpath / -cp 的值XjreOptionstring// -Xjre 的值classstring// 主类名args[]string// 传给 main 方法的参数}六个字段,对应前面分析的全部参数。
重点:注意
XjreOption这个字段名首字母大写——在 Go 里,首字母大写才能被其他包访问。虽然 ch01 里所有代码都在main包内,大写小写都行,但为了后续章节方便(Cmd 会被其他包使用),这里保持大写。
parseCmd():解析命令行参数
Go 标准库的flag包专门干这事,用起来很简洁:
// parseCmd 解析命令行参数,返回 Cmd 指针funcparseCmd()*Cmd{cmd:=&Cmd{}// 1. 设置自定义的 usage 函数flag.Usage=printUsage// 2. 注册需要解析的参数flag.BoolVar(&cmd.helpFlag,"help",false,"print help message")flag.BoolVar(&cmd.helpFlag,"?",false,"print help message")flag.BoolVar(&cmd.versionFlag,"version",false,"print version and exit")flag.StringVar(&cmd.cpOption,"classpath","","classpath")flag.StringVar(&cmd.cpOption,"cp","","classpath")flag.StringVar(&cmd.XjreOption,"Xjre","","path to jre")// 3. 执行解析flag.Parse()// 4. 解析剩下的非选项参数args:=flag.Args()iflen(args)>0{cmd.class=args[0]// 第一个是主类名cmd.args=args[1:]// 剩下的是 main 方法的参数}returncmd}flag包的核心 API:
| 函数 | 作用 | 例子 |
|---|---|---|
flag.BoolVar(&v, name, default, usage) | 注册布尔型选项 | flag.BoolVar(&help, "help", false, "帮助") |
flag.StringVar(&v, name, default, usage) | 注册字符串选项 | flag.StringVar(&cp, "cp", "", "类路径") |
flag.IntVar(&v, name, default, usage) | 注册整型选项 | — |
flag.Parse() | 执行解析 | 必须调用 |
flag.Args() | 获取非选项参数 | ["HelloWorld", "arg1"] |
flag.Usage | 自定义 usage 输出 | 覆盖默认帮助 |
注意几个细节:
- 同一个变量可以注册多个名字:
-help和-?都绑定到cmd.helpFlag,-classpath和-cp都绑定到cmd.cpOption。这跟真实的 java 命令一致。 flag.Parse()之后,flag.Args()返回所有非选项参数(即不以-开头的那些)。flag.Usage是个函数变量,赋值后,当解析出错或用户请求帮助时会自动调用它。
printUsage():帮助信息
// printUsage 打印帮助信息funcprintUsage(){fmt.Printf("Usage: %s [-options] class [args...]\n",os.Args[0])}os.Args[0]是程序自己的路径,所以帮助信息会显示成类似Usage: D:\jvmgo\ch01\ch01.exe [-options] class [args...]。
五、main.go:程序入口
有了参数解析,主程序就简单了。
ch01/main.go:
packagemainimport"fmt"funcmain(){cmd:=parseCmd()ifcmd.versionFlag{// 用户要版本号fmt.Println("version 0.0.1")}elseifcmd.helpFlag||cmd.class==""{// 用户要帮助,或者压根没给主类名printUsage()}else{// 正常启动 JVMstartJVM(cmd)}}// startJVM 启动 JVM(本章只是打印参数,后面章节逐步实现)funcstartJVM(cmd*Cmd){fmt.Printf("classpath:%s class:%s args:%v\n",cmd.cpOption,cmd.class,cmd.args)}逻辑很直白,就是前面那张流程图的直接翻译:
- 有
-version→ 打印版本号 - 有
-help,或者没给主类名 → 打印帮助 - 否则 → 启动 JVM(本章只打印参数)
重点:
startJVM()现在只是个"占位函数",打印一下参数就完事。在接下来的 10 章里,这个函数会不断"长大"——ch02 用它来测试类路径,ch03 用它打印 class 文件内容,ch05 开始真正执行字节码……到 ch11,它就是一个完整的 JVM 启动器了。
六、编译与测试
代码写完了,跑起来看看。
编译
cdch01 go build-ojvmgo.exe生成一个jvmgo.exe(Windows)或jvmgo(Mac/Linux)可执行文件。
测试 1:打印版本号
$ jvmgo.exe-versionversion0.0.1测试 2:打印帮助
$ jvmgo.exe-helpUsage: jvmgo.exe[-options]class[args...]$ jvmgo.exe Usage: jvmgo.exe[-options]class[args...]注意第二个测试:不带任何参数时也打印帮助,因为cmd.class == ""。
测试 3:正常启动
$ jvmgo.exe-cp"D:\test"HelloWorld arg1 arg2 classpath:D:\test class:HelloWorld args:[arg1 arg2]参数解析成功!-cp的值、HelloWorld主类名、以及arg1 arg2两个额外参数都被正确解析了。
测试 4:等价写法
# -classpath 和 -cp 是等价的$ jvmgo.exe-classpath"D:\test"HelloWorld classpath:D:\test class:HelloWorld args:[]# -Xjre 也能解析$ jvmgo.exe-Xjre"C:\Java\jdk1.8.0_202\jre"-cp"D:\test"HelloWorld classpath:D:\test class:HelloWorld args:[]七、踩坑记录
坑 1:Windows 下路径分隔符
Windows 的类路径用分号;分隔多个路径,Linux/Mac 用冒号::
# Windowsjvmgo-cp".;lib/*;C:\Java\rt.jar"HelloWorld# Linux / Mac./jvmgo-cp".:lib/*:/usr/lib/jvm/rt.jar"HelloWorld后面实现 CompositeEntry 时要处理这个差异——用os.PathListSeparator获取当前系统的分隔符,而不是硬编码。
坑 2:命令行参数的解析顺序
flag包要求选项必须在非选项参数之前,否则后面的选项会被当成普通参数:
# ✅ 正确:选项在前jvmgo-cp"D:\test"HelloWorld arg1# ❌ 错误:HelloWorld 后面的 -cp 不会被解析成选项jvmgo HelloWorld-cp"D:\test"arg1# 结果:class=HelloWorld, args=[-cp, D:\test, arg1]这跟真实的 java 命令行为是一致的(java HelloWorld -cp xxx里的-cp确实会被当作 main 方法的参数)。
坑 3:Windows 命令行中文乱码
如果你的 Go 程序输出中文乱码,在命令行先执行:
chcp65001# 切换到 UTF-8 代码页本篇小结
ch01 完成了 jvmgo 项目的第一步——一个 java 命令的雏形:
- 项目规划:明确 ch01~ch11 共 11 个版本的渐进式演进结构,每章复制上一章再增量开发
- Cmd 结构体:6 个字段承载全部命令行参数(helpFlag/versionFlag/cpOption/XjreOption/class/args)
- 参数解析:用 Go 标准库
flag包,BoolVar/StringVar注册选项,Parse()执行解析,Args()获取主类名和额外参数 - 主程序逻辑:
-version→ 打印版本;-help或无主类名 → 打印帮助;否则 → startJVM() - 完整测试:4 组测试全部通过,参数解析正确
现在我们的startJVM()还只是个打印参数的空壳。但别急——下一章(ch02)就要给它加上**类路径(classpath)**功能,让我们的 JVM 能够找到 class 文件在磁盘上的位置。
从下一章开始,就是真正的 JVM 核心内容了。
上一篇【第04篇】Go 语言环境准备——用 Go 写 JVM 的 5 大理由
下一篇【第06篇】类路径(classpath)是什么鬼——JVM 怎么找到你的 class 文件