一、初探 class 文件:平台无关性的基石
Java 在设计之初就提出一个非常著名的口号:”一次编写,随处运行”(Write Once, Run Anywhere)。亦即 Java 编译生成的二进制文件能够在不做任何改变的情况下运行于多个平台。而这也是 Java 语言平台无关性的由来。
然而 JVM 却非跨平台的——不同操作系统、不同 CPU 架构上的 JVM 实现各不相同。HotSpot、OpenJ9、ART 等虚拟机通过各自的方式加载和执行同一种平台无关的字节码(bytecode),使得源代码不用根据不同平台编译成不同的二进制可执行文件。这正是 Java 历经二十余年依然稳居主流语言之列的原因所在。
class 文件是字节码的物理载体。它是一个与平台无关的、严格二进制格式的文件,由 Java 编译器(javac)或其它 JVM 语言编译器(Kotlin、Scala、Groovy 等)生成。每一个 class 文件对应一个 Java 类(或接口),其中包含该类的全部元信息:版本号、常量池、访问标志、字段表、方法表、属性表。
在 Android 生态中,class 文件并非最终产物。Android 构建流程是 .java → .class → .dex,其中 class 到 dex 的转换由 d8 工具完成。但理解 class 文件结构对于深入理解字节码、反编译、性能优化以及安全分析都至关重要——毕竟 DEX 格式的设计思想直接来源于 class 文件格式,对其规范的理解是分析一切字节码工具链的基础。
二、class 文件结构全景
Java 虚拟机规范(Java Virtual Machine Specification, Chapter 4: The class File Format)规定用 u1、u2、u4 三种基本数据类型来表示 1、2、4 字节的无符号整数。若干条相同类型的数据集合用表(table)的形式存储。表是一个变长的结构,由代表长度的表头 n 和紧随着的 n 个数据项组成。class 文件采用类似 C 语言结构体的方式来组织数据。
以下是大端序(Big-Endian)class 文件的完整数据结构:
ClassFile { |
【记住】 class 文件由下面十个部分组成,可以通过一句英文助记:
| 部分 | 英文 | 助记 |
|---|---|---|
| 魔数(Magic Number) | Magic | My |
| 版本号(Version) | Minor & Major Version | Very |
| 常量池(Constant Pool) | Constant Pool | Cute |
| 类访问标记(Access Flags) | Access Flags | Animal |
| 当前类(This Class) | This Class | Turns |
| 父类(Super Class) | Super Class | Savage |
| 接口表(Interfaces) | Interfaces | In |
| 字段表(Fields) | Fields | Full |
| 方法表(Methods) | Methods | Moon |
| 属性表(Attributes) | Attributes | Areas |
下文将逐一对这十个部分进行深入剖析。
三、魔数(Magic Number):0xCAFEBABE
class 文件的前 4 个字节是魔数,固定为 0xCAFEBABE(咖啡宝贝)。这个名字的由来颇具传奇色彩——据说是 Java 早期开发者 James Gosling 或其同事在讨论时看到桌上的咖啡杯(Java 的标志正是一杯热气腾腾的咖啡),遂将十六进制的咖啡(CAFE)和宝贝(BABE)组合而成。
虚拟机在加载类文件之前会首先校验这 4 个字节,如果不是 0xCAFEBABE,则抛出 java.lang.ClassFormatError 异常并拒绝加载。这与操作系统加载可执行文件时检查魔数(如 ELF 的 0x7f 0x45 0x4c 0x46、PE 的 MZ)是同一种思路。
使用 xxd 或 hexdump 命令查看任意 class 文件的开头:
$ xxd HelloWorld.class | head -3 |
逐字节解读前 8 个字节(偏移 0x00 ~ 0x07):
| 偏移 | 字节 | 含义 |
|---|---|---|
| 0x00 ~ 0x03 | CA FE BA BE |
魔数:0xCAFEBABE |
| 0x04 ~ 0x05 | 00 00 |
副版本号(minor_version):0 |
| 0x06 ~ 0x07 | 00 3D |
主版本号(major_version):61(即 Java 17) |
需要特别注意 class 文件使用大端序(Big-Endian),即高位字节在前。这一点与 x86 架构的默认小端序相反,但对于十六进制查看工具(如 xxd)来说,默认即可直接按顺序阅读字节。
在 ART 的类加载流程中,art/runtime/class_linker.cc 的 OpenDexFilesFromOat 函数会校验 DEX 文件的魔数(DEX 文件的魔数是 dex\n035 或 dex\n037 等),这等效于 class 文件中对 0xCAFEBABE 的校验。不同的文件格式有不同的魔数约定,但设计意图一致——提供文件类型识别和格式校验的第一道防线。
四、版本号(Minor & Major Version)
魔数之后的 4 个字节分别表示副版本号(minor_version)和主版本号(major_version)。JVM 拒绝加载版本号超过自身支持的 class 文件,抛出 java.lang.UnsupportedClassVersionError。
下表是完整的主版本号与 Java 版本对应关系:
| Java 版本 | major_version | 说明 |
|---|---|---|
| Java 1.0.2 | 45 | 首个公开版本 |
| Java 1.1 | 45.3 | 内部类、反射、JDBC |
| Java 1.2 | 46 | Collections 框架 |
| Java 1.3 | 47 | HotSpot 默认虚拟机 |
| Java 1.4 | 48 | assert、NIO、正则 |
| Java 5 (1.5) | 49 | 泛型、注解、枚举、可变参数 |
| Java 6 (1.6) | 50 | 脚本语言支持、编译 API |
| Java 7 (1.7) | 51 | invokedynamic、try-with-resources |
| Java 8 (1.8) | 52 | Lambda、Stream API、默认方法 |
| Java 9 | 53 | 模块系统(Project Jigsaw) |
| Java 10 | 54 | var 局部变量类型推断 |
| Java 11 (LTS) | 55 | HTTP Client API、ZGC |
| Java 12 | 56 | Switch 表达式预览 |
| Java 13 | 57 | 文本块预览 |
| Java 14 | 58 | Records、Pattern Matching 预览 |
| Java 15 | 59 | 密封类预览 |
| Java 16 | 60 | Records 正式、Pattern Matching |
| Java 17 (LTS) | 61 | 密封类正式 |
| Java 18 | 62 | 简易 Web 服务器 |
| Java 19 | 63 | 虚拟线程预览 |
| Java 20 | 64 | 虚拟线程第二次预览 |
| Java 21 (LTS) | 65 | 虚拟线程正式、Record Patterns |
跨版本兼容性规则:
- 低版本 JVM 不能加载高版本 class 文件。例如,Java 8 的 JVM(major=52)无法加载 Java 17 编译的 class(major=61)。
- 高版本 JVM 可以加载低版本 class 文件。但现代 JVM 通常会对待旧版 class 文件触发特殊的兼容性处理逻辑。
- minor_version 主要用于修正大版本中的小修订,目前主流的 Java 版本中 minor_version 几乎总是 0。
在真机上验证版本号:
$ javap -verbose HelloWorld.class | head -5 |
五、常量池(Constant Pool):字节码的心脏
5.1 常量池总览
常量池是 class 文件中结构最复杂、信息量最丰富的部分,可以说是整个 class 文件的”心脏”。它紧随版本号之后,存储了该类用到的所有字面量(literal)和符号引用(symbolic reference)。
字面量包括:文本字符串(如 “Hello World”)、声明为 final 的常量值、数值常量(int、float、long、double 等)。
符号引用包括:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符、方法句柄和方法类型、动态调用点和动态常量。
常量池以 2 字节的 constant_pool_count 开头,其值等于常量池中的项数加 1。索引从 1 开始,0 被保留。这意味着如果 constant_pool_count = 27,则实际有 26 个常量池项(#1~#26),索引 #0 留空,用于表示”不引用任何常量池项”。
为什么索引 0 被保留? 在某些 class 文件结构元素中(如 exception_table 的 catch_type 字段、BootstrapMethods 的 method_handle 引用等),需要一种方式表达”无引用”的语义。指向索引 0 即表示”没有对应的常量池引用”。
5.2 常量池项类型完整表
每个 cp_info 项的第一个字节(u1)是 tag,标明该常量项的类型。JVM 规范目前定义了 17 种常量类型(Java SE 21):
| 类型 | tag 值 | 引入版本 | 结构说明 |
|---|---|---|---|
| CONSTANT_Utf8 | 1 | 45.0(1.0) | length(u2) + bytes(u1[length]):UTF-8 编码的字符串 |
| CONSTANT_Integer | 3 | 45.0 | bytes(u4):4 字节 int 值(大端序) |
| CONSTANT_Float | 4 | 45.0 | bytes(u4):4 字节 float 值(IEEE 754 单精度) |
| CONSTANT_Long | 5 | 45.0 | high_bytes(u4) + low_bytes(u4):8 字节 long 值 |
| CONSTANT_Double | 6 | 45.0 | high_bytes(u4) + low_bytes(u4):8 字节 double 值 |
| CONSTANT_Class | 7 | 45.0 | name_index(u2):指向 CONSTANT_Utf8 的内部类名 |
| CONSTANT_String | 8 | 45.0 | string_index(u2):指向 CONSTANT_Utf8 的字符串字面量 |
| CONSTANT_Fieldref | 9 | 45.0 | class_index(u2) + name_and_type_index(u2) |
| CONSTANT_Methodref | 10 | 45.0 | class_index(u2) + name_and_type_index(u2) |
| CONSTANT_InterfaceMethodref | 11 | 45.0 | class_index(u2) + name_and_type_index(u2) |
| CONSTANT_NameAndType | 12 | 45.0 | name_index(u2) + descriptor_index(u2) |
| CONSTANT_MethodHandle | 15 | 51.0(Java 7) | reference_kind(u1) + reference_index(u2) |
| CONSTANT_MethodType | 16 | 51.0(Java 7) | descriptor_index(u2) |
| CONSTANT_Dynamic | 17 | 55.0(Java 11) | bootstrap_method_attr_index(u2) + name_and_type_index(u2) |
| CONSTANT_InvokeDynamic | 18 | 51.0(Java 7) | bootstrap_method_attr_index(u2) + name_and_type_index(u2) |
| CONSTANT_Module | 19 | 53.0(Java 9) | name_index(u2) |
| CONSTANT_Package | 20 | 53.0(Java 9) | name_index(u2) |
注意: tag 值 0(无效)、2(保留给 CONSTANT_Unicode,从未实现)、13 和 14(曾经用作 CONSTANT_Fieldref info 和 CONSTANT_Methodref info 的历史变体,已废弃)均未被使用或已废弃。
重要规则: CONSTANT_Long 和 CONSTANT_Double 在常量池中占用两个索引槽位。例如,如果 #5 是一个 CONSTANT_Long,则 #6 被视为无效(不可用),下一项需从 #7 开始。这是因为这两种常量占用 8 字节,在 32 位体系结构中需要两个 32 位槽位存储。
5.3 符号引用链追踪
常量池的核心设计在于符号引用链——各项之间通过索引相互链接,最终汇聚到 CONSTANT_Utf8 以获取人类可读的名字和描述符。
以 CONSTANT_Methodref 为例,追踪一条完整的引用链:
CONSTANT_Methodref_info (tag=10) |
这条链完整表达了:”在类 java/lang/StringBuilder 中,有一个名为 append,类型描述符为 (Ljava/lang/String;)Ljava/lang/StringBuilder; 的方法”。JVM 在首次执行到该引用时,会通过这条链定位到目标类的目标方法,完成所谓的「常量池解析」(constant pool resolution)。
各引用类型的典型引用链:
- **CONSTANT_Fieldref (tag=9)**:class_index → CONSTANT_Class → CONSTANT_Utf8(类名),name_and_type_index → CONSTANT_NameAndType → CONSTANT_Utf8(字段名) + CONSTANT_Utf8(字段描述符)
- **CONSTANT_InterfaceMethodref (tag=11)**:结构同 Methodref 但用于接口方法调用
- **CONSTANT_String (tag=8)**:string_index → CONSTANT_Utf8(字符串字面量内容)
- **CONSTANT_MethodHandle (tag=15)**:reference_kind(引用种类,1-9,如 REF_getField=1, REF_getStatic=2, REF_putField=3, REF_putStatic=4, REF_invokeVirtual=5, REF_invokeStatic=6, REF_invokeSpecial=7, REF_newInvokeSpecial=8, REF_invokeInterface=9)+ reference_index → CONSTANT_Fieldref 或 CONSTANT_Methodref
- **CONSTANT_MethodType (tag=16)**:descriptor_index → CONSTANT_Utf8(方法描述符,如
()V或(IJ)Ljava/lang/String;) - **CONSTANT_InvokeDynamic (tag=18)**:bootstrap_method_attr_index → BootstrapMethods 属性表中的引导方法,name_and_type_index → CONSTANT_NameAndType
- **CONSTANT_Dynamic (tag=17)**:同 InvokeDynamic,但用于动态常量的计算
- **CONSTANT_Module (tag=19)**:name_index → CONSTANT_Utf8(模块名,如
java.base) - **CONSTANT_Package (tag=20)**:name_index → CONSTANT_Utf8(包名,如
java/lang)
5.4 常量池解析与运行时缓存
在 ART 中,常量池的解析(resolution)发生在首次使用该符号引用时(懒解析,lazy resolution),而非类加载时。解析过程的核心代码在 art/runtime/class_linker.cc 中:
- 字段解析:
ClassLinker::ResolveField和ClassLinker::ResolveFieldJLS——根据 class_index 定位目标类(必要时先加载),再根据 name_and_type_index 匹配字段,最终返回一个ArtField*指针。 - 方法解析:
ClassLinker::ResolveMethod——类似流程,在目标类及其父类和接口的 vtable/iftable 中匹配方法,返回ArtMethod*指针。
Quickening(快速化):解析成功后,ART 不会每次都重新解析常量池。解释器会将 ArtField* 或 ArtMethod* 指针直接写入 DEX 文件的对应槽位(或者在其内部缓存结构中记录),后续执行直接使用该指针,消除了重复解析的昂贵开销。
5.5 常量池实战:javap -verbose 输出解析
以下是一个简单类编译后 javap -verbose 的常量池输出片段:
Constant pool: |
可以看到,常量池最底层的叶子节点全部是 CONSTANT_Utf8,它们提供了字符串和标识符的实体内容。所有符号引用(Methodref、Fieldref 等)最终都追溯到 CONSTANT_Utf8。
六、访问标记(Access Flags)
紧随常量池之后的 2 个字节是访问标记(access_flags),用来标识该类或接口的访问权限和属性。它是一个位掩码,使用 16 个标志位。目前共定义了 9 个标志位:
| 标志名 | 十六进制值 | 二进制位 | 含义 |
|---|---|---|---|
| ACC_PUBLIC | 0x0001 | 0 | public 访问 |
| ACC_FINAL | 0x0010 | 4 | 无法派生子类 |
| ACC_SUPER | 0x0020 | 5 | 调用父类方法时使用 invokespecial 语义(现代 javac 始终设置) |
| ACC_INTERFACE | 0x0200 | 9 | 标识为接口而非类 |
| ACC_ABSTRACT | 0x0400 | 10 | 抽象类或接口,无法实例化 |
| ACC_SYNTHETIC | 0x1000 | 12 | 编译器合成,非源码中显式出现 |
| ACC_ANNOTATION | 0x2000 | 13 | 标识为注解类型 |
| ACC_ENUM | 0x4000 | 14 | 标识为枚举类型 |
| ACC_MODULE | 0x8000 | 15 | 标识为模块描述(module-info.class) |
组合示例:
- 普通 public 类:
ACC_PUBLIC | ACC_SUPER = 0x0021 - public final 类:
ACC_PUBLIC | ACC_FINAL | ACC_SUPER = 0x0031 - public abstract 类:
ACC_PUBLIC | ACC_ABSTRACT | ACC_SUPER = 0x0421 - interface:
ACC_PUBLIC | ACC_INTERFACE | ACC_ABSTRACT | ACC_SUPER = 0x0621(注意接口隐式为 abstract) - enum:
ACC_PUBLIC | ACC_FINAL | ACC_SUPER | ACC_ENUM = 0x4031 - package-private 类(无 ACC_PUBLIC):
ACC_SUPER = 0x0020
关于 ACC_SUPER: 历史上(Java 1.0 时代),invokespecial 指令在处理父类方法调用时的语义有所不同。ACC_SUPER 标志告诉 JVM 使用修正后的语义。所有现代 javac 都会自动设置此标志。只有非常老的类(Java 1.0.2 之前编译)才不会设置此标志。
七、this_class、super_class 和接口表
这三个要素共同定义了该类在类型层级中的位置:
- **this_class (u2)**:指向常量池中的一个 CONSTANT_Class_info,表示当前类的全限定名。
- **super_class (u2)**:指向常量池中的一个 CONSTANT_Class_info,表示直接父类。对于
java.lang.Object,这个值为 0(因为 Object 没有父类)。对于接口,super_class 指向java/lang/Object。 - **interfaces_count (u2) 和 interfaces[]**:接口数量及每个接口在常量池中的 CONSTANT_Class_info 索引。
假设有一个类声明 public class MyClass extends BaseClass implements InterfaceA, InterfaceB:
access_flags: 0x0021 (ACC_PUBLIC | ACC_SUPER) |
在二进制层面,this_class 只是一个 2 字节的无符号整数,实际类名需要通过两级间接引用(this_class → CONSTANT_Class → name_index → CONSTANT_Utf8)才能获得。
八、字段表(Fields)
8.1 field_info 结构
每个字段由一个 field_info 结构描述:
field_info { |
8.2 字段访问标记
字段的 access_flags 是类访问标记的子集,包含以下标志:
| 标志名 | 值 | 含义 |
|---|---|---|
| ACC_PUBLIC | 0x0001 | 公有 |
| ACC_PRIVATE | 0x0002 | 私有 |
| ACC_PROTECTED | 0x0004 | 受保护 |
| ACC_STATIC | 0x0008 | 静态 |
| ACC_FINAL | 0x0010 | 不可变(final) |
| ACC_VOLATILE | 0x0040 | 易变(volatile,禁止缓存) |
| ACC_TRANSIENT | 0x0080 | 瞬时(不参与序列化) |
| ACC_SYNTHETIC | 0x1000 | 编译器合成 |
| ACC_ENUM | 0x4000 | 枚举值 |
8.3 字段描述符(Field Descriptor)
字段描述符使用单字符编码来表示基本类型:
| 描述符字符 | Java 类型 | 说明 |
|---|---|---|
| B | byte | 8 位有符号整数 |
| C | char | 16 位无符号 Unicode 字符 |
| D | double | 64 位 IEEE 754 双精度浮点数 |
| F | float | 32 位 IEEE 754 单精度浮点数 |
| I | int | 32 位有符号整数 |
| J | long | 64 位有符号整数 |
| S | short | 16 位有符号整数 |
| Z | boolean | true/false(以 int 形式存储) |
| L<classname>; | 对象引用 | 如 Ljava/lang/String; |
| [ | 数组 | 如 [I 为 int[],[[Ljava/lang/String; 为 String[][] |
示例:
int count;→ 描述符IString name;→ 描述符Ljava/lang/String;long[] numbers;→ 描述符[JObject[][] matrix;→ 描述符[[Ljava/lang/Object;
8.4 字段属性
字段可以携带以下属性:
- ConstantValue:对应
static final常量字段(编译时常量)。例如static final int MAX = 100,ConstantValue 属性存储该 int 值。JVM 在准备阶段(Preparation)使用该值初始化静态字段。只有基本类型和 String 可以拥有 ConstantValue 属性。 - Signature:保存泛型类型签名,供反射 API(
Field.getGenericType())使用。 - RuntimeVisibleAnnotations:
@Retention(RUNTIME)的注解,运行时可见。 - RuntimeInvisibleAnnotations:
@Retention(CLASS)的注解,仅编译期可见。 - Deprecated:标记该字段已废弃(
@Deprecated注解)。 - Synthetic:标记该字段为编译器合成(非源码显式声明)。
九、方法表(Methods)
9.1 method_info 结构
method_info { |
9.2 方法访问标记
| 标志名 | 值 | 含义 |
|---|---|---|
| ACC_PUBLIC | 0x0001 | 公有 |
| ACC_PRIVATE | 0x0002 | 私有 |
| ACC_PROTECTED | 0x0004 | 受保护 |
| ACC_STATIC | 0x0008 | 静态 |
| ACC_FINAL | 0x0010 | 不可覆盖(final) |
| ACC_SYNCHRONIZED | 0x0020 | 同步方法(JVM 自动获取/释放 this 的监视器) |
| ACC_BRIDGE | 0x0040 | 桥方法(编译器为泛型类型擦除生成) |
| ACC_VARARGS | 0x0080 | 可变参数方法 |
| ACC_NATIVE | 0x0100 | 本地方法(非 Java 实现) |
| ACC_ABSTRACT | 0x0400 | 抽象方法(无方法体) |
| ACC_STRICT | 0x0800 | 严格浮点计算(strictfp) |
| ACC_SYNTHETIC | 0x1000 | 编译器合成方法 |
9.3 方法描述符(Method Descriptor)
方法描述符的语法为:( ParameterDescriptor* ) ReturnDescriptor
其中 ReturnDescriptor 可以是 V(void)或有效类型的描述符。示例:
| Java 方法签名 | 描述符 |
|---|---|
void run() |
()V |
int add(int, int) |
(II)I |
String toString() |
()Ljava/lang/String; |
void set(int, String) |
(ILjava/lang/String;)V |
Object[] getArray(int, boolean) |
(IZ)[Ljava/lang/Object; |
long compute(int[], long, double) |
([IJD)J |
注意: 名称和描述符中不使用 ‘.’ 而使用 ‘/‘ 分隔包名。如 java.lang.String 表示为 Ljava/lang/String;。内部类的名称使用 $ 分隔(如 Outer$Inner)。
9.4 Code 属性:方法体的核心
Code 属性是 method_info 的属性表中最重要的属性,包含方法的实际字节码指令、异常处理表以及调试信息。其结构如下:
Code_attribute { |
关键字段说明:
max_stack:方法执行时操作数栈的最大深度(以字为单位,long/double 占 2 个字)。由编译器在生成字节码时通过栈映射分析确定,用于 class 文件验证阶段确保栈不会溢出。如果有
max_stack=3,表示操作数栈上同时最多有 3 个 32 位数或 1 个 64 位数加 1 个 32 位数。max_locals:局部变量表的槽位数量。实例方法中槽位 0 被
this引用占用;静态方法中槽位 0 是第一个参数。long 和 double 类型占用两个连续槽位(如一个 long 变量使用槽位 n 和 n+1)。**code[]**:实际的 JVM 字节码指令序列。每条指令由一个操作码字节(opcode)和可选的操作数字节组成。指令集的完整讲解见【深入理解JVM字节码】第二篇。
**exception_table[]**:每个异常处理器包含四个字段:
start_pc(u2):异常处理器生效的起始字节码偏移end_pc(u2):异常处理器生效的结束字节码偏移(不含)handler_pc(u2):异常发生时的跳转目标偏移catch_type(u2):捕获的异常类型在常量池中的索引(0 表示 catch-all,即 finally)
Code 属性内部的子属性:
LocalVariableTable:局部变量调试信息。每个局部变量包含:start_pc、length(生效范围)、name_index(变量名)、descriptor_index(类型描述符)、index(槽位号)。IDE 和调试器依赖此信息在断点时显示变量名和值。
LineNumberTable:字节码偏移到源码行号的映射。每条记录包含 start_pc 和 line_number。堆栈轨迹(stack trace)使用此信息将字节码位置翻译回源代码行号。
StackMapTable:验证类型信息。自 Java 6 引入(class 文件版本 50.0),JVM 使用 StackMapTable 进行类型检查(Type Checking)验证以替代旧式的类型推导(Type Inference)验证。这是 Java 字节码验证机制的一次重大升级。更多细节见【深入理解JVM字节码】第二篇。
RuntimeVisibleParameterAnnotations / RuntimeInvisibleParameterAnnotations:方法参数的注解信息。
9.5 异常处理器实例
以一段 try-catch-finally 代码为例:
public void readFile(String path) { |
编译后的异常表(Exception table):
Exception table: |
这里有三条记录:
- 偏移 0-13(try 块)内若发生 IOException,跳转到 16(catch 块)。
- 偏移 0-13 内任何异常(any),跳转到 28(finally 块)。
- 偏移 16-22(catch 块)内任何异常,跳转到 28(finally 块)。这是因为即使 catch 块内部也可能抛出异常,finally 必须保证执行。
finally 的实现并非一个独立的字节码指令,而是由编译器在每个可能的退出路径上复制 finally 代码块,并通过异常表覆盖所有异常路径。这正是 “编译期展开”(compile-time inlining of finally)的策略。
十、属性表(Attributes)
属性表是 class 文件中最具扩展性的设计。JVM 规范定义了大量的标准属性,同时允许自定义属性(JVM 应静默忽略无法识别的属性)。
10.1 类级属性(Class-level Attributes)
| 属性名 | 说明 |
|---|---|
| SourceFile | 源文件名(如 “HelloWorld.java”),仅用于调试 |
| Signature | 泛型类型签名,保留完整的泛型参数信息 |
| RuntimeVisibleAnnotations | 类上的运行时注解(如 @Deprecated, @SuppressWarnings) |
| RuntimeInvisibleAnnotations | 类上的编译期注解 |
| InnerClasses | 内部类列表,记录每个内部类与外部类的映射关系 |
| EnclosingMethod | 匿名类/局部类的封闭方法 |
| BootstrapMethods | invokedynamic 引导方法表(Lambda 表达式的核心依赖) |
| Module | 模块描述(module-info.class 中使用) |
| ModulePackages | 模块导出的包列表 |
| ModuleMainClass | 模块的主类 |
| NestHost | 嵌套类宿主(Java 11 引入,支持私有成员跨类访问) |
| NestMembers | 嵌套类成员列表 |
| Record | Record 类的组件描述(Java 14 引入) |
| PermittedSubclasses | 密封类的允许子类列表(Java 15 预览,17 正式) |
| Deprecated | 标记该类已废弃 |
| SourceDebugExtension | 附加的调试信息(如 SMAP:源码到字节码的行号映射,常用于 JSP/模板引擎) |
10.2 方法级属性(Method-level Attributes)
| 属性名 | 说明 |
|---|---|
| Code | 方法体字节码(见前文 9.4 节) |
| Exceptions | 方法声明的受检异常(throws 子句)列表 |
| Signature | 泛型方法签名 |
| RuntimeVisibleParameterAnnotations | 运行时可见的形参注解 |
| RuntimeInvisibleParameterAnnotations | 编译期可见的形参注解 |
| AnnotationDefault | 注解类型元素的默认值 |
| MethodParameters | 方法参数名称信息(Java 8 引入,-parameters 编译选项) |
十一、实战:完整 class 文件的逐字节解读
以下以一个最简单的 Java 类 HelloWorld.java 为例,逐字节解读其 class 文件:
public class HelloWorld { |
编译生成的 class 文件的完整十六进制转储(共约 430 字节):
Offset Hex Dump Annotation |
逐段解析(配合 javap -v 常量池输出):
#1 = Methodref #6.#12 // java/lang/Object."<init>":()V |
字节码指令部分(main 方法):
public static void main(java.lang.String[]); |
逐条指令分析:
getstatic #2(偏移 0):将静态字段System.out(类型为 PrintStream)压入操作数栈。ldc #3(偏移 3):将常量池中 #3 号常量(字符串 “Hello, JVM!”)压入操作数栈。invokevirtual #4(偏移 5):调用 PrintStream.println(String) 方法。弹出栈顶的两个值(参数和接收者),执行方法,返回值 void 不压栈。return(偏移 8):方法返回。
十二、class 文件 vs DEX 文件结构对比
Android 平台不使用 class 文件作为运行时格式,而是使用 DEX(Dalvik Executable)格式。DEX 的设计目标是减少内存占用和 I/O 开销。两者在结构上有本质差异:
12.1 常量管理方式
class 文件使用分散的常量池(Constant Pool),每个 class 文件独立维护一个常量池。这导致多个 class 文件之间存在大量重复的字符串常量(如 java/lang/Object、<init>、()V 等在几乎每个 class 文件中都会出现)。
DEX 文件使用全局统一索引。所有类、字符串、类型、方法等都被提取到全局表格中:
DEX 文件结构: |
关键优势: 字符串 "java/lang/Object" 在 DEX 文件的 string_ids[] 中只出现一次,所有引用它的 type_ids、field_ids、method_ids 都使用 2 字节或 4 字节的索引。这大幅减少了重复信息的存储,使得 DEX 文件通常比对应的 jar 包(包含多个 class 文件)小 30-50%。
12.2 类定义方式
class 文件:每个 class 文件独立包含一个类,通过 this_class、super_class、interfaces[] 三个独立字段描述类型关系。
DEX 文件:所有类统一定义在 class_defs[] 数组中,每个 class_def 包含:
class_def_item { |
12.3 指令集差异
- class 文件使用高效的栈式指令集,约 205 条指令。
- DEX 文件使用基于寄存器的指令集,指令数量更多(约 230+ 条指令),但执行时需要的指令条数更少(因为寄存器架构通常比栈式架构需要更少的指令来完成同一计算)。寄存器式指令也更适合移动设备的性能特性——减少了内存访问次数。
12.4 在 ART 中的加载逻辑
ART 加载 class/DEX 文件的核心路径在 art/runtime/class_linker.cc:
- OpenDexFilesFromOat:从 APK/JAR 中读取 DEX 文件。如果存在对应的 .vdex 或 .oat 文件(AOT 编译产物),则优先使用验证和编译缓存。
- DefineClass:根据类名在 DEX 文件的 class_defs 中查找类定义项(二进制搜索 type_ids),解析 class_def 结构,分配内存中的
mirror::Class对象。 - LoadClass:递归加载父类和接口类。
- LinkClass:构建虚方法表(vtable)、接口方法表(iftable),计算字段偏移量。
- EnsureInitialized:如果尚未初始化,执行
<clinit>方法(类的静态初始化)。
十三、Modified UTF-8 编码
13.1 UTF-8 标准编码原理回顾
UTF-8 是一种变长编码方式,使用 1~4 个字节表示一个 Unicode 字符。规则如下:
- 单字节(U+0000 ~ U+007F):
0xxxxxxx。ASCII 字符直接用一个字节表示。 - 双字节(U+0080 ~ U+07FF):
110xxxxx 10xxxxxx。去掉 110 和 10 前缀后的 11 位用于编码。 - 三字节(U+0800 ~ U+FFFF):
1110xxxx 10xxxxxx 10xxxxxx。去掉前缀后的 16 位用于编码。 - 四字节(U+010000 ~ U+10FFFF):
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx。去掉前缀后的 21 位用于编码。
13.2 MUTF-8 与标准 UTF-8 的区别
Java 在 class 文件的 CONSTANT_Utf8 中使用的是 **Modified UTF-8 (MUTF-8)**,而非标准 UTF-8。两者的关键区别有二:
区别一:空字符(U+0000)的编码
标准 UTF-8 用一个字节 0x00 表示空字符。MUTF-8 用两个字节 0xC0 0x80 表示空字符。
原因:在其他语言(如 C 语言)中,0x00 字节被用作字符串的结束标志(null terminator)。如果 class 文件中字符串的编码使用了 0x00,可能导致 C 语言实现的 JVM 在解析时意外截断字符串。MUTF-8 使用 0xC080 替代,避免了这个问题,因为 0xC080 永远不会在正常的 C 字符串中出现。其二进制表示为 11000000 10000000,解码时按双字节格式处理得到的码点为 0。
区别二:不使用四字节编码
MUTF-8 只使用 1、2、3 字节的编码形式,不使用标准 UTF-8 的 4 字节编码。
对于 U+FFFF 以上的补充字符(Supplementary Characters,如 emoji、罕见汉字等),MUTF-8 不使用 UTF-8 的 4 字节形式,而是先用 UTF-16 的代理对(Surrogate Pair)拆分为两个 UTF-16 编码单元,然后将这两个编码单元分别按三字节格式编码,最终占用 6 个字节。
实例:emoji 字符 “😀”(U+1F600)的编码
- Unicode 码点:U+1F600
- UTF-16 代理对:
D83D DE00(高代理\uD83D+ 低代理\uDE00) - UTF-8 标准编码(4 字节):
F0 9F 98 80 - MUTF-8 编码(6 字节):
ED A0 BD ED B8 80- 高代理 U+D83D 按三字节 UTF-8 编码:
1110 1101 10 100000 10 111101→ED A0 BD - 低代理 U+DE00 按三字节 UTF-8 编码:
1110 1101 10 111000 10 000000→ED B8 80
- 高代理 U+D83D 按三字节 UTF-8 编码:
为什么 MUTF-8 这样设计? 这是为了兼容 Java 早期的 Unicode 实现。Java 在 JDK 1.0 时期使用的是 UCS-2(每个字符固定 2 字节),后来在 JDK 5.0 迁移到 UTF-16。class 文件格式为兼容这一历史,采用了将代理对分别编码为三字节 MUTF-8 的策略。
在实际开发中,如果你手动解析 class 文件并遇到 6 字节序列 ED A0 80 ~ ED BF BF 范围内的模式,那就是 MUTF-8 编码的代理对。使用 Java 的 DataInputStream.readUTF() 可以正确解码 MUTF-8。
十四、ART 中的 class 加载与文件解析
本节从 ART 源码视角梳理 class/DEX 文件的加载和解析过程。
14.1 加载入口
- Java 层入口:
libcore/ojluni/src/main/java/java/lang/ClassLoader.java→loadClass(String name)→ 委派给父加载器 →findClass(String name)。 - Android 中:
dalvik.system.BaseDexClassLoader→DexPathList.findClass()→ 遍历 dex elements,调用DexFile.loadClassBinaryName()→ 进入 Native 方法。
14.2 Native 层 DefineClass
art/runtime/native/dalvik_system_DexFile.cc 中的 Native 方法 DefineClass 负责实际的类定义操作:
// 简化逻辑 |
14.3 ClassLinker 的角色
art/runtime/class_linker.cc 是整个类加载系统的中枢,其核心函数包括:
- FindClass:在已加载的类和 ClassLoader 委托链中定位类。先在 ClassTable 中查找(使用 descriptor 作为键),未找到则委派给 ClassLoader 的
loadClass。 - DefineClass:从 DEX 文件中解析类定义(class_def_item),在堆上分配
mirror::Class对象,设置 class 指针、access_flags 等基本元数据。 - LoadClass:确保父类和所有接口类已被加载(递归调用 LoadClass)。
- LinkClass:构建 vtable(虚方法表)和 iftable(接口方法表),计算字段偏移(field offset),执行方法重写的链接。
- EnsureInitialized:如果类尚未完成
<clinit>,在当前线程中执行类的静态初始化方法。
14.4 vtable 和 iftable 的构建
vtable(虚方法表)是 Java 多态性的核心机制。在 LinkClass 期间,ART 为每个类构建一份 vtable:
- 如果父类已有 vtable,复制父类的 vtable 作为基础。
- 遍历当前类定义的方法,如果某个方法与 vtable 中已有的某个方法同名同描述符(即 override),则用新的
ArtMethod*替换 vtable 中的对应槽位。 - 如果是新增的方法(父类中没有),追加到 vtable 末尾。
iftable(接口方法表)用于 invokeinterface 指令的分派。构建过程需要确定每个已实现的接口方法在 itable 中的位置。art/runtime/class_linker.cc 中的 LinkInterfaceMethods 和 LinkVirtualMethods 分别完成 iftable 和 vtable 的构建。
十五、实战:使用 javap 工具解读 class 文件
javap 是 JDK 自带的 class 文件反编译工具,是学习字节码最重要的工具之一。
常用选项:
javap -c HelloWorld.class # 反编译方法体(显示字节码助记符) |
关键标志位解读(来自 javap -v 输出):
flags: (0x0001) ACC_PUBLIC → public |
十六、设计启示与深层思考
16.1 class 文件格式为何如此设计
紧凑性优先:class 文件使用 u1/u2/u4 变长编码,配合常量池的符号引用机制,避免了字符串的重复存储。指令操作数通常只需要 1-2 字节的常量池索引,而非完整的字符串。
可扩展性:通过属性表(attribute_info)机制,JVM 规范可以在不破坏向后兼容性的前提下添加新的元数据。旧的 JVM 实现可以安全地忽略未知属性。
静态安全性:所有类型信息(max_stack、StackMapTable)在编译期确定并写入 class 文件,使得 JVM 在运行时不需要进行复杂的数据流分析即可验证字节码的安全性。
16.2 从 class 文件到平台无关执行
class 文件的平台无关性体现在:它包含的是抽象的字节码指令和符号引用,不包含任何具体平台的机器码或内存布局信息。具体来说:
- 操作数栈是抽象概念,不映射到特定 CPU 的寄存器。
- 类型描述符(如
I表示 int)是平台无关的,JVM 负责将其映射到目标平台的实际类型宽度(在 64 位系统上,int 仍然是 32 位)。 - 常量池中的符号引用在运行时才被解析为直接引用(内存地址),而解析的结果依赖于目标 JVM 的具体实现。
这些设计使得同一个 class 文件可以在 x86 架构的 Windows JVM、ARM 架构的 Linux JVM、以及 Android 的 ART 上无修改地运行——这正是”Write Once, Run Anywhere”的技术根基。
面试问答
Q1:class 文件的魔数是什么?如果魔数校验失败,JVM 会怎么做?
A:class 文件的魔数是 0xCAFEBABE(咖啡宝贝),占文件开头 4 个字节(大端序)。JVM 在加载一个类之前首先读取并校验这 4 个字节。如果不匹配,JVM 会立即抛出 java.lang.ClassFormatError 并停止加载该 class 文件。这个错误与 ClassNotFoundException 不同——后者说明类文件未找到,前者说明文件存在但格式不合法。DEX 文件也有类似的魔数校验机制,其魔数是 dex\n035 或 dex\n037(DEX 格式版本号不同)。校验逻辑等价于 class 文件的魔数检查。ART 中校验入口在 art/runtime/class_linker.cc 的 DEX 文件打开函数中。
Q2:为什么常量池索引从 1 而非 0 开始?CONSTANT_Long 和 CONSTANT_Double 为什么占用两个索引?
A:常量池索引从 1 开始是为了将索引 0 预留给”无效引用”语义。在异常表的 catch_type 字段(0 表示 catch-all)、BootstrapMethods 的 method_handle 引用等场景中需要表达”无”。CONSTANT_Long 和 CONSTANT_Double 占据两个索引槽位是出于历史设计原因——这两种常量值占用 8 字节,在 32 位架构的早期 JVM 中,常量池项按 32 位对齐,一个 64 位值必须占用两个槽位。为保持规范的一致性,即使在 64 位 JVM 中,这一规则依然保留。在实际编码中,如果 #5 是 CONSTANT_Double,则 #6 在常量池中不可用(被标记为 unusable)。
Q3:class 文件中的 Signature 属性的作用是什么?如果被去除了会有什么影响?
A:Signature 属性存储在 class 文件、field_info 或 method_info 的属性表中,用于保留泛型类型信息。由于 Java 泛型基于类型擦除,字节码层面的方法签名和字段描述符已经失去泛型参数。Signature 属性通过特殊的字符串编码(如 <T:Ljava/lang/Object;>Ljava/util/List<TT;>;)完整保存了原始泛型类型。它有两个关键用途:(1) Java 反射 API 通过读取 Signature 属性提供 Method.getGenericReturnType()、Field.getGenericType() 等泛型感知方法;(2) javac 编译下游代码时需要读取依赖类中的 Signature 属性来进行泛型类型检查。如果使用 ProGuard/R8 且未配置 -keepattributes Signature,Signature 属性会被剥离,导致 Gson、Jackson 等库无法正确反序列化泛型类型,Retrofit 无法解析返回类型的泛型参数。
Q4:MUTF-8 和标准 UTF-8 有什么区别?为什么 class 文件使用 MUTF-8?
A:MUTF-8(Modified UTF-8)是 UTF-8 在 Java class 文件中的变体,主要有两个区别:(1) U+0000(空字符)使用双字节 0xC080 而非单字节 0x00 表示,这是为了避免 C 语言中以 0x00 结尾的字符串处理逻辑意外截断 class 文件中的字符串数据;(2) U+FFFF 以上的补充字符不使用 UTF-8 四字节编码,而是先分解为 UTF-16 代理对(高代理 + 低代理),再对每个代理字符分别使用三字节编码,共占用 6 个字节。例如 emoji “😀”(U+1F600)标准 UTF-8 编码为 F0 9F 98 80(4 字节),而 MUTF-8 编码为 ED A0 BD ED B8 80(6 字节)。这是为了兼容 Java 早期(JDK 1.0)的 UCS-2 字符模型。
Q5:class 文件和 DEX 文件在结构上有哪些核心差异?这种差异的设计动机是什么?
A:核心差异有四点。(1) 常量管理:class 文件每个文件内置独立的常量池,多个 class 文件间存在大量重复;DEX 文件使用全局统一的 string_ids/type_ids/proto_ids/field_ids/method_ids,所有信息只存储一次。(2) 类定义:class 文件通过 this_class/super_class/interfaces[] 三个独立字段描述类型关系;DEX 文件将所有类统一定义在 class_defs[] 数组中。(3) 指令集:class 文件使用栈式指令集(约 205 条指令),DEX 文件使用基于寄存器的指令集(约 230+ 条指令),寄存器式指令在移动设备上需要的指令执行次数更少。(4) DEX 文件大小比等效的 class 文件 jar 包小 30-50%,主要得益于全局字符串去重和更紧凑的编码。设计动机是在移动设备的存储和内存受限环境中最大化空间效率,同时保持足够的运行时性能。
Q6:Code 属性中的 max_stack 和 max_locals 是如何确定的?为什么需要在 class 文件中存储这些值?
A:max_stack 和 max_locals 由 Java 编译器在代码生成阶段通过栈映射分析(Stack Map Analysis)确定。编译器遍历所有可能的控制流路径,跟踪每条路径上的操作数栈深度和局部变量使用情况,取最大值。譬如 javac 源码中 com.sun.tools.javac.jvm.Gen 类负责这一分析。这些值存储在 class 文件中供 JVM 验证器使用——验证器在类加载时检查每条字节码指令前后的栈深度是否一致、局部变量槽位访问是否越界。如果未在 class 文件中预先声明这些最大值,JVM 就需要在加载时进行完整的数据流分析来确定,这将显著增加类加载时间。预先计算并存储这些值是编译期优化类加载性能的设计。
Q7:ACC_SUPER 标志在现代 Java 中还有什么用?为什么标准 javac 总是设置它?
A:ACC_SUPER 标志的历史背景是 Java 1.0.2 时期 invokespecial 指令在处理父类方法调用时存在两种不同的语义模式。设置 ACC_SUPER 标志后 JVM 使用修正后的语义——即 invokespecial 对父类方法的调用严格在编译期确定的父类中进行,不执行虚方法分派。未设置该标志时,某些旧的 JVM 实现可能采用不同的行为。自 Java 1.0.2 之后的版本开始,JVM 规范要求所有新编译的 class 文件必须设置此标志。现代 javac 总是设置 ACC_SUPER,所以它在当代 Java 开发中主要是一个”历史遗留标志”——你几乎不会看到 class 文件不包含此标志,除非处理非常古老的遗留 class 文件(JDK 1.0.2 时代编译)。






