【JVM从入门到放弃】1.class文件结构

109 阅读12分钟

Java程序是运行在 Java 虚拟机(JVM)上的, JVM 屏蔽了各个操作系统之间的差异, 使得 Java 代码只需要写一遍, 就可以在多个操作系统上运行。

Java源代码经过编译后会生成class文件, 我们首先来看一下class文件的结构。

类型名称说明
u4magic魔数
u2minor_version副版本号
u2major_version主版本号
u2constant_pool_count常量池中常量的个数
cp_infoconstant_pool常量池
u2access_flags访问标志
u2this_class类索引
u2super_class父类索引
u2interfaces_count接口个数
u2interfaces接口索引集合
u2fields_count字段个数
field_infofields字段集合
u2methods_count方法个数
method_infomethods方法集合
u2attributes_count附加属性个数
attribute_infoattributes附加属性集合

class 文件中定义了两种数据类型:

  1. u1、u2、u4、u8 分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数
  2. 以 _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_file_hex.png

接下来, 我们来分析一下这个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 文件中的常量池内容如下:

class_file_cp.png

使用 javap 命令可以更方便的查看 class 文件的内容:

javap -verbose ClassFileDemo.class

其中常量池部分如下:

javap_cp.png

访问标志

在常量池的部分结束之后, 接下来的是访问标志(access_flags), 这个标志用于识别一些类或者接口层次的访问信息, 包括: 这个 class 文件描述的是类还是接口、这个类(或接口)是否定义为 public、是否定义为 abstract、如果是类的话, 是否被声明为 final 等等。

访问标志的定义如下:

标志名称标志值说明
ACC_PUBLIC0x0001是否为 public 类型
ACC_FINAL0x0010是否被声明为 final, 只有类可设置
ACC_SUPER0x0020是否允许使用 invokevirtual 字节码指令的新语义, JDK1.0.2 之后编译出来的类的这个标志都必须为真
ACC_INTERFACE0x0200标识这是一个接口
ACC_ABSTRACT0x0400是否为 abstract 类型, 对于接口或者抽象类来说, 此标志值为真, 其他类型值为假
ACC_SYNTHETIC0x1000标识这个类并非由用户代码产生的
ACC_ANNOTATION0x2000标识这是一个注解
ACC_ENUM0x4000标识这是一个枚举
ACC_MODULE0x8000标识这是一个模块

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:

class_file_af.png

类索引

类索引(this_class)是一个 u2 类型的数据, 用于确定这个类的全名。它的索引值指向常量池中 CONSTANT_Class 类型的常量。而 CONSTANT_Class 类型的常量中有一个索引值又指向了常量池中 CONSTANT_Utf8 类型的常量。CONSTANT_Utf8 常量中使用 UTF-8 编码的字符串存储了这个类的全名。

ClassFileDemo.class 文件中类索引的值是 0x0008, 指向了常量池中索引为 8 的常量。查看常量池, 可以看到索引 8 的常量正是 CONSTANT_Class 类型的常量, 它又指向了索引 10 的字符串常量"ClassFileDemo"。

class_file_tc2.png

父类索引

父类索引和类索引基本一样, 只不过父类索引是用于确定这个类的父类。

由于 Java 不允许多重继承, 所以父类索引只有一个, 除了 java.lang.Object 之外, 所有 Java 类的父类索引都不为 0。

ClassFileDemo.class 文件中类索引的值是 0x0002, 最终指向了字符串常量"java/lang/Object"。

接口

接口个数(interfaces_count)和接口索引集合(interfaces)用来描述这个类实现的接口。接口个数表示这个类实现了几个接口, 这些被实现的接口将按 implements 关键字后的接口顺序从左到右排列在接口索引集合中。

接口个数是一个 u2 类型的数据, 表示接口索引集合的容量。如果该类没有实现任何接口, 则接口个数值为 0, 后面的接口索引集合不再占用任何字节。如果实现了接口, 则接口索引集合中会有一组接口索引, 每个接口索引占 2 个字节, 指向常量池中的 CONSTANT_Class 常量。

ClassFileDemo 没有实现任何接口, 所以它的接口个数为 0, 接口索引集合也不再占用任何字节。

class_file_ic.png

字段

Java 中的字段(Field)包括类变量和实例变量, 通过 class 文件中的字段个数(fields_count)和字段集合(fields)共同表示。字段个数表示类中定义了多少个字段, 这些字段存放在字段集合中, 字段集合由字段表(field_info)组成。字段集合中不会列出从父类或者父接口中继承而来的字段, 但有可能会有编译器自动添加的字段。

