上一篇【第10篇】Classpath 整合——让 JVM 找到所有 class 文件 下一篇【第12篇】魔数与版本号——class 文件的"身份证"
摘要
上一章我们找到了 class 文件,但读出来是一堆看不懂的数字:[202 254 186 190 0 0 0 52 0 143 …]。
这一篇开始,我们把这堆数字翻译成有意义的结构。class 文件是 JVM 的"通用语"——不管你用 Java、Kotlin 还是 Scala 写的代码,只要编译成符合规范的 class 文件,JVM 就能执行。它也是二进制格式设计的典范,学完之后你会理解为什么需要魔数、为什么常量池要单独抽出来。
本文先建立全局认知:class 文件的八大组成部分、大端序存储、u1/u2/u4 数据类型、表的组织方式,最后用十六进制直击一个真实的 class 文件。
一、class 文件到底是什么?
先纠正一个常见误解。
你可能以为"class 文件"就是指磁盘上的 .class 文件。不准确。 JVM 规范里的 class 文件,泛指任何格式符合规范的 class 数据:
【class 文件的来源(JVM 规范不限制)】
┌──────────────────────────────────────────────┐
│ class 数据可以从哪来? │
├──────────────────────────────────────────────┤
│ · 从文件系统读取 .class 文件 ← 我们实现的 │
│ · 从 JAR/ZIP 压缩包中提取 ← 我们实现的 │
│ · 通过网络下载(Applet、远程类加载) │
│ · 从数据库中加载 │
│ · 运行时动态生成(动态代理、ASM、CGLIB) │
│ · 从加密文件中解密得到(代码保护) │
└──────────────────────────────────────────────┘
只要字节流符合 class 文件格式,JVM 就能加载!
重点:JVM 规范严格规定了 class 文件的格式,但完全不限制 class 文件的来源。这种"格式严格、来源自由"的设计,正是 Java 生态能玩出这么多花样(热部署、字节码增强、动态代理)的根本原因。
为什么格式要严格规定?
因为"编写一次,处处运行"。如果每个 JVM 实现都有自己的格式,那编译后的代码就没法跨平台了。规范把格式钉死,才能保证:
- Windows 上编译的 class 文件,Linux 上的 JVM 也能跑
- Oracle JDK、OpenJDK、Azul Zing、我们的 jvmgo,都能读同一个 class 文件
二、基本数据类型:u1、u2、u4
class 文件的基本单位是字节,整个文件就是一个字节流。
为了描述格式,JVM 规范定义了三种数据类型:
| u1 | 1 字节无符号整数 | uint8 | 1 |
| u2 | 2 字节无符号整数 | uint16 | 2 |
| u4 | 4 字节无符号整数 | uint32 | 4 |
(JVM 规范没有定义 u8,但读 long/double 时需要读 8 字节,我们自己加了个 readUint64。)
大端序(Big-Endian)
多字节数据(u2、u4)在 class 文件中统一用大端序存储。
什么是大端序?简单说:高位字节在前(低地址),低位字节在后。
【大端序 vs 小端序】
假设有一个 u4 值:0xCAFEBABE
大端序 Big-Endian(class 文件用这个,也叫网络字节序)
┌────────┬────────┬────────┬────────┐
│ CA │ FE │ BA │ BE │ 高字节在前
└────────┴────────┴────────┴────────┘
偏移 0 偏移 1 偏移 2 偏移 3
最高位 最低位
小端序 Little-Endian(x86 CPU 内存存储用这个)
┌────────┬────────┬────────┬────────┐
│ BE │ BA │ FE │ CA │ 低字节在前
└────────┴────────┴────────┴────────┘
偏移 0 偏移 1 偏移 2 偏移 3
最低位 最高位
举例:一个 u2 值 0x0034(十进制 52):
大端序存储: [0x00, 0x34]
读取方式: 0x00 << 8 | 0x34 = 0x0034 = 52 ✅
如果用小端序错误解析:
0x34 << 8 | 0x00 = 0x3400 = 13312 ❌
重点:这是解析二进制时最容易踩的坑——字节序搞反,结果差之千里。好消息是 Go 标准库的 encoding/binary 包提供了 BigEndian.Uint16() / BigEndian.Uint32(),我们不用手动移位。
表(Table)的组织方式
class 文件里大量使用"表"来存储相同类型的多条数据。表的结构是:
【表的结构】
┌─────────────┬─────────┬─────────┬─────┬─────────┐
│ 表头 (n) │ 表项 1 │ 表项 2 │ … │ 表项 n │
│ u2 或 u4 │ │ │ │ │
└─────────────┴─────────┴─────────┴─────┴─────────┘
↑
表示后面跟着 n 个表项
表头通常是 u2(少数情况 u4)
比如接口索引表:
┌────────────┬────────┬────────┬────────┐
│ count = 3 │ 0x05 │ 0x0A │ 0x11 │
│ (u2) │ (u2) │ (u2) │ (u2) │
└────────────┴────────┴────────┴────────┘
接口数量 3 常量池索引(指向接口名)
读取逻辑:先读 u2 得到 n,再循环 n 次读取表项。
三、ClassFile 结构全貌
JVM 规范用类似 C 语言结构体的语法描述 class 文件格式。整个 class 文件就是一个 ClassFile 结构:
ClassFile {
u4 magic; // 魔数
u2 minor_version; // 次版本号
u2 major_version; // 主版本号
u2 constant_pool_count; // 常量池计数
cp_info constant_pool[constant_pool_count–1]; // 常量池
u2 access_flags; // 访问标志
u2 this_class; // 类索引
u2 super_class; // 超类索引
u2 interfaces_count; // 接口计数
u2 interfaces[interfaces_count]; // 接口索引表
u2 fields_count; // 字段计数
field_info fields[fields_count]; // 字段表
u2 methods_count; // 方法计数
method_info methods[methods_count]; // 方法表
u2 attributes_count; // 属性计数
attribute_info attributes[attributes_count]; // 属性表
}
对应到 Go 的结构体(我们的 ClassFile):
type ClassFile struct {
// magic uint32 // 魔数校验完就不用存了
minorVersion uint16 // 次版本号
majorVersion uint16 // 主版本号
constantPool ConstantPool // 常量池
accessFlags uint16 // 访问标志
thisClass uint16 // 类索引
superClass uint16 // 超类索引
interfaces []uint16 // 接口索引表
fields []*MemberInfo // 字段表
methods []*MemberInfo // 方法表
attributes []AttributeInfo // 属性表
}
细节:注意 magic 字段被注释掉了。因为魔数在解析时校验完就完事了,没必要占用结构体空间——这是个不错的设计习惯:只保留有用的信息。
图解:class 文件的八大部分
【class 文件结构全景图】
偏移 大小 内容 说明
────────────────────────────────────────────────────────────
0x00 4 字节 magic = 0xCAFEBABE ① 魔数:身份标识
0x04 2 字节 minor_version ② 次版本号
0x06 2 字节 major_version ② 主版本号(52 = JDK 8)
────────────────────────────────────────────────────────────
0x08 2 字节 constant_pool_count ③ 常量池计数
0x0A 不定 constant_pool[] ③ 常量池(重头戏!)
· 字面量:字符串、数字常量
· 符号引用:类名、字段名、方法名
────────────────────────────────────────────────────────────
… 2 字节 access_flags ④ 访问标志
(public/final/abstract/interface…)
… 2 字节 this_class ⑤ 类索引 → 常量池
… 2 字节 super_class ⑤ 超类索引 → 常量池
… 2 字节 interfaces_count ⑥ 接口计数
… 不定 interfaces[] ⑥ 接口索引表
────────────────────────────────────────────────────────────
… 2 字节 fields_count ⑦ 字段计数
… 不定 fields[] ⑦ 字段表
… 2 字节 methods_count ⑦ 方法计数
… 不定 methods[] ⑦ 方法表
────────────────────────────────────────────────────────────
… 2 字节 attributes_count ⑧ 属性计数
… 不定 attributes[] ⑧ 属性表
────────────────────────────────────────────────────────────
八大块可以归成四组:
| ① ② 身份信息 | 魔数、版本号 | 确认这是合法 class 文件,以及它的版本 |
| ③ 常量池 | 常量池 | 存放所有字面量和符号引用(整个文件最核心的部分) |
| ④ ⑤ ⑥ 类的元信息 | 访问标志、类索引、超类索引、接口表 | 描述类的访问属性和继承关系 |
| ⑦ ⑧ 类的成员 | 字段表、方法表、属性表 | 描述类的字段、方法和附加信息 |
四、为什么常量池是核心?
注意看上面那张图——常量池通常占据 class 文件 60% 以上的空间,而且后面几乎所有部分(类索引、字段表、方法表、属性表)都要引用它。
【常量池的中心地位】
┌──────────────────┐
│ 常量池 │
│ (Constant Pool) │
│ │
│ #1 Utf8 "Hello" │
│ #2 Class │
│ #3 NameAndType │
│ #4 Methodref │
│ #5 String │
│ … │
└────────▲─────────┘
│ 所有地方都来引用
┌───────────────────┼───────────────────┐
│ │ │
┌────┴────┐ ┌─────┴────┐ ┌──────┴─────┐
│ 类索引 │ │ 字段表 │ │ 方法表 │
│this_class│ │(字段名?) │ │ (方法名?) │
└─────────┘ └──────────┘ └────────────┘
指向 #2 指向 Utf8 常量 指向 Utf8 常量
常量池存两类东西:
| 字面量(Literal) | 各种常量值 | 字符串 "Hello"、整数 123456789、浮点数 3.14f |
| 符号引用(Symbolic Reference) | 类、字段、方法的"名字" | java/lang/Object、main、([Ljava/lang/String;)V |
重点:符号引用是"未解析"的引用——它只记录一个名字(字符串),不记录真实的内存地址。JVM 在类加载的"解析"阶段,才会把符号引用转换成直接引用(真实指针)。这个延迟绑定的设计,是 Java 动态性的基础。
五、用十六进制直击真实的 class 文件
光看结构太抽象,我们来"解剖"一个真实的 class 文件。
用 xxd(Linux/Mac)或十六进制编辑器(Windows)打开 HelloWorld.class:
xxd HelloWorld.class | head -20
00000000: cafe babe 0000 0034 0022 0a00 0600 1409 …….4."……
00000010: 0015 0016 0800 170a 0018 0019 0700 1a07 …………….
00000020: 001b 0100 063c 696e 6974 3e01 0003 2829 …..<init>…()
00000030: 5601 0004 436f 6465 0100 0f4c 696e 654e V…Code…LineN
00000040: 756d 6265 7254 6162 6c65 0100 124c 6f63 umberTable…Loc
00000050: 616c 5661 7269 6162 6c65 5461 626c 6501 alVariableTable.
…
逐字节解读:
【前 16 字节的解读】
字节序列:CA FE BA BE | 00 00 | 00 34 | 00 22 | 0A 00 06 00 14 …
└────┬────┘ └─┬──┘ └─┬──┘ └─┬──┘
│ │ │ │
magic minor major constant
0xCAFEBABE version version pool_count
0x0000 0x0034 0x0022
= 52 = 34
(JDK 8) (常量池
有33项)
详细拆解:
┌────────────┬──────────────────────────────────────────┐
│ CA FE BA BE│ 魔数,固定值 0xCAFEBABE │
├────────────┼──────────────────────────────────────────┤
│ 00 00 │ minor_version = 0 │
├────────────┼──────────────────────────────────────────┤
│ 00 34 │ major_version = 0x0034 = 52 → JDK 8 ✅ │
├────────────┼──────────────────────────────────────────┤
│ 00 22 │ constant_pool_count = 0x0022 = 34 │
│ │ 注意:实际常量有 34-1 = 33 项 │
├────────────┼──────────────────────────────────────────┤
│ 0A │ 第 1 个常量的 tag = 0x0A = 10 │
│ │ → CONSTANT_Methodref_info │
├────────────┼──────────────────────────────────────────┤
│ 00 06 │ class_index = 6 │
├────────────┼──────────────────────────────────────────┤
│ 00 14 │ name_and_type_index = 20 │
└────────────┴──────────────────────────────────────────┘
看到 cafe babe 了吗?这就是那个著名的魔数。
再看后面几行,那些 3c 696e 6974 3e 是什么?转成 ASCII:
3c 69 6e 69 74 3e → < i n i t > → "<init>"
56 → 0x56 = 86 → 这是 Utf8 常量的长度或 tag
43 6f 64 65 → C o d e → "Code"(属性名)
class 文件里的字符串就是这么存的——先一个 u2 表示长度,后面紧跟 UTF-8 编码的字节。这个我们后面解析常量池时会详细讲。
六、我们的解析路线图
整个第三模块(文章 011-024)会按这个顺序实现:
【class 文件解析路线图】
① 准备工作
└─ ClassReader:字节读取器(readUint8/16/32/64、readBytes)
↓
② 文件头(文章 012)
├─ magic:魔数校验
├─ minor_version / major_version:版本号校验
↓
③ 常量池(文章 015-020)★核心
├─ 14 种常量类型
├─ 数值型:Integer / Float / Long / Double
├─ 字符串型:Utf8 / String
└─ 符号引用型:Class / NameAndType / Fieldref / Methodref
/ InterfaceMethodref / MethodType / MethodHandle
/ InvokeDynamic
↓
④ 类信息(文章 013-014)
├─ access_flags:类访问标志
├─ this_class / super_class:类和超类索引
└─ interfaces[]:接口索引表
↓
⑤ 成员信息(文章 014)
├─ fields[]:字段表
└─ methods[]:方法表
↓
⑥ 属性表(文章 021-024)
├─ Code:方法的字节码 ★最重要
├─ ConstantValue:static final 常量值
├─ Exceptions:方法抛出的异常
├─ LineNumberTable:行号映射(调试用)
├─ LocalVariableTable:局部变量表(调试用)
├─ SourceFile:源文件名
└─ Deprecated / Synthetic:编译器标记
对应的 Go 文件:
| class_reader.go | ClassReader 字节读取器 |
| class_file.go | ClassFile 结构体 + Parse() |
| constant_pool.go | 常量池 |
| constant_info.go | 14 种常量的具体实现 |
| member_info.go | 字段表和方法表(MemberInfo) |
| attribute_info.go | 属性表 |
本篇小结
class 文件是 JVM 的"通用语",核心要点:
下一篇,我们开始动手:实现 ClassReader 字节读取器,并解析 class 文件的"身份证"——魔数和版本号。你会看到 0xCAFEBABE 这个著名数字背后的故事,以及 Go 的 reslice 语法如何让二进制解析变得优雅。
上一篇【第10篇】Classpath 整合——让 JVM 找到所有 class 文件 下一篇【第12篇】魔数与版本号——class 文件的"身份证"


