前言
平时写 Java,我们看到的是这样的代码:
public class UserService {
public int add(int a, int b) {
return a + b;
}
}
然后执行:
javac UserService.java
java UserService
程序就跑起来了。
但如果继续往下追,会出现一连串问题:
.java 为什么不能直接交给 JVM 执行?
.class 到底是什么?
JVM 为什么可以运行 Kotlin、Scala 等其他语言?
JVM 本身到底是一个软件、一个规范,还是一台“虚拟计算机”?
.class 文件开头为什么固定是 CA FE BA BE?
常量池为什么从 1 开始编号?
一个空类为什么编译后并不是真的“什么都没有”?
Java 方法最后为什么会变成 aload_0、invokespecial、return 这些东西?
JVM 又是怎样知道 0x2A 代表 aload_0 的?
这些问题其实是一条完整链路上的不同位置。
真正把它们串起来,会发现 JVM 的第一层核心不是 GC,也不是调优,而是一个更基础的问题:
JVM 到底接收什么,又怎样理解和执行它?
答案的中心就是:
ClassFile
Java、Kotlin、Scala 等源代码可以完全不同,但只要最终形成符合 JVM 规范的 ClassFile,后面的加载、验证、链接和执行就可以进入同一套 JVM 世界。
这一篇从 JVM 的整体定位开始,一直走到 ClassFile、Constant Pool、Code Attribute 和 Bytecode Instruction,最终亲手拆开一个最简单的 .class 文件。
一、JVM 到底是什么:先看 Java 的完整执行链
1. .java 到底经历了什么?
先从最熟悉的 Java 程序开始。
假设有:
Hello.java
执行:
javac Hello.java
得到:
Hello.class
然后:
java Hello
从宏观上看,可以先把执行链理解成:
这里已经出现了 JVM 的几个核心组件:
ClassLoader
Interpreter
JIT Compiler
Runtime / Execution Engine
但这张图还有一个非常关键的分界点:
源语言世界
↓
ClassFile
↓
JVM 世界
这条边界非常重要。
2. 为什么 JVM 不直接执行 Java 源码?
因为 JVM 规范定义的输入不是 Java 源代码语法,而是 ClassFile 格式和 JVM 指令集。
Java 语言本身有自己的语法:
if (a > b) {
return a;
}
这些语法先由 Java 编译器处理。
编译器会经历诸如:
词法分析
→ 语法分析
→ 语义分析
→ 生成 ClassFile / Bytecode
等阶段。
到了 JVM 这一侧,它不需要重新理解:
if 是什么 Java 语法?
public 是什么源码关键字?
for 循环是怎么写的?
它面对的是已经按照 ClassFile 规范组织好的结构,以及方法中的 JVM Bytecode。
因此:
Java Language 与 JVM 之间真正的“协议层”,就是 ClassFile。
3. 为什么 JVM 又能运行 Kotlin、Scala 等语言?
这就解释了 JVM 的“跨语言”能力。
假设我们设计一门完全不同的语言:
设 x = 10
设 y = 20
输出 x + y
这门语言根本不是 Java。
但如果我们自己写一个编译器,把它翻译成符合 JVM 规范的 ClassFile:
JVM 并不需要知道这种语言原来长什么样。
它只需要确认:
ClassFile 格式是否合法?
Constant Pool 是否符合规范?
Bytecode 是否可以验证?
方法描述符是否合法?
指令是否合法?
所以更准确的说法不是:
JVM 与 Java 完全没有关系。
而是:
JVM 的执行契约并不限定源语言必须是 Java;只要最终产物符合 JVM 的 ClassFile 和运行时规则,就可以进入 JVM。
这就是为什么 JVM 能成为一个跨语言平台。
4. JVM 是“规范”,HotSpot 才是“实现”
另一个特别容易混淆的问题是:
JVM = HotSpot 吗?
不是。
更准确的关系类似:
interface
↓
implementation
JVM Specification 规定:
ClassFile 应该长什么样
有哪些数据类型和运行时区域
类怎样加载、链接、初始化
JVM 指令是什么
每条指令应该有什么语义
异常应该怎么处理
...
而真正把这些规范做成可运行软件的是具体 JVM Implementation。
现在最常见的实现之一是 OpenJDK 中的 HotSpot。
历史上还出现过很多不同 JVM,包括 JRockit、J9、Microsoft VM、TaobaoVM、LiquidVM、Azul Zing 等。
这些名字本身不是这一篇最重要的内容,真正重要的是它们证明了一件事:
JVM 不是某一个唯一程序,而是一套可以被不同厂商实现的虚拟机规范。
其中 IBM J9 后来进入 Eclipse 基金会体系,今天对应 Eclipse OpenJ9;Azul 的 Zing 今天仍作为 Azul Prime 中的高性能 JVM 产品线存在。
这里也顺便纠正一个很容易混在一起的说法:
Java 收费
= HotSpot 收费
这种说法过于粗糙。
应该分成至少四层:
Java Language
JVM Specification
HotSpot VM Implementation
某个厂商发布的 JDK Distribution 与 License
HotSpot 本身存在于 OpenJDK 开源代码中,而 Oracle JDK 等具体发行版的商业授权规则属于发行版和版本层面的许可问题,不能简单等价为“JVM 收费”。
5. 为什么 HotSpot 要“解释 + JIT”混合执行?
执行:
java -version
经常能看到:
mixed mode
意思是 HotSpot 并不是只有一种执行方式。
它可以:
Interpreter
+
JIT Compiler
一起工作。
最容易出现的误解是:
如果所有代码都 JIT 编译成机器码,Java 就会失去跨平台能力,所以只能编译热点代码。
这个因果关系并不准确。
Java 的跨平台边界仍然是:
ClassFile / Bytecode
同一份 ClassFile 到 Linux x86-64 上,可以由 Linux x86-64 的 JVM 在运行时生成对应机器码;到了 macOS ARM64,又由对应 JVM 生成 ARM64 机器码。
所以 JIT 生成平台相关 Machine Code 并不会破坏 ClassFile 的跨平台性。
HotSpot 采用自适应优化真正考虑的是:
启动速度
编译成本
代码缓存占用
运行时 Profile
热点路径
优化收益
如果一个方法只运行一次,却先花大量时间做高级优化编译,可能得不偿失。
于是更合理的模式是:
所以 HotSpot 这个名字本身就很形象:
先找到真正的 Hot Spot,再把主要优化资源放到热点路径上。
6. JLS 和 JVMS 分别管什么?
学习 JVM 时经常会遇到两份规范:
JLS
JVMS
不要把它们混在一起。
JLS
Java Language Specification
它更关心 Java Language 本身:
语法
类型系统
表达式
类和接口
泛型
线程与内存模型
...
JVMS
Java Virtual Machine Specification
它更关心 JVM 这一层:
ClassFile Format
Runtime Data Areas
Loading / Linking / Initialization
Bytecode Instruction Set
Verification
...
这一篇后面真正需要频繁查的是:
JVMS Chapter 4:Class File Format
JVMS Chapter 6:Instruction Set
一个很重要的学习习惯是:
遇到“这个字节到底代表什么”“这条指令到底做什么”时,优先回到 JVMS,而不是背二手结论。
7. JDK、JRE、JVM:旧的套娃图今天还能不能照搬?
很多老教材会画:
JDK
└── JRE
└── JVM
如果是为了建立 Java 8 时代的概念直觉,这张图很好用:
JVM → 执行虚拟机
JRE → JVM + 运行 Java 程序所需的类库与运行环境
JDK → Runtime + javac、javadoc、javap 等开发工具
但到了现代 JDK,不能再机械理解成:
JDK 安装目录里一定存在一个 jre/ 子目录
JDK 9 开始运行时镜像进入模块化时代;JDK 11 及以后已经不再提供旧式独立 JRE Image 布局。
今天还可以使用 jlink 按模块生成自定义 Runtime Image。
所以现在更推荐记住“职责关系”,而不是死背“目录包含关系”:
二、JVM 真正认的是什么:ClassFile 才是执行契约
前面已经得到一个核心结论:
Java Source
不是 JVM 最终的执行契约
ClassFile
才是 JVM 与上层语言之间的重要二进制契约
那么下一个问题自然就是:
ClassFile 到底是什么?
1. .class 不是“反编译后的 Java 代码”
在 IDEA 里双击:
Minimal.class
IDE 往往会显示一段很像 Java 的代码。
这容易产生错觉:
.class里面是不是其实还保存着 Java 源码?
不是。
IDE 展示的是 Decompiled View。
真正的 ClassFile 本质上是一串二进制 Byte Stream。
你看到:
CA FE BA BE 00 00 00 34 ...
才更接近磁盘中真实存储的结构。
2. 为什么从“最小类”开始研究最好?
如果一上来就拆 Spring、Tomcat 或某个几十个方法的业务类,Constant Pool、Fields、Methods、Attributes 会立刻膨胀。
最好的办法是:
public class Minimal {
}
看上去什么都没有。
正因为简单,才最适合回答:
编译器最少会生成哪些内容?
ClassFile 最少有哪些结构?
为什么空类还有一个构造方法?
Object.<init> 从哪里来的?
然后逐步加东西:
空类
↓
加一个 int 字段
↓
加一个 String 字段
↓
实现一个接口
↓
加一个普通方法
↓
自己写构造方法
每改一次源码,就重新 javac 和 javap。
这种“差分观察”比一次背完整 ClassFile 结构有效得多。
3. ClassFile 的字节到底按什么规则解释?
JVMS 对 ClassFile 的描述很严格。
ClassFile 是:
8-bit byte stream
多个字节组成较大数值时使用 Big-endian:
高位 Byte 在前
低位 Byte 在后
例如:
00 34
解释成无符号 16-bit 整数:
0x0034 = 52
这里需要纠正一个常见的旧笔记写法:
ClassFile 有 u1、u2、u4、u8 四种基本类型
按照当前 JVMS Chapter 4 的正式 ClassFile 记法,定义的是:
u1 → 1 Byte unsigned quantity
u2 → 2 Byte unsigned quantity
u4 → 4 Byte unsigned quantity
没有统一定义一个 u8 作为 ClassFile 顶层基本记法。
那 long 和 double 的 8 Byte 怎么存?
以 CONSTANT_Long_info 为例,它是:
u1 tag
u4 high_bytes
u4 low_bytes
也就是两个 u4 拼成 64-bit 数据。
这正是为什么读规范比只记课堂缩写更可靠。
4. ClassFile 顶层到底有什么?
把整个 ClassFile 先看成一个有固定顺序的二进制协议:
| 顺序 | 字段 | 作用 |
|---|---|---|
| 1 | magic | 文件魔数 |
| 2 | minor_version | 次版本号 |
| 3 | major_version | 主版本号 |
| 4 | constant_pool_count | 常量池计数 |
| 5 | constant_pool | 常量池 |
| 6 | access_flags | 当前 Class / Interface 属性 |
| 7 | this_class | 当前类 |
| 8 | super_class | 父类 |
| 9 | interfaces_count | 直接父接口数量 |
| 10 | interfaces | 接口索引表 |
| 11 | fields_count | 字段数量 |
| 12 | fields | 字段表 |
| 13 | methods_count | 方法数量 |
| 14 | methods | 方法表 |
| 15 | attributes_count | 顶层属性数量 |
| 16 | attributes | 顶层属性表 |
顺序不能随便换。
最值得建立的直觉是:
ClassFile 不是“用分隔符切出来的文本文件”,而是一份严格按照字段长度、计数值、Tag 和索引解释的紧凑二进制协议。
三、从前 10 个字节开始读懂一个 .class
现在不再只看图,直接做实验。
本文实验环境:
OpenJDK 21.0.11
Linux x86_64
为了让生成结果与 Java 8 时代最经典的教材示例一致,部分实验使用:
javac --release 8
1. 实验代码
创建:
public class Minimal {
}
编译:
javac --release 8 -g Minimal.java
为什么加 -g?
因为它会保留调试信息,后面 javap 更容易看到 LineNumberTable、LocalVariableTable 等内容。
然后查看前 16 Byte:
od -An -tx1 -N16 Minimal.class
本文实际得到:
ca fe ba be 00 00 00 34 00 10 0a 00 02 00 03 07
不要被这一串十六进制吓到。
从左到右拆即可。
2. Magic:为什么一定是 CAFEBABE?
前 4 Byte:
CA FE BA BE
合起来就是:
0xCAFEBABE
这就是 ClassFile Magic Number。
它的用途与 PNG、ZIP 等格式的文件签名类似:
先确认“你到底是什么文件”
JVM 加载一个候选 ClassFile 时,首先就必须检查这个基本格式。
所以:
CA FE BA BE
不是“某条 JVM 指令”,而是文件格式身份标识。
3. Version:00 00 00 34 到底是什么意思?
紧接 Magic 的 4 Byte:
00 00 00 34
拆成:
minor_version = 00 00 = 0
major_version = 00 34 = 52
而:
0x34
= 3 × 16 + 4
= 52
ClassFile Major Version 52 对应 Java 8。
这也解释了常见错误:
Unsupported class file major version
本质就是:
当前 JVM 不支持这份 ClassFile 所声明的版本。
再做一个对比。
使用同一个 OpenJDK 21 编译器,默认编译:
javac Minimal.java
本文实际看到:
ca fe ba be 00 00 00 41
0x41 = 65,即 Java 21 的 ClassFile Major Version。
而:
javac --release 8 Minimal.java
则得到:
ca fe ba be 00 00 00 34
也就是 Major Version 52。
截至 Java SE 26,ClassFile Major Version 已经到 70。
因此 Version 字段其实就是 ClassFile 与 JVM 之间的兼容性门槛之一。
4. Constant Pool Count:为什么 00 10 不是 16 个有效常量?
继续往后两个 Byte:
00 10
也就是:
constant_pool_count = 16
但是有效 Constant Pool Entry 不是 16 个,而是:
15 个
因为 Constant Pool 的有效索引从:
#1
开始。
#0 被保留,用来表达“不引用任何 Constant Pool Entry”这样的特殊语义。
所以:
有效索引范围
= 1 ~ constant_pool_count - 1
这里还有一个特殊历史设计:
CONSTANT_Long
CONSTANT_Double
会占用两个 Constant Pool Index,其中后一格不可使用。
所以以后看到 Constant Pool 索引偶尔“跳号”,不要立刻认为 ClassFile 损坏。
四、Constant Pool:为什么它是 ClassFile 的核心?
如果只看 ClassFile 顶层结构,Constant Pool 只是其中一段。
但真正开始分析会发现:
this_class 在引用 Constant Pool
super_class 在引用 Constant Pool
field name 在引用 Constant Pool
method name 在引用 Constant Pool
method descriptor 在引用 Constant Pool
Code Attribute 名称也在引用 Constant Pool
Bytecode 调方法、访问字段仍然在引用 Constant Pool
整个 ClassFile 像一张大量节点通过索引连接起来的图。
1. 为什么不把类名、方法名直接重复写进去?
假设一个类里十几个地方都要引用:
java/lang/Object
如果每次都把整个字符串重新存一遍:
Method A → "java/lang/Object"
Method B → "java/lang/Object"
Field C → "java/lang/Object"
...
会重复占空间,而且结构难统一。
Constant Pool 的思路是:
信息集中定义
+
其他地方通过 Index 引用
这很像协议中的 ID 表,也像数据库中规范化后的外键关系。
2. Constant Pool Entry 为什么必须先读 Tag?
Constant Pool 不是一个“每项都固定 5 Byte”的数组。
每一项第一 Byte 都是:
tag
例如:
0x01 → CONSTANT_Utf8
0x07 → CONSTANT_Class
0x0A → CONSTANT_Methodref
读到 Tag 后,才能知道后面该读多少 Byte,以及这些 Byte 应该按什么结构解释。
所以 Variable-length Constant Pool 不能简单通过:
index × 固定长度
直接定位。
3. 现在到底有多少种 Constant Pool 类型?
老资料经常只讲到 Tag 18。
但 ClassFile Format 后来继续演化。
以 Java SE 26 JVMS 为准,目前规范列出了 17 种 Constant Kind:
| Tag | Constant Kind |
|---|---|
| 1 | CONSTANT_Utf8 |
| 3 | CONSTANT_Integer |
| 4 | CONSTANT_Float |
| 5 | CONSTANT_Long |
| 6 | CONSTANT_Double |
| 7 | CONSTANT_Class |
| 8 | CONSTANT_String |
| 9 | CONSTANT_Fieldref |
| 10 | CONSTANT_Methodref |
| 11 | CONSTANT_InterfaceMethodref |
| 12 | CONSTANT_NameAndType |
| 15 | CONSTANT_MethodHandle |
| 16 | CONSTANT_MethodType |
| 17 | CONSTANT_Dynamic |
| 18 | CONSTANT_InvokeDynamic |
| 19 | CONSTANT_Module |
| 20 | CONSTANT_Package |
注意:
Tag 并不是 1、2、3、4……连续编号
比如没有 Tag 2。
它背后还能看到 JVM 的历史演化:
早期 ClassFile
→ Utf8 / Number / Class / String / Fieldref / Methodref 等基础结构
Java 7
→ MethodHandle / MethodType / InvokeDynamic
Java 9
→ Module / Package
Java 11
→ Dynamic Constant
所以 ClassFile 并不是几十年前设计完以后就完全没变,而是在保持兼容性的同时继续扩展。
4. 实际拆一个 CONSTANT_Methodref
前面的 Minimal.class 执行:
javap -v -c -p Minimal.class
本文实际得到 Constant Pool 开头:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
这几项正好展示了 Constant Pool 的“索引链”。
先看:
#1 Methodref
它并没有直接把所有信息写死,而是保存:
class_index
name_and_type_index
关系大致是:
最终组合出来:
java/lang/Object.<init>:()V
也就是:
Object 的无参构造方法
如果直接看二进制,一个典型 CONSTANT_Methodref_info 可以长成:
0A 00 02 00 03
解释方式是:
0A → Tag = Methodref
00 02 → class_index = #2
00 03 → name_and_type_index = #3
这里真正应该学会的不是背:
0A 后面固定是什么
而是:
读 Tag → 查规范 → 按对应结构继续读 → 再沿 Index 跳到其他 Constant Pool Entry。
这才是以后面对任意 ClassFile 都能复用的方法。
5. CONSTANT_Utf8 真的是普通 UTF-8 吗?
名称叫:
CONSTANT_Utf8
很多资料于是直接写:
ClassFile 里面的字符串统一是 UTF-8。
这个说法不够精确。
JVMS 对 CONSTANT_Utf8_info 使用的是:
Modified UTF-8
也就是 Java ClassFile 规定的一种 Modified UTF-8 Encoding,而不是完全等同于标准 UTF-8。
对普通 ASCII 类名:
java/lang/Object
你几乎感受不到差别。
但到了:
NUL
Supplementary Character
编码规则就存在差异。
因此严谨说法应该是:
ClassFile 中
CONSTANT_Utf8_info的字符串内容采用 Modified UTF-8。
五、类、父类、接口、字段和方法怎么挂到常量池上?
Constant Pool 读明白以后,ClassFile 后面很多字段一下就简单了。
因为很多结构并不重复保存字符串,而是保存:
u2 index
1. Access Flags:为什么一个 0x0021 能代表多个属性?
access_flags 是 Bit Mask。
例如一个普通 Public Class 常见:
0x0021
拆开:
0x0001 = ACC_PUBLIC
0x0020 = ACC_SUPER
按位或:
0x0001 | 0x0020 = 0x0021
也就是说一个 u2 可以把多个 Boolean Property 压在不同 Bit 上。
bit = 1 → 具备某属性
bit = 0 → 不具备某属性
这里要注意:
Class Access Flags
Field Access Flags
Method Access Flags
是不同上下文。
例如 ACC_PRIVATE 可以用于字段或方法,但普通顶层 Class 本身并不是靠 ACC_PRIVATE 表示“private class”。
所以不要把所有 ACC_* 常量混成一张无上下文表来背。
2. this_class 和 super_class 为什么只是两个数字?
假设 javap -v 显示:
this_class: #7 // Minimal
super_class: #2 // java/lang/Object
这两个字段本身只是 Constant Pool Index。
所以 ClassFile 的设计非常统一:
能引用就尽量引用
不重复塞字符串
3. Interfaces 也是同一套逻辑
如果:
public class Demo implements Runnable, AutoCloseable {
// ...
}
ClassFile 不会把两个接口名称直接塞在 interfaces[] 里。
它会存:
interfaces_count = 2
interfaces[0] = 某个 Constant Pool Index
interfaces[1] = 某个 Constant Pool Index
而对应 Entry 必须再指向 CONSTANT_Class_info。
因此可以自己做一个非常好的差分实验:
先编译不 implements 的版本
↓
记录 Constant Pool 与 interfaces_count
↓
增加两个接口
↓
重新编译
↓
对比新增的 Class / Utf8 与 interfaces[]
4. Field 是怎么描述的?
一个字段不只是名字:
int i;
在 ClassFile 中,field_info 需要表达:
Access Flags
Name
Descriptor
Attributes
例如:
name_index → Utf8 "i"
descriptor_index → Utf8 "I"
其中:
I = int
这就进入了 Descriptor。
5. Method Descriptor 到底怎么看?
Method Descriptor 的核心格式:
(参数类型...)返回值类型
常见基本类型:
| Descriptor | Java Type |
|---|---|
B | byte |
C | char |
D | double |
F | float |
I | int |
J | long |
S | short |
Z | boolean |
V | void,仅返回值可用 |
Reference Type:
Ljava/lang/String;
Array:
[I → int[]
[[I → int[][]
做一个实际实验:
public class DescriptorDemo {
public long calc(int[] values, int count, long offset) {
return offset;
}
}
执行:
javac --release 8 DescriptorDemo.java
javap -s -p DescriptorDemo.class
本文实际得到:
public long calc(int[], int, long);
descriptor: ([IIJ)J
逐段拆:
(
[I → int[]
I → int
J → long
)
J → return long
所以:
([IIJ)J
并不神秘,它只是 JVM 的类型编码语法。
六、Code Attribute:Java 方法真正执行的内容在哪里?
现在终于来到最关键的一层。
一个 method_info 大致需要描述:
这个方法有什么 Flag?
叫什么名字?
Descriptor 是什么?
还有哪些 Attribute?
对一个普通有方法体的 Java 方法来说,最重要的 Attribute 就是:
Code
1. Code Attribute 里面有什么?
Code Attribute 不只是几条 Bytecode。
它还要描述运行这段 Bytecode 所需要的辅助信息,例如:
max_stack
max_locals
code_length
code[]
exception_table
nested attributes
关系可以画成:
其中常见 Nested Attributes 包括:
LineNumberTable
LocalVariableTable
StackMapTable
...
注意一个容易混淆的地方:
Local Variables of JVM Frame
和:
LocalVariableTable Attribute
不是同一个概念。
前者是 JVM Frame 的运行时结构;后者主要是 ClassFile 中可供调试器使用的 Debug Information Attribute,而且可以因为编译参数不同而不存在。
2. 一个“空构造方法”为什么有 5 Byte?
继续看最小类:
public class Minimal {
}
源码里甚至没有自己写 Constructor。
但 Java Compiler 会生成默认无参构造方法。
执行:
javap -v -c -p Minimal.class
本文实际得到:
public Minimal();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1
4: return
真正的 Bytecode Sequence:
2A B7 00 01 B1
正好 5 Byte。
一个看起来“什么都没写”的 Class,底层已经存在真实执行逻辑。
3. aload_0 到底加载什么?
aload_0:
Opcode = 0x2A
它的语义是:
从当前 Frame 的 Local Variable Array 中取索引 0 位置的 Reference,并压入 Operand Stack。
在 Instance Method 中,Local Variable Slot 0 通常就是:
this
所以构造方法开始:
aload_0
就是先把当前对象 Reference 压到 Operand Stack,为后面的实例方法调用准备 objectref。
再次强调:
aload_0操作的是当前 Frame 的 Local Variable Array,不是去读取 ClassFile 的LocalVariableTableDebug Attribute。
这两个名字很像,但层级完全不同。
4. invokespecial #1 为什么要带两个 Byte 参数?
invokespecial:
Opcode = 0xB7
后面的:
00 01
组成一个 u2 Constant Pool Index:
#1
前面已经看到:
#1 = Methodref #2.#3
= java/lang/Object.<init>:()V
因此:
B7 00 01
表达的就是:
按照 #1 这个符号引用执行 invokespecial
在这个具体 Constructor 中,目标就是:
Object.<init>()V
5. 为什么子类构造前一定会出现父类构造链?
Java Constructor 有一条重要规则:
构造当前类之前
先完成父类构造链
如果源码没有显式写:
super();
编译器会在满足语言规则的情况下插入隐式父类构造调用。
所以:
public class Minimal {
}
最终并不是:
什么都不做 → return
而是:
this 入栈
→ 调 Object.<init>
→ return
这就是读 Bytecode 的价值:
很多 Java Source 中“看不见”的语言规则,会在 ClassFile 里变成明确的结构和指令。
6. return 为什么是 B1?
return:
Opcode = 0xB1
用于:
void 方法正常返回
JVM 的执行模型就是读取 Opcode,然后依据 JVMS 中定义的 Instruction Semantics 执行。
所以:
2A
并不是 CPU x86 的机器指令。
它是 JVM Bytecode Opcode。
最终 HotSpot Interpreter 或 JIT 再把 JVM 这一层落实到真正平台相关的机器执行。
七、不要只背结构:用“增量实验”看 ClassFile 怎么变化
ClassFile 最有效的学习方式不是一次性背完所有字段,而是:
一次只给 Java Source 增加一个特征,然后比较编译产物发生了什么变化。
1. 第一步:只有一个空类
public class Minimal {
}
本文使用:
javac --release 8 -g Minimal.java
javap -v -c -p Minimal.class
实际输出的关键信息:
minor version: 0
major version: 52
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
interfaces: 0, fields: 0, methods: 1, attributes: 1
注意:
methods: 1
虽然源代码一个方法都没写。
原因就是默认 Constructor。
Constant Pool 实际有:
#1 ~ #15
包括:
Object.<init>
Minimal
Code
LineNumberTable
LocalVariableTable
this
SourceFile
Minimal.java
等信息。
这已经证明:
“Java Source 很简单”不代表“ClassFile 就只保存源码里显式写出的字符”。
2. 第二步:增加 int i = 888
改成:
public class FieldDemo {
int i = 888;
}
执行:
javac --release 8 -g FieldDemo.java
javap -v -c -p FieldDemo.class
本文实际看到 Constant Pool 新增了:
#7 = Fieldref ... FieldDemo.i:I
#9 = NameAndType ... i:I
#11 = Utf8 i
#12 = Utf8 I
Constructor 也从:
aload_0
invokespecial
return
变成:
0: aload_0
1: invokespecial #1
4: aload_0
5: sipush 888
8: putfield #7
11: return
这条执行链非常有价值:
从这里可以直接看到:
父类初始化
发生在
当前类实例字段初始化代码之前
3. 第三步:继续加 String、Interface、Method
接下来建议自己继续:
public class Demo implements Runnable {
int i = 888;
String name = "JVM";
public void run() {
System.out.println(name);
}
}
每加一个特征,只观察几件事:
Constant Pool 增加了什么?
interfaces_count 怎么变?
fields_count 怎么变?
methods_count 怎么变?
Code Attribute 多了哪些 Opcode?
Descriptor 怎么变?
不要一开始就试图把几十种 Attribute 和所有 Bytecode 指令背下来。
学习顺序应该是:
4. javap 到底怎么用?
最实用的几组参数:
javap -p Demo.class
显示所有成员,包括 Private Member。
javap -c Demo.class
反汇编 Method Bytecode。
javap -v Demo.class
显示更完整的 ClassFile 信息,包括:
Version
Flags
Constant Pool
Methods
Attributes
Bytecode
...
日常分析可以直接组合:
javap -v -c -p Demo.class
如果想看原始十六进制,再配合:
od -An -tx1 -v Demo.class
IDEA 里也可以使用内置 Bytecode Viewer;如果希望用结构化树形方式观察 Constant Pool、Methods、Attributes,也可以使用 jclasslib Bytecode Viewer。
最重要的不是工具名称,而是形成这样的验证闭环:
Java Source
↓
javac
↓
Raw ClassFile Hex
↓
javap / jclasslib
↓
JVMS
↓
相互验证
八、JVM 为什么叫“基于栈的虚拟机”?
分析 aload_0 时已经出现两个结构:
Local Variable Array
Operand Stack
这正是理解 JVM Bytecode 的关键。
1. 一个方法执行时,不只是“一串指令”
Method 被调用时会创建对应 Frame。
先建立第一层直觉:
例如:
aload_0
做的是:
Local Variables
↓
取 Slot 0 的 reference
↓
Operand Stack
后续指令再从 Operand Stack 取操作数或压入新值。
因此大量 JVM Bytecode 的语义都能写成:
Before Stack
→ Instruction
→ After Stack
2. JVM 与 x86 Register Machine 有什么区别?
可以先粗略对比:
JVM Bytecode
→ Stack-oriented Instruction Set
x86 / ARM Machine Code
→ Register-oriented Machine Instruction Set
Stack Machine 的一个优势是 Bytecode 更紧凑、抽象层更统一,不需要在 ClassFile 中绑定具体硬件寄存器名称。
但不要继续推出:
JVM 基于栈,所以每一步都必须真的去 RAM 访问内存,因此一定比寄存器机器慢。
这个说法太机械。
因为真正运行到 HotSpot JIT 后,JIT 会进行:
Register Allocation
Instruction Selection
Inlining
Escape Analysis
Dead Code Elimination
...
最终生成 Native Machine Code 时,热点计算完全可能大量使用真实 CPU Registers。
所以:
“JVM 是基于栈的”主要描述 JVM Bytecode Abstract Machine,而不是说最终 CPU 执行时所有中间值都必须老老实实存放在物理内存中的某个 Java 栈结构里。
九、从 Bytecode 谈原子性:最容易混在一起的三个层级
分析 JVM 指令时,很容易顺手问:
一条 JVM Bytecode 是不是一个不可分割的 Atomic Operation?
这个问题非常好,但也是最容易把几套概念混成一套的地方。
必须先拆成三个层级:
1. lock/read/load/use/assign/store/write/unlock 不是“8 条 JVM Bytecode”
老的 Java Memory Model 教材经常会列:
lock
read
load
use
assign
store
write
unlock
然后把它们简称成“JVM 的 8 个原子操作”。
如果把这句话理解成:
JVM Instruction Set 中只有这 8 个 Opcode 是 Atomic Bytecode。
就是错误的。
它们描述的是早期 JMM 教学模型中对 Shared Variable / Main Memory / Working Memory 的抽象动作,不是 JVMS Chapter 6 中那种:
aload_0
getfield
putfield
invokevirtual
return
Bytecode Opcode。
现代 JLS Chapter 17 直接从 Inter-thread Actions、Synchronization Actions、Happens-before 等概念定义内存模型。
因此以后看到“8 个原子操作”时,第一反应应该是:
这在讲 JMM 抽象动作?
还是 JVM Bytecode?
还是 CPU Atomic Instruction?
先确认层级,再讨论 Atomicity。
2. long / double 的原子性到底怎么说?
Java Language Memory Model 有一个很特殊的历史规则。
对 Shared Variable 来说:
volatile long / double
→ read / write 必须 atomic
Reference Read / Write 也要求 Atomic。
而对 Non-volatile long / double,Java 规范仍然允许某些 JVM Implementation 把一次 64-bit Write 视为两个 32-bit Write,虽然实现被鼓励尽量不要这样做。
所以:
volatile long value;
这里的 volatile 确实包含对单次 Read / Write Atomicity 的规范保证。
但:
value++;
仍然不是因此就自动 Atomic。
因为它是:
Read
→ Calculate
→ Write
组成的 Read-Modify-Write Compound Operation。
3. pop2 / dup2 为什么不能拿来证明线程原子性?
long 和 double 在 JVM Frame 的 Local Variable / Operand Stack 语义中属于 Category 2 Value。
因此 JVM 提供:
pop2
dup2
等适合 Category 2 Value 的 Stack Manipulation Instruction。
但这个规则解决的是:
Operand Stack Type / Slot Semantics
Bytecode Verification
不是直接证明:
多线程看到的 long/double Read/Write 一定 Atomic
线程之间的 Atomicity 保证来自 Java Memory Model;具体如何落到 Machine Instruction,是 JVM Implementation 和 Hardware 的工作。
这是三个不同层面,不能相互替代。
4. 为什么 i++ 仍然不是 Atomic?
假设:
count++;
从 Java Source 看只有一行。
但它的语义是:
读取 count
↓
+1
↓
写回 count
即使某种场景下 Bytecode 出现单条 iinc,也不能据此推出多个线程共享同一个变量时整个 Java 语义就具备 Atomic Read-Modify-Write Guarantee。
判断并发安全不能使用:
“源码只有一行”
也不能只使用:
“javap 看上去只有一条 Bytecode”
而要看 Java Memory Model 与具体 Synchronization Semantics。
5. MESI 也不等于 Atomic RMW
还有一个常见跨层误区:
CPU 有 MESI
→ 所有修改天然 Atomic
不对。
Cache Coherence 主要解决:
多个 Cache 如何维持同一 Cache Line 的一致性
而:
Atomic Read-Modify-Write
还需要具体 CPU Instruction 和 Memory Ordering 语义保证。
比如 x86 上常见的:
lock cmpxchg
属于更具体的 Atomic Instruction 机制。
因此原子性问题永远先问:
你说的是哪一层?
十、把整条链路重新串起来
现在回到最开始的问题:
Java 程序到底是怎么变成 JVM 可以执行的东西?
完整链路可以重新画成:
再拿最小类走一遍。
源代码:
public class Minimal {
}
编译后,前几个 Byte 已经告诉 JVM:
CA FE BA BE
→ 我是 ClassFile
00 00 00 34
→ ClassFile Version = 52.0
00 10
→ Constant Pool Count = 16
Constant Pool 再告诉 JVM:
#1
→ Object.<init>:()V
Method Table 再告诉 JVM:
Minimal.<init>:()V
它的 Code Attribute 中是:
2A B7 00 01 B1
JVM 再按照 Instruction Set 解释:
2A
→ aload_0
→ this 压入 Operand Stack
B7 00 01
→ invokespecial #1
→ 调用 Object.<init>()V
B1
→ return
于是一个看起来完全空白的 Java Class,在 JVM 眼里已经是一份结构非常明确的二进制程序。
十一、总结
这一篇最值得记住的不是:
CAFEBABE
0x2A
0xB7
0xB1
这些数字本身。
真正应该建立的是 JVM 的层级关系。
第一层:
Java 不是 JVM 唯一的上层语言
因为真正连接编译器和 JVM 的是:
ClassFile Contract
第二层:
JVM 是 Specification
HotSpot / OpenJ9 等是 Implementation
第三层:
ClassFile 不是源码
而是一份严格定义的 Binary Format
它从:
Magic
Version
Constant Pool
Access Flags
Class / Interface
Fields
Methods
Attributes
按固定顺序组织。
第四层:
Constant Pool 是整份 ClassFile 的符号中心
Class、Field、Method、Descriptor、Attribute Name、Bytecode Operand 都会大量通过 Index 与它发生关系。
第五层:
真正的方法实现位于 Code Attribute
而 Code 中最核心的是:
JVM Bytecode Instructions
一个最简单 Constructor:
2A B7 00 01 B1
已经能够一路解析成:
aload_0
→ invokespecial Object.<init>
→ return
最后还必须建立一个边界意识:
JMM Action
≠ JVM Bytecode
≠ Native Machine Instruction
≠ CPU Cache Coherence Protocol
只有把这些层级分开,后面学习:
Class Loading
Runtime Data Areas
Operand Stack
GC
JIT
Escape Analysis
Safepoint
JMM
JVM Tuning
才不会不断把不同层面的概念混在一起。
JVM 的第一扇门不是 GC,而是 ClassFile。源码只是写给程序员看的表达方式,ClassFile 才是 JVM 真正接收的结构化契约;而 Bytecode,则是这台抽象机器真正理解的指令语言。
评论区