这篇笔记的目标是把 JVM 最容易反复混淆的两块内容重新梳理清楚:一块是
class文件到底长什么样、各字段在干什么;另一块是类从磁盘上的字节流变成 JVM 里可执行元数据时,加载、链接、初始化分别发生了什么。
这里更偏“结构梳理 + 易错点纠正”,不是完整展开 JVM 全家桶。像运行时数据区、垃圾回收器、JIT 这些内容可以单独拆到别的笔记;本文先把 class 文件和类加载主线捋顺。
参考资料:
The Java Virtual Machine Specification, Java SE 21 Edition
[TOC]
class 文件
class 文件本质上是一份按照 JVM 规范组织的二进制数据。JVM 并不是“猜”源码含义,而是严格按照 class 文件格式去解析字段、常量池、方法表和属性表。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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]; // 属性表
}
魔数
magic 固定为 0xCAFEBABE,用来标识这是一份合法的 class 文件。
很多二进制文件格式都会在开头放一个固定标记,作用就是让解析器先判断“这是不是我能识别的格式”,class 文件也是同样思路。
版本号
版本号由 minor_version 和 major_version 组成,用来描述这份 class 文件是按哪个 class 文件规范生成的。
通常更关键的是主版本号,它直接决定当前 JVM 能不能识别这份 class 文件。比如高版本 javac 产出的 class 文件,低版本 JVM 往往无法加载。
常量池计数器
constant_pool_count 记录的是“常量池容量”,不是“可用常量项个数”。
真正可用的常量池索引范围是 1 ~ constant_pool_count - 1,因为索引 0 被 JVM 规范保留,表示“不指向任何常量池项”。
常量池
常量池可以理解为 class 文件的“中心索引区”,里面主要放两大类信息:
- 字面量,比如字符串常量、整数常量等。
- 符号引用,比如类名、字段名、方法名、方法描述符等。
类加载后,很多后续动作都要先从常量池里找到对应的信息,所以常量池是理解 class 文件和类加载的关键入口。
问题一:java 的字段名、方法名,有没有长度限制
有,但更准确地说,是它们在 class 文件里对应的 CONSTANT_Utf8_info 数据有长度上限。
字段名、方法名等标识符会以字符串形式出现在常量池中,而 class 文件里的字符串由 CONSTANT_Utf8_info 表示:
1
2
3
4
5
CONSTANT_Utf8_info {
u1 tag; // 标签,固定为 1
u2 length; // 字符串的字节长度(无符号 16 位整数)
u1 bytes[length]; // 存储字符串的 UTF-8 编码字节
}
这里的 length 是一个 u2,最大值是 65535,也就是最多 65535 个字节,不是“严格等于 64KB”。
需要注意两点:
- 这个限制针对的是字节长度,不是字符个数。
-
class 文件里使用的是 Modified UTF-8,但直观理解时可以先按“不同字符占用字节数不同”来记。
-
ASCII 字符(如英文字母、数字、下划线等):每个字符占 1 字节,因此最大字符数为 65535 个
- 非 ASCII 字符(如中文、日文等):每个字符可能占 2-3 字节(UTF-8 编码),因此最大字符数会减少。例如,一个中文占 3 字节时,最大字符数约为 65535 / 3 ≈ 21845 个
问题二:常量池计数器,是从0开始,还是从1开始
常量池索引从 1 开始使用,0 是保留值。
但是原先这句话要修正一下:
- 不是“匿名内部类没有名称所以会用 0”。
- 匿名内部类在 class 文件层面依然有编译器生成的名字,比如
Outer$1。 super_class为0的典型且规范中的特例,是java/lang/Object,因为它没有父类。
所以更准确的说法是:索引 0 用来表达“没有有效的常量池引用”,而不是专门给“匿名类”使用。
另外还有一个细节:CONSTANT_Long_info 和 CONSTANT_Double_info 会占用两个常量池位置,这也是常量池索引经常让人觉得“不连续”的原因之一。
访问标志(Access Flags)
access_flags 用来描述类的访问和语义属性,比如:
publicfinalabstractinterfaceenumannotationsynthetic
它不是只给 private、public 这种源码可见修饰符准备的,而是一组 JVM 层面的位标记。
类索引、父类索引
this_class 和 super_class 都是指向常量池的索引,索引对应的项通常是 CONSTANT_Class_info。
需要再往下一层看:
this_class表示当前类是谁。super_class表示直接父类是谁。CONSTANT_Class_info自己并不直接存类名字符串,而是再间接指向一个CONSTANT_Utf8_info。
也就是说,class 文件里大量信息都是“索引 -> 再索引 -> 最终字符串”的跳转结构。
接口计数器
interfaces_count 表示当前类实现了多少个直接接口。
接口表
interfaces 是一个 u2 数组,数组中的每个元素都是常量池索引,指向对应接口的 CONSTANT_Class_info。
这里记录的是“直接实现的接口”,不负责把整个继承树都展开。
字段数量
fields_count 表示当前类声明了多少个字段。
字段表
fields 里存的不是字段值本身,而是字段的描述信息,比如:
- 字段名
- 描述符
- 访问标志
- 附加属性
如果字段有常量值,比如 static final 的编译期常量,相关信息会体现在字段属性里,例如 ConstantValue。
方法数量
methods_count 表示当前类声明了多少个方法。
方法表
methods 里也是方法的描述信息,不是“方法源码”本身。方法最重要的几个部分通常包括:
- 方法名
- 方法描述符
- 访问标志
- 方法属性
对于普通 Java 方法,真正的字节码通常放在方法的 Code 属性里。
但也要注意:
abstract方法没有Codenative方法也没有 Java 字节码形式的Code
属性数量
attributes_count 表示当前 class 文件最外层挂了多少个属性。
属性表
属性表是 class 文件的扩展机制。很多“额外信息”都不是写死在主结构里的,而是通过属性挂上去。
常见属性包括:
CodeLineNumberTableLocalVariableTableSourceFileExceptionsInnerClassesBootstrapMethods
所以可以把属性理解为:在 class 文件主骨架之外,继续补充语义信息和调试信息的通用扩展点。
一个直观抓手:用 javap -verbose
如果只是背结构,很容易忘。更直观的方法是自己写一个简单类,然后看编译后的 class 文件:
1
2
javac Demo.java
javap -verbose Demo.class
javap -verbose 会把常量池、字段表、方法表、访问标志、属性表都打印出来,和 JVM 规范里的结构能一一对上。
类加载过程
平时说“类加载”时,很多时候其实是把 加载、链接、初始化 混在一起说了。按 JVM 规范,更准确的主线是:
- 加载(Loading)
- 链接(Linking)
- 初始化(Initialization)
如果从更完整的类生命周期看,后面还有使用和卸载;但复习重点通常先放在前面这三步。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
类的生命周期主线
┌────────────┐
│ 加载 │
│ Loading │
└─────┬──────┘
│
v
┌────────────┐
│ 链接 │
│ Linking │
└─────┬──────┘
│
v
┌──────────────────────────────┐
│ 1. 验证 Verification │
│ 2. 准备 Preparation │
│ 3. 解析 Resolution │
└──────────────┬───────────────┘
│
v
┌────────────┐
│ 初始化 │
│Initialize │
└─────┬──────┘
│
v
┌──────────────────────────────┐
│ 执行 <clinit>() │
│ - 静态变量赋值 │
│ - 静态代码块 │
└──────────────┬───────────────┘
│
v
┌────────────┐
│ 使用 │
│ Using │
└─────┬──────┘
│
v
┌────────────┐
│ 卸载 │
│ Unloading │
└────────────┘
可以先把这张图记成一句话:类先被 JVM 加载进来,再完成链接,真正执行静态逻辑发生在初始化阶段。
1、加载(Loading)
加载阶段要完成三件核心事情:
- 通过类的全限定名获取定义这个类的二进制字节流。
- 把字节流代表的静态存储结构转成 JVM 运行时的数据结构。
- 在内存中生成一个代表这个类的
java.lang.Class对象,作为后续访问入口。
这里有几个容易混淆的点:
- 加载的输入不一定非得来自磁盘文件,也可以来自网络、JAR、动态生成字节码、数据库等。
- 生成
Class对象,发生在加载阶段,不是初始化阶段。 - “拿到 class 文件字节流”只是第一步,后面还要把它变成 JVM 能真正使用的数据结构。
2、链接(Linking)
链接分成三步:验证、准备、解析。
验证
验证的目标是保证这份字节流确实符合 JVM 规范,避免把一份非法或恶意构造的数据直接交给执行引擎。
常见会检查的内容包括:
- 文件格式是否正确,比如魔数、版本号、常量池结构是否合法
- 元数据是否合理,比如是否有不合法的继承关系
- 字节码指令是否安全、类型是否匹配
- 符号引用是否能被正确解析
文件格式验证
这是最直观的一层,比如检查:
- 魔数是否为
0xCAFEBABE - 主次版本号是否在当前 JVM 可接受范围内
- 常量池中的各项结构是否完整合法
准备(Preparation)
准备阶段会为 类变量(也就是 static 变量) 分配内存,并设置默认初始值。
这里的“默认初始值”指的是零值,而不是源码里写的赋值结果。例如:
1
public static int value = 10;
在准备阶段结束后,value 先是 0;等到初始化阶段执行 <clinit> 时,才会被赋值成 10。
不过如果是 static final 且属于编译期常量,JVM 可能会在准备阶段就直接按 ConstantValue 属性赋值。
解析(Resolution)
解析阶段会把常量池中的符号引用替换成直接引用。
比如源码里写的是某个类名、字段名、方法签名,在 class 文件里先以符号形式存在;等解析之后,JVM 才把它们和实际运行时目标关联起来。
要注意:
- 规范上解析属于链接的一部分。
- 实现上解析不一定一次性全部做完,很多 JVM 会采用按需解析、延迟解析。
3、初始化(Initialization)
初始化阶段才是真正执行类构造逻辑的阶段,核心动作是执行类的 <clinit>() 方法。
<clinit>() 不是源码里手写的方法,而是编译器把下面两类东西合并出来的:
- 静态变量赋值语句
- 静态代码块
而且会按照源码中的出现顺序执行。
例如:
1
2
3
4
5
6
7
public class Demo {
static int a = 1;
static {
a = 2;
}
}
那么最终初始化完成后,a 的值是 2。
哪些情况会触发初始化
下面这些场景通常会触发一个类的初始化:
new一个类的实例- 读取或设置某个类的静态字段(但编译期常量除外)
- 调用类的静态方法
- 通过反射主动使用该类
- 初始化一个类时,如果其父类尚未初始化,会先初始化父类
- JVM 启动时,包含
main方法的主类会先初始化
所以一定要区分:
- 加载:类被 JVM 认识了
- 链接:类的结构被校验并接入运行时
- 初始化:类级别的代码真的开始执行了
一个最常见的顺序例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Parent {
static {
System.out.println("parent init");
}
}
class Child extends Parent {
static {
System.out.println("child init");
}
}
public class Demo {
public static void main(String[] args) {
new Child();
}
}
输出顺序是:
1
2
parent init
child init
原因是:初始化子类之前,JVM 必须先确保父类已经完成初始化。
容易混淆的几个点
1、加载不等于初始化
类被 ClassLoader 加载进来,不代表静态代码块已经执行。
2、Class 对象是在加载阶段生成的
很多人会把“生成 Class 对象”记到初始化阶段,这是不对的。它属于加载阶段的结果。
3、super_class = 0 不是给匿名类准备的
这个保留值的经典场景是 java/lang/Object 没有父类;匿名内部类依然有编译器生成的类名,也照样会有自己的父类或接口信息。
4、方法字节码通常在 Code 属性里
方法表本身只是一层描述结构,真正执行的字节码一般挂在 Code 属性下面。
5、解析不一定一次性完成
规范把解析放在链接里讲,但 JVM 实现常常会做延迟解析,所以运行时第一次真正用到某个符号引用时,才完成对应绑定也很常见。
这篇先记住什么
如果只是为了先搭一个 JVM 骨架,至少记住下面这几个点:
class文件是严格结构化的二进制格式,核心是常量池、字段表、方法表、属性表。- 常量池索引从
1开始,0是保留值。 this_class、super_class、interfaces本质上都是通过常量池索引去找类信息。- 类加载主线是:加载 -> 链接(验证、准备、解析)-> 初始化。
- 执行静态变量赋值和静态代码块,发生在初始化阶段,而不是加载阶段。