
上一篇【第11篇】class 文件结构总览——JVM 的通用语下一篇【第13篇】类访问标志——public/abstract/final 都在里面摘要解析 class 文件第一步先实现ClassReader字节读取器——这里有个 Go 的优雅技巧用 reslice 语法实现零索引的顺序读取比维护偏移量变量清爽得多。然后解析文件头魔数0xCAFEBABEJava 团队的咖啡梗和版本号。版本号背后有个重要的兼容性规则——高版本 JVM 能跑低版本 class反之不行这就是为什么你用 JDK 21 编译的代码没法在 JDK 8 上跑。最后我们会用 Go 特有的panic-recover机制做错误处理把解析过程中的崩溃转换成可检查的 error。一、ClassReader字节读取器解析 class 文件前先得有个趁手的工具来读字节。虽然可以直接操作[]byte但到处写data[i] 8 | data[i1]太痛苦了。我们封装一个ClassReader// ch03/classfile/class_reader.gopackageclassfileimportencoding/binary// ClassReader 用来读取 class 文件中的字节数据typeClassReaderstruct{data[]byte}就这么简单——只是[]byte的包装。六种读取方法// readUint8 读取 u1 类型数据1 字节func(self*ClassReader)readUint8()uint8{val:self.data[0]// 取第一个字节self.dataself.data[1:]// 跳过已读数据 ← 关键returnval}// readUint16 读取 u2 类型数据2 字节大端序func(self*ClassReader)readUint16()uint16{val:binary.BigEndian.Uint16(self.data)self.dataself.data[2:]// 跳过 2 字节returnval}// readUint32 读取 u4 类型数据4 字节大端序func(self*ClassReader)readUint32()uint32{val:binary.BigEndian.Uint32(self.data)self.dataself.data[4:]// 跳过 4 字节returnval}// readUint64 读取 8 字节JVM 规范没定义 u8但读 long/double 需要func(self*ClassReader)readUint64()uint64{val:binary.BigEndian.Uint64(self.data)self.dataself.data[8:]returnval}// readUint16s 读取 uint16 表表的大小由开头的 uint16 指出func(self*ClassReader)readUint16s()[]uint16{n:self.readUint16()// 先读表头表项数量s:make([]uint16,n)// 预分配切片fori:ranges{s[i]self.readUint16()// 逐个读取表项}returns}// readBytes 读取指定数量的字节func(self*ClassReader)readBytes(nuint32)[]byte{bytes:self.data[:n]// 取前 n 个字节self.dataself.data[n:]// 跳过returnbytes}亮点reslice 语法实现零索引这是本书代码里一个很 Go 味的设计——不用索引变量记录读到哪了而是用 reslice 直接砍掉已读部分// 传统做法维护一个索引typeClassReaderstruct{data[]byteindexint// ← 需要额外维护}func(r*ClassReader)readUint16()uint16{val:binary.BigEndian.Uint16(r.data[r.index:])r.index2// ← 每次都要手动推进returnval}// 本书做法reslice推荐typeClassReaderstruct{data[]byte// ← 不需要索引字段}func(self*ClassReader)readUint16()uint16{val:binary.BigEndian.Uint16(self.data)self.dataself.data[2:]// ← 直接把已读部分切掉returnval}【reslice 的工作原理】 初始data [CA FE BA BE 00 00 00 34 ...] └─ data 指向整个切片 readUint32() → 返回 0xCAFEBABE data data[4:] └─ data 现在指向 [00 00 00 34 ...] 前 4 个字节被切掉了 readUint16() → 返回 0x0000 data data[2:] └─ data 现在指向 [00 34 ...] 每次读取data 都从头部消耗掉相应字节 data[0] 永远是当前要读的位置 → 不需要索引对比项索引方案reslice 方案字段数2 个data index1 个data每次读取要手动index n自动data data[n:]越界风险可能忘记检查 indexdata 不够长会直接 panic代码简洁度一般清爽重点reslice 的底层实现只是移动了切片的内部指针不会复制数据性能完全没问题。这是 Go 切片的一个经典用法。binary.BigEndian大端序解析Go 标准库的encoding/binary包提供了现成的大端序解析importencoding/binarybinary.BigEndian.Uint16(data)// 从 data 前 2 字节解析 uint16大端序binary.BigEndian.Uint32(data)// 前 4 字节binary.BigEndian.Uint64(data)// 前 8 字节// 还有小端序版本class 文件用不到binary.LittleEndian.Uint16(data)底层实现其实就是移位组合但用标准库更清晰、更不容易出错// 等价于手写val:uint32(data[0])24|uint32(data[1])16|uint32(data[2])8|uint32(data[3])二、魔数0xCAFEBABEclass 文件的前 4 个字节永远是0xCAFEBABE。为什么是咖啡宝贝这是个程序员的冷幽默【魔数 0xCAFEBABE 的由来】 CA FE BA BE │ │ │ │ │ │ │ └─ BE │ │ └──── BA │ └─────── FE └────────── CA 连起来读CAFE BABE → Cafe Babe咖啡宝贝 ☕ Java 的 Logo 就是一杯咖啡 Java 语言的名字也来自爪哇咖啡 所以魔数用 CAFEBABE 是个一语双关的梗 据说这个魔数是 James GoslingJava 之父拍脑袋定的 理由是它看起来挺酷而且不容易和其它文件格式冲突冷知识Java 的魔数传统还延续到了其他地方——比如 Java 的.class是CAFEBABE而 Mach-O 可执行文件格式用的魔数之一是FEEDFACE喂脸。程序员的幽默感都长这样。魔数的作用魔数是文件格式的第一道防线作用说明快速识别读前 4 字节就知道这是不是 class 文件防止误加载避免把文本文件、图片等垃圾数据当 class 解析快速失败格式不对立即报错不用读完整个文件才发现很多文件格式都有魔数文件格式魔数十六进制ASCII 表示Java classCA FE BA BEÊþº¾PNG 图片89 50 4E 47‰PNGPDF 文档25 50 44 46%PDFZIP/JAR50 4B 03 04PK…GIF 图片47 49 46 38GIF8ELFLinux 可执行7F 45 4C 46.ELF代码实现// 魔数的固定值// 注意Go 里没有十六进制字面量直接写 0xCAFEBABE 作为 uint32 的问题// 直接用常量即可constMAGIC_NUMBER0xCAFEBABEfunc(self*ClassFile)readAndCheckMagic(reader*ClassReader){magic:reader.readUint32()ifmagic!0xCAFEBABE{panic(java.lang.ClassFormatError: magic!)}}重点注意错误信息用了java.lang.ClassFormatError——这是真正 JVM 规范里定义的异常类型。文件格式不对时HotSpot 抛的就是ClassFormatError。我们虽然是 Go 实现的但错误信息尽量向规范靠拢。三、版本号major 与 minor魔数后面紧跟 4 个字节2 字节次版本号 2 字节主版本号。func(self*ClassFile)readAndCheckVersion(reader*ClassReader){self.minorVersionreader.readUint16()// 次版本号self.majorVersionreader.readUint16()// 主版本号// 校验版本范围switchself.majorVersion{case45:return// JDK 1.0.2 ~ 1.1 支持的最低版本case46,47,48,49,50,51,52:ifself.minorVersion0{return// 这些主版本要求 minor 必须是 0}}panic(java.lang.UnsupportedClassVersionError!)}主版本号与 JDK 的对应关系【major_version 与 JDK 版本对照表】 major JDK 版本 发布时间 备注 ──────────────────────────────────────────────── 45 JDK 1.0.2 / 1.1 1996 最低支持版本 46 JDK 1.2 1998 47 JDK 1.3 2000 48 JDK 1.4 2002 49 JDK 5 (1.5) 2004 泛型、注解 50 JDK 6 2006 51 JDK 7 2011 52 JDK 8 ★ 2014 Lambda、Stream ← 我们用这个 53 JDK 9 2017 模块化 54 JDK 10 2018 55 JDK 11 (LTS) 2018 ... 61 JDK 17 (LTS) 2021 65 JDK 21 (LTS) 2023次版本号的规则从 JDK 1.2 开始minor_version 基本上永远是 0。规则是major 45不检查minor那时候 minor 还乱七八糟major ∈ [46, 52]minor必须是 0major 52按理说也要是 0但我们只支持到 52直接 panic版本兼容性高版本 JVM 跑低版本 class这是版本号最重要的实际意义【版本兼容性矩阵】 class 版本 (major) JDK 8 JVM JDK 11 JVM JDK 17 JVM JDK 21 JVM ───────────────────────────────────────────────────────────── 52 (8) ✅ ✅ ✅ ✅ 55 (11) ❌ ✅ ✅ ✅ 61 (17) ❌ ❌ ✅ ✅ 65 (21) ❌ ❌ ❌ ✅ 规则JVM 只能运行【不高于自己版本】的 class 文件 → 高版本 JVM 向下兼容 → 低版本 JVM 不能跑高版本 class重点这就是为什么用 JDK 21 编译、在 JDK 8 环境部署会报那个经典错误java.lang.UnsupportedClassVersionError: XXX : Unsupported major.minor version 65.0意思是你的 class 是版本 65JDK 21但我这个 JVM 只支持到 52JDK 8。解决办法用低版本 JDK 重新编译javac -source 8 -target 8 Xxx.javaMaven 里配maven.compiler.source8/maven.compiler.source升级运行时 JDK四、Parse()解析入口与错误处理有了读取器和校验方法来看统一的解析入口。Go 的 panic-recover 机制Go没有try-catch 异常机制只有panic/recover机制说明panic(v)抛出异常终止当前函数执行逐层向上冒泡recover()只能在defer函数中调用捕获 panic让程序恢复正常defer注册延迟执行的函数前面文章讲过Parse() 实现// ch03/classfile/class_file.go// Parse 把 []byte 解析成 ClassFile 结构体funcParse(classData[]byte)(cf*ClassFile,errerror){// 用 defer recover 捕获解析过程中的 panicdeferfunc(){ifr:recover();r!nil{// r 可能是 error也可能是 string 等任意类型varokboolerr,okr.(error)// 尝试类型断言成 errorif!ok{errfmt.Errorf(%v,r)// 不是 error 就包装一下}}}()cr:ClassReader{classData}cfClassFile{}cf.read(cr)return}这个设计的巧妙之处【panic-recover 的错误处理模式】 正常路径 Parse() → cf.read(cr) → 一路顺利 → 返回 (cf, nil) 异常路径 Parse() → cf.read(cr) → readAndCheckMagic() → 魔数不对 │ ▼ panic(ClassFormatError!) │ │ 冒泡 ▼ defer 注册的匿名函数被触发 │ ▼ r : recover() // 捕获 panic │ ▼ err 转换后的 error │ ▼ 返回 (cf, err) ✅ 不崩溃好处解析代码里可以到处写panic(...)简洁直接而不需要每个函数都返回 error、每层调用都检查。错误统一在入口处被捕获并转成标准 error。重点这是 Go 里处理深层嵌套调用的错误的常用手法——用 panic 做内部异常在 API 边界用 recover 转成 error。既保持了内部代码的简洁又对外提供了 Go 标准的错误处理方式。read() 方法顺序解析func(self*ClassFile)read(reader*ClassReader){self.readAndCheckMagic(reader)// 魔数self.readAndCheckVersion(reader)// 版本号self.constantPoolreadConstantPool(reader)// 常量池第 015-020 篇self.accessFlagsreader.readUint16()// 访问标志第 013 篇self.thisClassreader.readUint16()// 类索引第 014 篇self.superClassreader.readUint16()// 超类索引第 014 篇self.interfacesreader.readUint16s()// 接口索引表第 014 篇self.fieldsreadMembers(reader,self.constantPool)// 字段表第 014 篇self.methodsreadMembers(reader,self.constantPool)// 方法表第 014 篇self.attributesreadAttributes(reader,self.constantPool)// 属性表第 021-024 篇}顺序至关重要——class 文件是紧凑的字节流读错一个字节后面全乱套。这就是为什么 reslice 方案更安全永远从 data[0] 读不会读错位置。【解析顺序严格对应文件格式】 ┌──────────────────────────────────────┐ │ readAndCheckMagic() │ 4 字节 魔数 │ readAndCheckVersion() │ 4 字节 版本号 │ readConstantPool() │ 不定 常量池 │ accessFlags readUint16() │ 2 字节 访问标志 │ thisClass readUint16() │ 2 字节 类索引 │ superClass readUint16() │ 2 字节 超类索引 │ interfaces readUint16s() │ 不定 接口表 │ fields readMembers() │ 不定 字段表 │ methods readMembers() │ 不定 方法表 │ attributes readAttributes() │ 不定 属性表 └──────────────────────────────────────┘ ↓ 依次吃掉字节流顺序不能乱五、读完了怎么用Getter 方法Go 的访问控制很简单——首字母大写公开小写私有。ClassFile 的字段都是小写私有需要通过 Getter 方法暴露给其他包// Getter 方法把私有字段暴露出去func(self*ClassFile)MinorVersion()uint16{returnself.minorVersion}func(self*ClassFile)MajorVersion()uint16{returnself.majorVersion}func(self*ClassFile)ConstantPool()ConstantPool{returnself.constantPool}func(self*ClassFile)AccessFlags()uint16{returnself.accessFlags}func(self*ClassFile)Fields()[]*MemberInfo{returnself.fields}func(self*ClassFile)Methods()[]*MemberInfo{returnself.methods}// 便捷方法直接从常量池查出类名func(self*ClassFile)ClassName()string{returnself.constantPool.getClassName(self.thisClass)}func(self*ClassFile)SuperClassName()string{ifself.superClass0{returnself.constantPool.getClassName(self.superClass)}return// 只有 java.lang.Object 的 super_class 是 0}func(self*ClassFile)InterfaceNames()[]string{interfaceNames:make([]string,len(self.interfaces))fori,cpIndex:rangeself.interfaces{interfaceNames[i]self.constantPool.getClassName(cpIndex)}returninterfaceNames}几个便捷方法很实用方法返回说明ClassName()java/lang/Object本类名SuperClassName()java/lang/Object或父类名Object 没有父类返回空InterfaceNames()[]string所有接口名注意SuperClassName()里的if self.superClass 0——只有java.lang.Object的 super_class 是 0它没有父类。这是 class 文件格式的一个特例。六、测试把 class 文件读出来改写 ch03 的startJVM// ch03/main.gofuncstartJVM(cmd*Cmd){cp:classpath.Parse(cmd.XjreOption,cmd.cpOption)className:strings.Replace(cmd.class,.,/,-1)cf:loadClass(className,cp)fmt.Printf(class: %v\n,cmd.class)printClassInfo(cf)}funcloadClass(classNamestring,cp*classpath.Classpath)*classfile.ClassFile{classData,_,err:cp.ReadClass(className)iferr!nil{panic(err)}cf,err:classfile.Parse(classData)iferr!nil{panic(err)}returncf}funcprintClassInfo(cf*classfile.ClassFile){fmt.Printf(version: %v.%v\n,cf.MajorVersion(),cf.MinorVersion())fmt.Printf(constants count: %v\n,len(cf.ConstantPool()))fmt.Printf(access flags: 0x%x\n,cf.AccessFlags())fmt.Printf(this class: %v\n,cf.ClassName())fmt.Printf(super class: %v\n,cf.SuperClassName())fmt.Printf(interfaces: %v\n,cf.InterfaceNames())fmt.Printf(fields count: %v\n,len(cf.Fields()))fmt.Printf(methods count: %v\n,len(cf.Methods()))}运行结果ch03.exe-XjreC:\Java\jdk1.8.0_202\jrejava.lang.Stringclass: java.lang.String version: 52.0 constants count: 540 access flags: 0x31 this class: java/lang/String super class: java/lang/Object interfaces: [java/io/Serializable java/lang/Comparable java/lang/CharSequence] fields count: 5 methods count: 94成功了我们第一次读懂了一个真实的 Java 类版本 52.0JDK 8540 个常量访问标志 0x31本类java/lang/String父类java/lang/Object实现了 3 个接口Serializable、Comparable、CharSequence5 个字段、94 个方法用javap对照验证javap-vjava.lang.String|head-20# 或者对 rt.jar 里的类javap-v-cpC:\Java\jdk1.8.0_202\jre\lib\rt.jarjava.lang.String小知识java.lang.String实现了Serializable、Comparable、CharSequence三个接口这个从 class 文件里直接读出来了。而它有 94 个方法——这就是 String 类庞大的原因。本篇小结解析 class 文件的第一步完成ClassReader用 reslice 语法实现零索引顺序读取data data[n:]自动推进不需要维护偏移量大端序解析用binary.BigEndian.Uint16/32/64避免手写移位出错魔数0xCAFEBABEJava 的咖啡梗文件格式的第一道防线校验失败抛ClassFormatError版本号major 52 JDK 8规则是高版本 JVM 向下兼容反之不行低版本 JVM 跑高版本 class 会报UnsupportedClassVersionErrorpanic-recover内部代码用panic快速失败API 边界用defer recover转换成标准 error首次成功解析出java.lang.String的版本、父类、接口、字段数、方法数下一篇我们解析类访问标志access_flags——那个0x31到底是什么意思为什么 String 类的访问标志是0x31而不是0x01答案就藏在位运算里。上一篇【第11篇】class 文件结构总览——JVM 的通用语下一篇【第13篇】类访问标志——public/abstract/final 都在里面