侧边栏壁纸
  • 累计撰写 144 篇文章
  • 累计创建 23 个标签
  • 累计收到 3 条评论

目 录CONTENT

文章目录

JVM 到底执行什么?从 .java、.class 到字节码指令,第一次真正读懂 ClassFile

YaFuX
2023-10-15 / 0 评论 / 0 点赞 / 2 阅读 / 0 字
温馨提示:
部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

前言

平时写 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 文件。

Java / Kotlin / Scala / 其他语言
各自的编译器
ClassFile 格式
ClassLoader
JVM Runtime
Interpreter
JIT Compiler
执行
OS / Hardware

一、JVM 到底是什么:先看 Java 的完整执行链

1. .java 到底经历了什么?

先从最熟悉的 Java 程序开始。

假设有:

Hello.java

执行:

javac Hello.java

得到:

Hello.class

然后:

java Hello

从宏观上看,可以先把执行链理解成:

javac 编译
Hello.java
Hello.class
ClassLoader 加载
JVM 中的类元数据与运行时结构
Interpreter 解释执行
JIT 编译热点代码
执行结果

这里已经出现了 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:

自定义语言源码
自己的 Compiler
合法 ClassFile
JVM

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。

Java Virtual Machine Specification
HotSpot
Eclipse OpenJ9
其他兼容 JVM 实现

现在最常见的实现之一是 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
热点路径
优化收益

如果一个方法只运行一次,却先花大量时间做高级优化编译,可能得不偿失。

于是更合理的模式是:

方法第一次开始运行
Interpreter 快速执行
收集 Runtime Profile
是否成为 Hot Code?
JIT 编译与优化
Native Machine Code
后续直接执行优化后的代码

所以 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。

所以现在更推荐记住“职责关系”,而不是死背“目录包含关系”:

JDK
Java Runtime Components
javac / javap / javadoc 等开发工具
JVM Implementation
Java Modules / Runtime Libraries

二、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 字段
↓
实现一个接口
↓
加一个普通方法
↓
自己写构造方法

每改一次源码,就重新 javacjavap

这种“差分观察”比一次背完整 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 顶层基本记法。

longdouble 的 8 Byte 怎么存?

CONSTANT_Long_info 为例,它是:

u1 tag
u4 high_bytes
u4 low_bytes

也就是两个 u4 拼成 64-bit 数据。

这正是为什么读规范比只记课堂缩写更可靠。

4. ClassFile 顶层到底有什么?

把整个 ClassFile 先看成一个有固定顺序的二进制协议:

顺序字段作用
1magic文件魔数
2minor_version次版本号
3major_version主版本号
4constant_pool_count常量池计数
5constant_pool常量池
6access_flags当前 Class / Interface 属性
7this_class当前类
8super_class父类
9interfaces_count直接父接口数量
10interfaces接口索引表
11fields_count字段数量
12fields字段表
13methods_count方法数量
14methods方法表
15attributes_count顶层属性数量
16attributes顶层属性表

顺序不能随便换。

Magic
Version
Constant Pool
Access Flags
This / Super / Interfaces
Fields
Methods
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 更容易看到 LineNumberTableLocalVariableTable 等内容。

然后查看前 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 像一张大量节点通过索引连接起来的图。

Constant Pool
this_class
super_class
field_info
method_info
attributes
Bytecode operands

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 应该按什么结构解释。

Utf8
Class
Methodref
读取 1 Byte Tag
Tag 类型
读取 length + bytes
读取 name_index
读取 class_index + name_and_type_index

所以 Variable-length Constant Pool 不能简单通过:

index × 固定长度

直接定位。

3. 现在到底有多少种 Constant Pool 类型?

老资料经常只讲到 Tag 18。

但 ClassFile Format 后来继续演化。

以 Java SE 26 JVMS 为准,目前规范列出了 17 种 Constant Kind:

TagConstant Kind
1CONSTANT_Utf8
3CONSTANT_Integer
4CONSTANT_Float
5CONSTANT_Long
6CONSTANT_Double
7CONSTANT_Class
8CONSTANT_String
9CONSTANT_Fieldref
10CONSTANT_Methodref
11CONSTANT_InterfaceMethodref
12CONSTANT_NameAndType
15CONSTANT_MethodHandle
16CONSTANT_MethodType
17CONSTANT_Dynamic
18CONSTANT_InvokeDynamic
19CONSTANT_Module
20CONSTANT_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

关系大致是:

#1 CONSTANT_Methodref
#2 CONSTANT_Class
#3 CONSTANT_NameAndType
#4 Utf8: java/lang/Object
#5 Utf8: <init>
#6 Utf8: ()V

最终组合出来:

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_classsuper_class 为什么只是两个数字?

假设 javap -v 显示:

this_class:  #7  // Minimal
super_class: #2  // java/lang/Object

这两个字段本身只是 Constant Pool Index。

this_class = #7
CONSTANT_Class
Utf8: Minimal
super_class = #2
CONSTANT_Class
Utf8: java/lang/Object

所以 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 的核心格式:

(参数类型...)返回值类型

常见基本类型:

DescriptorJava Type
Bbyte
Cchar
Ddouble
Ffloat
Iint
Jlong
Sshort
Zboolean
Vvoid,仅返回值可用

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

关系可以画成:

method_info
access_flags
name_index
descriptor_index
attributes[]
Code Attribute
max_stack
max_locals
code[] Bytecode
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。

2A
aload_0
B7 00 01
invokespecial #1
B1
return

一个看起来“什么都没写”的 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 的 LocalVariableTable Debug 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

这条执行链非常有价值:

aload_0
调用 Object.<init>
aload_0 再次把 this 压栈
sipush 888
putfield i
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 指令背下来。

学习顺序应该是:

空类
Field
Reference Field
Interface
Method
Constructor
Exception / Lambda / Generic 等复杂结构

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。

先建立第一层直觉:

JVM Frame
Local Variables
Operand Stack
Dynamic Linking 等其他信息

例如:

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?

这个问题非常好,但也是最容易把几套概念混成一套的地方。

必须先拆成三个层级:

Java Language / JMM
JVM Bytecode
JVM Implementation
Native Machine Instructions
CPU / Cache Coherence / Atomic RMW

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 为什么不能拿来证明线程原子性?

longdouble 在 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 可以执行的东西?

完整链路可以重新画成:

Java Source
javac
ClassFile
Magic / Version
Constant Pool
Class / Field / Method Metadata
Attributes
Code Attribute
Bytecode Instructions
ClassLoader / Verification / Runtime
Interpreter
JIT Compiler
Native Execution

再拿最小类走一遍。

源代码:

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,则是这台抽象机器真正理解的指令语言。

0

评论区