JVM class 文件结构与类加载流程

从 ClassFile、常量池到加载、链接与初始化

Posted by Ekko on October 28, 2025

这篇笔记围绕 JVM 中最容易混淆的两条主线展开:class 文件的二进制结构,以及类从字节流进入运行时后的加载、链接、初始化过程。重点不在于罗列名词,而在于把字段、索引、描述符、符号引用与类生命周期串成一条连续链路。

本文聚焦 ClassFile 结构和类加载主线,不展开运行时数据区、垃圾回收器、JIT 等主题。阅读目标是建立一份可以直接对照 javap -verbose 输出和 JVM 规范的结构化认知。

参考资料:

官方规范:The Java Virtual Machine Specification, Java SE 21 EditionChapter 4. The class File Format4.3 DescriptorsChapter 5. Loading, Linking, and InitializingThe 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_versionmajor_version 组成,用来描述这份 class 文件是按哪个 class 文件规范生成的。

通常更关键的是主版本号,它直接决定当前 JVM 能否识别这份 class 文件。高版本 javac 产出的 class 文件,在低版本 JVM 上往往会抛出 UnsupportedClassVersionError


常量池计数器

constant_pool_count 记录的是“常量池容量”,不是“可用常量项个数”。

真正可用的常量池索引范围是 1 ~ constant_pool_count - 1,因为索引 0 被 JVM 规范保留,表示“不指向任何常量池项”。


常量池

常量池可以理解为 class 文件的“中心索引区”,里面主要放两大类信息:

  1. 字面量,比如字符串常量、整数常量等。
  2. 符号引用,比如类名、字段名、方法名、方法描述符等。

类加载后,很多后续动作都要先从常量池里找到对应的信息,所以常量池是理解 class 文件和类加载的关键入口。

常见的常量池项可以先按下面几类理解:

常量项 作用 常见配合
CONSTANT_Utf8_info 存储字符串、名称、描述符等文本信息 CONSTANT_Class_infoCONSTANT_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”。

需要注意两点:

  1. 这个限制针对的是字节长度,不是字符个数。
  2. class 文件里使用的是 Modified UTF-8,与标准 UTF-8 并不完全等价,例如 \u0000 会使用两个字节编码。
  • ASCII 字符(如英文字母、数字、下划线等)通常占 1 字节,因此理论上最多可表示 65535 个字符。
  • 常见中文字符通常占 3 字节,因此最大字符数会显著下降,大致是 65535 / 3 的量级。

常量池索引为什么从 1 开始

常量池索引从 1 开始使用,0 是保留值。

  • 不是“匿名内部类没有名称所以会用 0”。
  • 匿名内部类在 class 文件层面依然有编译器生成的名字,比如 Outer$1
  • super_class0 的典型且规范中的特例,是 java/lang/Object,因为它没有父类。

所以更准确的说法是:索引 0 用来表达“没有有效的常量池引用”,而不是专门给“匿名类”使用。

另外还有一个细节:CONSTANT_Long_infoCONSTANT_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 用来描述类的访问和语义属性,比如:

  • public
  • final
  • abstract
  • interface
  • enum
  • annotation
  • synthetic

它不是只给 privatepublic 这种源码可见修饰符准备的,而是一组 JVM 层面的位标记。

类索引、父类索引

this_classsuper_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 方法没有 Code
  • native 方法也没有 Java 字节码形式的 Code

属性数量

attributes_count 表示当前 class 文件最外层挂了多少个属性。

属性表

属性表是 class 文件的扩展机制。很多“额外信息”都不是写死在主结构里的,而是通过属性挂上去。

常见属性包括:

  • Code
  • LineNumberTable
  • LocalVariableTable
  • SourceFile
  • Exceptions
  • InnerClasses
  • BootstrapMethods

所以可以把属性理解为:在 class 文件主骨架之外,继续补充语义信息和调试信息的通用扩展点。


如何对照 javap -verbose 输出

只记结构定义,通常很难和真实字节码建立联系。更直接的方式是准备一个简单类,再去观察编译后的 class 文件:

1
2
javac Demo.java
javap -verbose Demo.class

javap -verbose 会把常量池、字段表、方法表、访问标志、属性表都打印出来,和 JVM 规范里的结构能一一对上。


类加载过程

日常讨论里,“类加载”这个说法经常把 加载、链接、初始化 混在一起。按照 JVM 规范,更准确的主线是:

  1. 加载(Loading)
  2. 链接(Linking)
  3. 初始化(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)

加载阶段要完成三件核心事情:

  1. 通过类的全限定名获取定义这个类的二进制字节流。
  2. 把字节流代表的静态存储结构转成 JVM 运行时的数据结构。
  3. 在内存中生成一个代表这个类的 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 实现常常会做延迟解析,所以运行时第一次真正用到某个符号引用时,才完成对应绑定也很常见。


结论梳理

围绕本文主题,至少需要把下面几个结论记牢:

  1. class 文件是严格结构化的二进制格式,核心是常量池、字段表、方法表、属性表。
  2. 常量池索引从 1 开始,0 是保留值。
  3. this_classsuper_classinterfaces 本质上都是通过常量池索引去找类信息。
  4. 类加载主线是:加载 -> 链接(验证、准备、解析)-> 初始化。
  5. 执行静态变量赋值和静态代码块,发生在初始化阶段,而不是加载阶段。