这篇笔记围绕 JVM 中最容易混淆的两条主线展开:
class文件的二进制结构,以及类从字节流进入运行时后的加载、链接、初始化过程。重点不在于罗列名词,而在于把字段、索引、描述符、符号引用与类生命周期串成一条连续链路。
本文聚焦
ClassFile结构和类加载主线,不展开运行时数据区、垃圾回收器、JIT 等主题。阅读目标是建立一份可以直接对照javap -verbose输出和 JVM 规范的结构化认知。
参考资料:
官方规范:The Java Virtual Machine Specification, Java SE 21 Edition 、 Chapter 4. The class File Format 、 4.3 Descriptors 、 Chapter 5. Loading, Linking, and Initializing 、 The Java Language Specification, Chapter 12. Execution
JDK 工具:javap
[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 上往往会抛出 UnsupportedClassVersionError。
常量池计数器
constant_pool_count 记录的是“常量池容量”,不是“可用常量项个数”。
真正可用的常量池索引范围是 1 ~ constant_pool_count - 1,因为索引 0 被 JVM 规范保留,表示“不指向任何常量池项”。
常量池
常量池可以理解为 class 文件的“中心索引区”,里面主要放两大类信息:
- 字面量,比如字符串常量、整数常量等。
- 符号引用,比如类名、字段名、方法名、方法描述符等。
类加载后,很多后续动作都要先从常量池里找到对应的信息,所以常量池是理解 class 文件和类加载的关键入口。
常见的常量池项可以先按下面几类理解:
| 常量项 | 作用 | 常见配合 |
|---|---|---|
CONSTANT_Utf8_info |
存储字符串、名称、描述符等文本信息 | 被 CONSTANT_Class_info、CONSTANT_NameAndType_info 等间接引用 |
CONSTANT_Class_info |
表示类或接口的符号引用 | 再指向一个 CONSTANT_Utf8_info |
CONSTANT_NameAndType_info |
绑定“名称 + 描述符” | 供字段引用、方法引用复用 |
CONSTANT_Fieldref_info |
表示字段符号引用 | 指向类信息和 NameAndType |
CONSTANT_Methodref_info |
表示普通方法符号引用 | 指向类信息和 NameAndType |
CONSTANT_InterfaceMethodref_info |
表示接口方法符号引用 | 指向接口信息和 NameAndType |
字段名、方法名有没有长度限制
有,但更准确地说,是它们在 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]; // 存储字符串的 Modified UTF-8 编码字节
}
这里的 length 是一个 u2,最大值是 65535,也就是最多 65535 个字节,而不是“严格等于 64KB”。
需要注意两点:
- 这个限制针对的是字节长度,不是字符个数。
- class 文件里使用的是 Modified UTF-8,与标准 UTF-8 并不完全等价,例如
\u0000会使用两个字节编码。
- ASCII 字符(如英文字母、数字、下划线等)通常占 1 字节,因此理论上最多可表示
65535个字符。 - 常见中文字符通常占 3 字节,因此最大字符数会显著下降,大致是
65535 / 3的量级。
常量池索引为什么从 1 开始
常量池索引从 1 开始使用,0 是保留值。
- 不是“匿名内部类没有名称所以会用 0”。
- 匿名内部类在 class 文件层面依然有编译器生成的名字,比如
Outer$1。 super_class为0的典型且规范中的特例,是java/lang/Object,因为它没有父类。
所以更准确的说法是:索引 0 用来表达“没有有效的常量池引用”,而不是专门给“匿名类”使用。
另外还有一个细节:CONSTANT_Long_info 和 CONSTANT_Double_info 会占用两个常量池位置,后一个位置只是占位不可用,这也是常量池索引看起来“不连续”的原因之一。
描述符
字段表、方法表以及若干常量池项里都会出现“描述符”。描述符不是源码写法,而是 JVM 在 class 文件里编码类型签名的方式。
| Java 声明 | 描述符 | 含义 |
|---|---|---|
int value |
I |
基本类型 int |
String name |
Ljava/lang/String; |
引用类型使用 L...; 包裹 |
int[] nums |
[I |
一维 int 数组 |
String[][] names |
[[Ljava/lang/String; |
二维对象数组 |
void run(String s, int n) |
(Ljava/lang/String;I)V |
参数列表写在括号内,返回值写在括号后 |
阅读 javap -verbose 输出时,只要能够识别描述符,就能快速把常量池项、字段表和方法表串起来。
访问标志(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)
如果从更完整的类生命周期看,后面还有使用和卸载;理解主线时,先把前三步抓牢即可。
flowchart TB
A[加载 Loading] --> B[链接 Linking]
B --> B1[验证 Verification]
B1 --> B2[准备 Preparation]
B2 --> B3[解析 Resolution]
B3 --> C[初始化 Initialization]
C --> D[执行 clinit]
D --> E[使用 Using]
E --> F[卸载 Unloading]
类生命周期三大阶段可以先对照下面这张表:
| 阶段 | 核心动作 | 直接结果 | 常见误区 |
|---|---|---|---|
| 加载 | 获取字节流并创建 Class 对象 |
类已经被 JVM 识别,有了运行时访问入口 | 误以为静态代码已经执行 |
| 链接 | 完成验证、准备、解析 | 类可以被 JVM 安全接入运行时 | 误以为源码里的静态赋值在准备阶段已经全部完成 |
| 初始化 | 执行 <clinit>() |
静态变量显式赋值和静态代码块真正生效 | 误以为任何“拿到类”的动作都会触发 |
加载(Loading)
加载阶段要完成三件核心事情:
- 通过类的全限定名获取定义这个类的二进制字节流。
- 把字节流代表的静态存储结构转成 JVM 运行时的数据结构。
- 在内存中生成一个代表这个类的
java.lang.Class对象,作为后续访问入口。
这一阶段最容易混淆的点有三类:
- 加载的输入不一定非得来自磁盘文件,也可以来自网络、JAR、动态生成字节码、数据库等。
- 生成
Class对象,发生在加载阶段,不是初始化阶段。 - “拿到 class 文件字节流”只是第一步,后面还要把它变成 JVM 能真正使用的数据结构。
链接(Linking)
链接分成三步:验证、准备、解析。
验证
验证的目标是保证这份字节流确实符合 JVM 规范,避免把一份非法或恶意构造的数据直接交给执行引擎。
常见会检查的内容包括:
- 文件格式是否正确,比如魔数、版本号、常量池结构是否合法
- 元数据是否合理,比如是否有不合法的继承关系
- 字节码指令是否安全、类型是否匹配
- 符号引用是否能被正确解析
文件格式验证
这是最直观的一层,比如检查:
- 魔数是否为
0xCAFEBABE - 主次版本号是否在当前 JVM 可接受范围内
- 常量池中的各项结构是否完整合法
准备(Preparation)
准备阶段会为 类变量(也就是 static 变量) 分配内存,并设置默认初始值。
这里的“默认初始值”指的是零值,而不是源码里写的赋值结果。例如:
1
public static int value = 10;
在准备阶段结束后,value 先是 0;等到初始化阶段执行 <clinit> 时,才会被赋值成 10。
不过如果是 static final 且属于编译期常量,JVM 可能会在准备阶段就直接按 ConstantValue 属性赋值。
解析(Resolution)
解析阶段会把常量池中的符号引用替换成直接引用。
例如 java/lang/String.length:()I 这样的信息,在 class 文件中先以“类名 + 方法名 + 描述符”的符号形式存在;解析之后,JVM 才会把它和实际运行时目标关联起来。
要注意:
- 规范上解析属于链接的一部分。
- 实现上解析不一定一次性全部做完,很多 JVM 会采用按需解析、延迟解析。
初始化(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 认识了
- 链接:类的结构被校验并接入运行时
- 初始化:类级别的代码真的开始执行了
不会触发初始化的常见场景
下面这些情况经常与“主动使用”混淆,但通常不会触发目标类初始化:
- 通过子类名访问父类中定义的静态字段,只会初始化真正声明该字段的类。
- 读取编译期常量,例如
static final int PORT = 8080这类会被编译器内联的值。 - 使用类字面量,例如
Demo.class。 - 创建某个类的数组,例如
new Demo[10];这里创建的是数组类,不会先执行Demo的<clinit>()。
接口还有一个容易忽略的边界:初始化一个接口时,并不会像类那样先强制初始化它的父接口;只有当父接口中定义的非常量字段被真正使用时,相关初始化才会发生。
父类与子类的初始化顺序
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 必须先确保父类已经完成初始化。
常见混淆点
加载不等于初始化
类被 ClassLoader 加载进来,不代表静态代码块已经执行。
Class 对象是在加载阶段生成的
Class 对象属于加载阶段的结果,不属于初始化阶段的副产物。
super_class = 0 不是给匿名类准备的
这个保留值的经典场景是 java/lang/Object 没有父类;匿名内部类依然有编译器生成的类名,也照样会有自己的父类或接口信息。
方法字节码通常在 Code 属性里
方法表本身只是一层描述结构,真正执行的字节码一般挂在 Code 属性下面。
解析不一定一次性完成
规范把解析放在链接里讲,但 JVM 实现常常会做延迟解析,所以运行时第一次真正用到某个符号引用时,才完成对应绑定也很常见。
结论梳理
围绕本文主题,至少需要把下面几个结论记牢:
class文件是严格结构化的二进制格式,核心是常量池、字段表、方法表、属性表。- 常量池索引从
1开始,0是保留值。 this_class、super_class、interfaces本质上都是通过常量池索引去找类信息。- 类加载主线是:加载 -> 链接(验证、准备、解析)-> 初始化。
- 执行静态变量赋值和静态代码块,发生在初始化阶段,而不是加载阶段。