Java程序是运行在 Java 虚拟机(JVM)上的, JVM 屏蔽了各个操作系统之间的差异, 使得 Java 代码只需要写一遍, 就可以在多个操作系统上运行。
Java源代码经过编译后会生成class文件, 我们首先来看一下class文件的结构。
| 类型 | 名称 | 说明 |
|---|---|---|
| u4 | magic | 魔数 |
| u2 | minor_version | 副版本号 |
| u2 | major_version | 主版本号 |
| u2 | constant_pool_count | 常量池中常量的个数 |
| cp_info | constant_pool | 常量池 |
| u2 | access_flags | 访问标志 |
| u2 | this_class | 类索引 |
| u2 | super_class | 父类索引 |
| u2 | interfaces_count | 接口个数 |
| u2 | interfaces | 接口索引集合 |
| u2 | fields_count | 字段个数 |
| field_info | fields | 字段集合 |
| u2 | methods_count | 方法个数 |
| method_info | methods | 方法集合 |
| u2 | attributes_count | 附加属性个数 |
| attribute_info | attributes | 附加属性集合 |
class 文件中定义了两种数据类型:
- u1、u2、u4、u8 分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数
- 以 _info 结尾的类型称为表。表是由多个无符号数或者其他表构成的复合类型, 整个 class 文件也可以视作是一张表
编译一个 java 文件
创建一个名为 ClassFileDemo.java 的文件, 输入下面内容:
public class ClassFileDemo {
int num;
public int getNum() {
return this.num;
}
}
打开命令行, 使用 javac 编译:
javac ClassFileDemo.java
在同一目录下会生成一个 ClassFileDemo.class 文件, 由于 class 文件不是文本文件, 无法用文本编辑器直接打开, 需要使用 16 进制编辑器打开。
我们用一个16进制编辑器: HxDHexEditor 打开这个class文件:
接下来, 我们来分析一下这个class文件。
魔数
每个 class 文件的头 4 个字节被称为魔数(Magic Number), 它的唯一作用是表示这个文件是一个 class 文件, 固定为 0xCAFEBABE。
版本号
魔数后面的 4 个字节是 class 文件的版本号: 第 5 和第 6 个字节是副版本号(Minor Version), 第 7 和第 8 个字节是主版本号(Major Version)。
Java 的版本号是从 45 开始的, JDK 1.1 之后的每个 JDK 大版本发布主版本号向上加 1, 高版本的 JDK 能向下兼容以前版本的 class 文件, 但不能运行以后版本的 class 文件。
ClassFileDemo.class 文件中的版本号 0x0041.0x0000 转换成十进制是 65.0, 即 JDK 21。
常量池
常量池包含了 class 文件中用到的各种字符串常量、类和接口名、字段和方法名等符号引用,以及字面量常量值等信息。
主版本号后面的 2 个字节是 constant_pool_count, 它表示常量池中常量的个数。
ClassFileDemo.class 文件中的 constant_pool_count 是 0x0013, 转换成十进制是 19, 代表常量池中有 18 项常量, 索引范围是 1 ~ 18 (常量池的索引是从 1 开始的)。
ClassFileDemo.class 文件中的常量池内容如下:
使用 javap 命令可以更方便的查看 class 文件的内容:
javap -verbose ClassFileDemo.class
其中常量池部分如下:
访问标志
在常量池的部分结束之后, 接下来的是访问标志(access_flags), 这个标志用于识别一些类或者接口层次的访问信息, 包括: 这个 class 文件描述的是类还是接口、这个类(或接口)是否定义为 public、是否定义为 abstract、如果是类的话, 是否被声明为 final 等等。
访问标志的定义如下:
| 标志名称 | 标志值 | 说明 |
|---|---|---|
| ACC_PUBLIC | 0x0001 | 是否为 public 类型 |
| ACC_FINAL | 0x0010 | 是否被声明为 final, 只有类可设置 |
| ACC_SUPER | 0x0020 | 是否允许使用 invokevirtual 字节码指令的新语义, JDK1.0.2 之后编译出来的类的这个标志都必须为真 |
| ACC_INTERFACE | 0x0200 | 标识这是一个接口 |
| ACC_ABSTRACT | 0x0400 | 是否为 abstract 类型, 对于接口或者抽象类来说, 此标志值为真, 其他类型值为假 |
| ACC_SYNTHETIC | 0x1000 | 标识这个类并非由用户代码产生的 |
| ACC_ANNOTATION | 0x2000 | 标识这是一个注解 |
| ACC_ENUM | 0x4000 | 标识这是一个枚举 |
| ACC_MODULE | 0x8000 | 标识这是一个模块 |
access_flags 中一共有 16 个标志位可以使用, 当前只定义了其中 9 个, 没有使用到的标志位要求一律为零。
如果一个类既是 public 的, 又是 final 的, 而且使用 JDK1.0.2 之后的 JDK 编译, 那么它的访问标志就是 ACC_PUBLIC, ACC_FINAL, ACC_SUPER 这三个的标志值相加的结果: 0x0031。
ClassFileDemo.class 文件中定义了一个 public 的类: ClassFileDemo, 并且它使用了 JDK 21 进行编译, 因此它应该包含 ACC_PUBLIC 和 ACC_SUPER 这两个标志, 所以它的访问标志是 0x0021。
查看 ClassFileDemo.class 文件中访问标志的值, 可以看到正是 0x0021:
类索引
类索引(this_class)是一个 u2 类型的数据, 用于确定这个类的全名。它的索引值指向常量池中 CONSTANT_Class 类型的常量。而 CONSTANT_Class 类型的常量中有一个索引值又指向了常量池中 CONSTANT_Utf8 类型的常量。CONSTANT_Utf8 常量中使用 UTF-8 编码的字符串存储了这个类的全名。
ClassFileDemo.class 文件中类索引的值是 0x0008, 指向了常量池中索引为 8 的常量。查看常量池, 可以看到索引 8 的常量正是 CONSTANT_Class 类型的常量, 它又指向了索引 10 的字符串常量"ClassFileDemo"。
父类索引
父类索引和类索引基本一样, 只不过父类索引是用于确定这个类的父类。
由于 Java 不允许多重继承, 所以父类索引只有一个, 除了 java.lang.Object 之外, 所有 Java 类的父类索引都不为 0。
ClassFileDemo.class 文件中类索引的值是 0x0002, 最终指向了字符串常量"java/lang/Object"。
接口
接口个数(interfaces_count)和接口索引集合(interfaces)用来描述这个类实现的接口。接口个数表示这个类实现了几个接口, 这些被实现的接口将按 implements 关键字后的接口顺序从左到右排列在接口索引集合中。
接口个数是一个 u2 类型的数据, 表示接口索引集合的容量。如果该类没有实现任何接口, 则接口个数值为 0, 后面的接口索引集合不再占用任何字节。如果实现了接口, 则接口索引集合中会有一组接口索引, 每个接口索引占 2 个字节, 指向常量池中的 CONSTANT_Class 常量。
ClassFileDemo 没有实现任何接口, 所以它的接口个数为 0, 接口索引集合也不再占用任何字节。
字段
Java 中的字段(Field)包括类变量和实例变量, 通过 class 文件中的字段个数(fields_count)和字段集合(fields)共同表示。字段个数表示类中定义了多少个字段, 这些字段存放在字段集合中, 字段集合由字段表(field_info)组成。字段集合中不会列出从父类或者父接口中继承而来的字段, 但有可能会有编译器自动添加的字段。
字段表(field_info)用于描述接口或者类中声明的字段, 定义如下:
| 类型 | 名称 | 数量 |
|---|---|---|
| u2 | access_flags | 1 |
| u2 | name_index | 1 |
| u2 | descriptor_index | 1 |
| u2 | attributes_count | 1 |
| attribute_info | attributes | attributes_count |
name_index 和 descriptor_index 都是对常量池中常量的引用, 分别代表着字段名以及字段的描述符。attributes_count 表示 attributes 中元素的个数, attributes 保存了字段的附加属性。access_flags 是字段的访问标志, 用于表示字段是否为 public, 是否为 static 等等。
access_flags 的结构如下:
| 标志 | 值 | 说明 |
|---|---|---|
| ACC_PUBLIC | 0x0001 | 字段是否 public |
| ACC_PRIVATE | 0x0002 | 字段是否 private |
| ACC_PROTECTED | 0x0004 | 字段是否 protected |
| ACC_STATIC | 0x0008 | 字段是否 static |
| ACC_FINAL | 0x0010 | 字段是否 final |
| ACC_VOLATILE | 0x0040 | 字段是否 volatile |
| ACC_TRANSIENT | 0x0080 | 字段是否 transient |
| ACC_SYNTHETIC | 0x1000 | 字段是否由编译器自动生成 |
| ACC_ENUM | 0x4000 | 字段是否 enum |
access_flags 需要满足以下几个条件:
- ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED 不能同时选择
- ACC_FINAL、ACC_VOLATILE 不能同时选择
- 接口之中的字段必须有 ACC_PUBLIC、ACC_STATIC、ACC_FINAL 标志
接下来看一下字段描述符, 它的定义如下:
| 标识字符 | 说明 |
|---|---|
| B | 基本类型 byte |
| C | 基本类型 char |
| D | 基本类型 double |
| F | 基本类型 float |
| I | 基本类型 int |
| J | 基本类型 long |
| S | 基本类型 short |
| Z | 基本类型 boolean |
| V | 特殊类型 void |
| L | 对象类型, 比如 Ljava/lang/Object |
对于数组类型, 每一维度将使用一个前置的[字符来描述, 比如二维数组String[][]的字段描述符是: [[Ljava/lang/String, 一个整型数组int[]的字段描述符是: [I。
ClassFileDemo 类中只有一个字段: int num, class 文件中的数据如下:
access_flags 为 0x0000, 表示字段没有修饰符。name_index 为 0x000B, 指向常量池中索引为 11 的值"num"。descriptor_index 为 0x000C, 指向常量池中索引为 12 的值"I"。attributes_count 为 0, 表示这个字段没有额外的属性。
方法
Java 中的方法使用方法个数(methods_count)和方法集合(methods)描述。方法集合由方法表(method_info)组成。如果父类方法在子类中没有被重写, 方法集合中就不会出现来自父类的方法。但有可能会出现由编译器自动添加的方法, 比如类构造器<clinit>()方法和实例构造器<init>()方法。
方法表的结构与字段表一样:
| 类型 | 名称 | 数量 |
|---|---|---|
| u2 | access_flags | 1 |
| u2 | name_index | 1 |
| u2 | descriptor_index | 1 |
| u2 | attributes_count | 1 |
| attribute_info | attributes | attributes_count |
name_index 和 descriptor_index 在方法表中分别代表着方法名以及方法的描述符, attributes 保存了方法的附加属性。方法描述符与字段的描述符定义也完全一致。
有区别的是方法的访问标志(access_flags), 定义如下:
| 标志 | 值 | 说明 |
|---|---|---|
| ACC_PUBLIC | 0x0001 | 方法是否 public |
| ACC_PRIVATE | 0x0002 | 方法是否 private |
| ACC_PROTECTED | 0x0004 | 方法是否 protected |
| ACC_STATIC | 0x0008 | 方法是否 static |
| ACC_FINAL | 0x0010 | 方法是否 final |
| ACC_SYNCHRONIZED | 0x0020 | 方法是否为 synchronized |
| ACC_BRIDGE | 0x0040 | 方法是否为编译器产生的桥接方法 |
| ACC_VARARGS | 0x0080 | 方法是否接受不定参数 |
| ACC_NATIVE | 0x0100 | 方法是否为 native |
| ACC_ABSTRACT | 0x0400 | 方法是否为 abstract |
| ACC_STRICT | 0x0800 | 方法是否为 strictfp |
| ACC_SYNTHETIC | 0x1000 | 方法是否由编译器自动产生 |
用描述符来描述方法时, 按照先参数列表、后返回值的顺序描述, 参数列表按照参数的顺序放在一组小括号之内。
以下是一些方法描述符的示例:
// ()V
void inc();
// ()Ljava/lang/String
String toString();
// ([CII[CIII)I
int indexOf(char[]source,int sourceOffset,int sourceCount,char[]target,int targetOffset,int targetCount,int fromIndex);
方法的定义可以通过访问标志、名称索引、描述符索引来表达清楚, 而方法里面的代码经过编译后, 会存放在方法的附加属性(attributes)中一个名为 Code 的属性(attribute_info)里面。
ClassFileDemo 类中有两个方法, 一个是编译器自动生成的构造器 <init>() 方法, 另一个是源码中的 getNum() 方法。
图中标出了方法个数和方法集合中的第一个方法:
access_flags 为 0x0001, 表示这个方法的修饰符是 ACC_PUBLIC。name_index 为 0x0005, 指向常量池中索引为 5 的值"<init>"。descriptor_index 为 0x0006, 指向常量池中索引为 6 的值"()V"。attributes_count 为 1, 表示这个方法有 1 个附加属性。
属性表
class 文件、字段表、方法表都可以携带自己的属性表集合(attributes), 用来存储它们自己的信息。
对于每一个属性, 它的名称都要从常量池中引用一个 CONSTANT_Utf8 类型的常量来表示, 而属性值的结构则是完全自定义的, 只需要通过一个 u4 的 attribute_length 去说明属性值所占用的位数即可。
属性表的通用结构如下:
| 类型 | 名称 | 数量 |
|---|---|---|
| u2 | attribute_name_index | 1 |
| u4 | attribute_length | 1 |
| u1 | 各属性自定义 | attribute_length |
我们看一下 ClassFileDemo.class 中的各个属性。
首先是 <init>() 方法, 它有一个属性, 属性名是常量池中索引为 13(0x000D)的"Code", Code 属性的长度是 29 个字节(0x0000001D):
第二个方法是 getNum(), 它同样只有一个 Code 属性, 它的 Code 属性的长度也是 29 个字节(0x0000001D):
方法部分结束以后, 就是 class 文件自己的附加属性了, ClassFileDemo.class 有 1 个属性, 属性名是常量池中索引为 17(0x0011)的"SourceFile", SourceFile 属性用来标识这个 class 文件的源代码是哪个文件, 它的长度是 2 个字节(0x00000002), 最后的两字节 0x0012 就是源代码的文件名, 它指向常量池中索引为 18 的字符串"ClassFileDemo.java":
至此, 整个 ClassFileDemo.class 文件就全部结束了。