字段表(field_info)用于描述接口或者类中声明的字段, 定义如下:

类型名称数量
u2access_flags1
u2name_index1
u2descriptor_index1
u2attributes_count1
attribute_infoattributesattributes_count

name_index 和 descriptor_index 都是对常量池中常量的引用, 分别代表着字段名以及字段的描述符。attributes_count 表示 attributes 中元素的个数, attributes 保存了字段的附加属性。access_flags 是字段的访问标志, 用于表示字段是否为 public, 是否为 static 等等。

access_flags 的结构如下:

标志说明
ACC_PUBLIC0x0001字段是否 public
ACC_PRIVATE0x0002字段是否 private
ACC_PROTECTED0x0004字段是否 protected
ACC_STATIC0x0008字段是否 static
ACC_FINAL0x0010字段是否 final
ACC_VOLATILE0x0040字段是否 volatile
ACC_TRANSIENT0x0080字段是否 transient
ACC_SYNTHETIC0x1000字段是否由编译器自动生成
ACC_ENUM0x4000字段是否 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 文件中的数据如下:

class_file_f.png

access_flags 为 0x0000, 表示字段没有修饰符。name_index 为 0x000B, 指向常量池中索引为 11 的值"num"。descriptor_index 为 0x000C, 指向常量池中索引为 12 的值"I"。attributes_count 为 0, 表示这个字段没有额外的属性。

方法

Java 中的方法使用方法个数(methods_count)和方法集合(methods)描述。方法集合由方法表(method_info)组成。如果父类方法在子类中没有被重写, 方法集合中就不会出现来自父类的方法。但有可能会出现由编译器自动添加的方法, 比如类构造器<clinit>()方法和实例构造器<init>()方法。

方法表的结构与字段表一样:

类型名称数量
u2access_flags1
u2name_index1
u2descriptor_index1
u2attributes_count1
attribute_infoattributesattributes_count

name_index 和 descriptor_index 在方法表中分别代表着方法名以及方法的描述符, attributes 保存了方法的附加属性。方法描述符与字段的描述符定义也完全一致。

有区别的是方法的访问标志(access_flags), 定义如下:

标志说明
ACC_PUBLIC0x0001方法是否 public
ACC_PRIVATE0x0002方法是否 private
ACC_PROTECTED0x0004方法是否 protected
ACC_STATIC0x0008方法是否 static
ACC_FINAL0x0010方法是否 final
ACC_SYNCHRONIZED0x0020方法是否为 synchronized
ACC_BRIDGE0x0040方法是否为编译器产生的桥接方法
ACC_VARARGS0x0080方法是否接受不定参数
ACC_NATIVE0x0100方法是否为 native
ACC_ABSTRACT0x0400方法是否为 abstract
ACC_STRICT0x0800方法是否为 strictfp
ACC_SYNTHETIC0x1000方法是否由编译器自动产生

用描述符来描述方法时, 按照先参数列表、后返回值的顺序描述, 参数列表按照参数的顺序放在一组小括号之内。

以下是一些方法描述符的示例:

// ()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() 方法。

图中标出了方法个数和方法集合中的第一个方法:

class_file_fc1.png

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 去说明属性值所占用的位数即可。

属性表的通用结构如下:

类型名称数量
u2attribute_name_index1
u4attribute_length1
u1各属性自定义attribute_length

我们看一下 ClassFileDemo.class 中的各个属性。

首先是 <init>() 方法, 它有一个属性, 属性名是常量池中索引为 13(0x000D)的"Code", Code 属性的长度是 29 个字节(0x0000001D):

class_file_a1.png

第二个方法是 getNum(), 它同样只有一个 Code 属性, 它的 Code 属性的长度也是 29 个字节(0x0000001D):

class_file_a2.png

方法部分结束以后, 就是 class 文件自己的附加属性了, ClassFileDemo.class 有 1 个属性, 属性名是常量池中索引为 17(0x0011)的"SourceFile", SourceFile 属性用来标识这个 class 文件的源代码是哪个文件, 它的长度是 2 个字节(0x00000002), 最后的两字节 0x0012 就是源代码的文件名, 它指向常量池中索引为 18 的字符串"ClassFileDemo.java":

class_file_a3.png

至此, 整个 ClassFileDemo.class 文件就全部结束了。