JVM内容

从 class 文件结构到类加载流程

Posted by Ekko on October 28, 2025

这篇笔记的目标是把 JVM 最容易反复混淆的两块内容重新梳理清楚:一块是 class 文件到底长什么样、各字段在干什么;另一块是类从磁盘上的字节流变成 JVM 里可执行元数据时,加载、链接、初始化分别发生了什么。

这里更偏“结构梳理 + 易错点纠正”,不是完整展开 JVM 全家桶。像运行时数据区、垃圾回收器、JIT 这些内容可以单独拆到别的笔记;本文先把 class 文件和类加载主线捋顺。

参考资料:

The Java Virtual Machine Specification, Java SE 21 Edition

Chapter 4. The class File Format

Chapter 5. Loading, Linking, and Initializing

[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 往往无法加载。


常量池计数器

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

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


常量池

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

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

类加载后,很多后续动作都要先从常量池里找到对应的信息,所以常量池是理解 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”。

需要注意两点:

  1. 这个限制针对的是字节长度,不是字符个数。
  2. class 文件里使用的是 Modified UTF-8,但直观理解时可以先按“不同字符占用字节数不同”来记。

  3. ASCII 字符(如英文字母、数字、下划线等):每个字符占 1 字节,因此最大字符数为 65535 个

  4. 非 ASCII 字符(如中文、日文等):每个字符可能占 2-3 字节(UTF-8 编码),因此最大字符数会减少。例如,一个中文占 3 字节时,最大字符数约为 65535 / 3 ≈ 21845 个

问题二:常量池计数器,是从0开始,还是从1开始

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

但是原先这句话要修正一下:

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

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

另外还有一个细节:CONSTANT_Long_infoCONSTANT_Double_info 会占用两个常量池位置,这也是常量池索引经常让人觉得“不连续”的原因之一。


访问标志(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)

如果从更完整的类生命周期看,后面还有使用和卸载;但复习重点通常先放在前面这三步。

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)

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

  1. 通过类的全限定名获取定义这个类的二进制字节流。
  2. 把字节流代表的静态存储结构转成 JVM 运行时的数据结构。
  3. 在内存中生成一个代表这个类的 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 骨架,至少记住下面这几个点:

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