类文件结构

在我们学习C语言的时候,我们的编程过程会经历如下几个阶段:写代码、保存、编译、运行。实际上,最关键的一步是编译,因为只有经历了编译之后,我们所编写的代码才能够翻译为机器可以直接运行的二进制代码,并且在不同的操作系统下,我们的代码都需要进行一次编译之后才能运行。

如果全世界所有的计算机指令集只有x86一种,操作系统只有Windows一种,那也许就不会有Java语言的出现。

随着时代的发展,人们迫切希望能够在不同的操作系统、不同的计算机架构中运行同一套编译之后的代码。本地代码不应该是我们编程的唯一选择,所以,越来越多的语言选择了与操作系统和机器指令集无关的中立格式作为编译后的存储格式。

“一次编写,到处运行”,Java最引以为傲的口号,标志着平台不再是限制编程语言的阻碍。

实际上,Java正式利用了这样的解决方案,将源代码编译为平台无关的中间格式,并通过对应的Java虚拟机读取和运行这些中间格式的编译文件,这样,我们只需要考虑不同平台的虚拟机如何编写,而Java语言本身很轻松地实现了跨平台。

现在,越来越多的开发语言都支持将源代码编译为.class字节码文件格式,以便能够直接交给JVM运行,包括Kotlin(安卓开发官方指定语言)、Groovy、Scala等。

avatar

那么,让我们来看看,我们的源代码编译之后,是如何保存在字节码文件中的。


类文件信息

我们之前都是使用javap命令来对字节码文件进行反编译查看的,那么,它以二进制格式是怎么保存呢?我们可以使用WinHex软件(Mac平台可以使用010 Editor)来以十六进制查看字节码文件。

1
2
3
4
5
6
7
public class Main {
public static void main(String[] args) {
int i = 10;
int a = i++;
int b = ++i;
}
}

找到我们在IDEA中编译出来的class文件,将其拖动进去:

avatar

可以看到整个文件中,全是一个字节一个字节分组的样子,从左上角开始,一行一行向下读取。

实际上Class文件采用了一种类似于C中结构体的伪结构来存储数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ClassFile {
u4 magic; //Class 文件的标志
u2 minor_version;//Class 的小版本号
u2 major_version;//Class 的大版本号
u2 constant_pool_count;//常量池的数量
cp_info constant_pool[constant_pool_count-1];//常量池
u2 access_flags;//Class 的访问标记
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];//属性表集合
}

而Class文件中,有两种允许存在的数据类型,一个是无符号数,还有一个是表。

  • 无符号数一般是基本数据类型,用u1、u2、u4、u8来表示,表示1个字节~8个字节的无符号数。可以表示数字、索引引用、数量值或是以UTF-8编码格式的字符串。
  • 表包含多个无符号数,并且以”_info”结尾。

我们首先从最简单的开始看起。

avatar

首先,我们可以看到,前4个字节(共32位)组成了魔数(其实就是表示这个文件是一个JVM可以运行的字节码文件,除了Java以外,其他某些文件中也采用了这种魔数机制来进行区分,这种方式比直接起个文件扩展名更安全)

字节码文件的魔数为:CAFEBABE(这名字能想出来也是挺难的了,毕竟4个bit位只能表示出A-F这几个字母)

紧接着魔数的后面4个字节存储的是字节码文件的版本号,注意前两个是次要版本号(现在基本都不用了,都是直接Java8、Java9这样命名了),后面两个是主要版本号,这里我们主要看主版本号,比如上面的就是34,注意这是以16进制表示的,我们把它换算为10进制后,得到的结果为:34 -> 3*16 + 4 = 52,其中52代表的是JDK8编译的字节码文件(51是JDK7、50是JDK6、53是JDK9,以此类推)

JVM会根据版本号决定是否能够运行,比如JDK6只能支持版本号为1.1~6的版本,也就是说必须是Java6之前的环境编译出来的字节码文件,否则无法运行。又比如我们现在安装的是JDK8版本,它能够支持的版本号为1.1~8,那么如果这时我们有一个通过Java7编译出来的字节码文件,依然是可以运行的,所以说Java版本是向下兼容的。

紧接着,就是类的常量池了,这里面存放了类中所有的常量信息(注意这里的常量并不是指我们手动创建的final类型常量,而是程序运行一些需要用到的常量数据,比如字面量和符号引用等)由于常量的数量不是确定的,所以在最开始的位置会存放常量池中常量的数量(是从1开始计算的,不是0,比如这里是18,翻译为10进制就是24,所以实际上有23个常量)

接着再往下,就是常量池里面的数据了,常量池中每一项常量都是一个表,这 14 种表有一个共同的特点:开始的第一位是一个 u1 类型的标志位 -tag 来标识常量的类型,代表当前这个常量属于哪种常量类型,都是以_info结尾

类型标志描述
CONSTANT_Utf8_info1UTF-8编码格式的字符串
CONSTANT_Integer_info3整形字面量(第一章我们演示的很大的数字,实际上就是以字面量存储在常量池中的)
CONSTANT_Float_info4浮点型字面量
CONSTANT_Long_info5长整型字面量
CONSTANT_Double_info6双精度浮点型字面量
CONSTANT_Class_info7类或接口的符号引用
CONSTANT_String_info8字符串类型的字面量
CONSTANT_Fieldref_info9字段的符号引用
CONSTANT_Methodref_info10方法的符号引用
CONSTANT_InterfaceMethodref_info11接口方法的符号引用
CONSTANT_NameAndType_info12字段或方法的符号引用
CONSTANT_MethodType_info16方法类型
CONSTANT_MethodHandle_info15表示方法句柄
CONSTANT_InvokeDynamic_info18表示一个动态方法调用点

比如我们来看第一个CONSTANT_Methodref_info表中存放了什么数据,这里我只列出它的结构表(详细的结构表可以查阅《深入理解Java虚拟机 第三版》中222页总表):

常量项目类型描述
CONSTANT_Methodref_infotagu1值为10
indexu2指向声明方法的类描述父CONSTANT_Class_info索引项
indexu2指向名称及类型描述符CONSTANT_NameAndType_info索引项

在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口,是否为 public 或者 abstract 类型,如果是类的话是否声明为 final 等等。

1
u2             access_flags;//Class 的访问标记

可以看到它只占了2个字节,那么它是如何表示访问标志呢?

avatar

比如我们这里的Main类,它是一个普通的class类型,并且访问权限为public,那么它的访问标志值是这样计算的:

ACC_PUBLIC | ACC_SUPER = 0x0001 | 0x0020 = 0x0021(这里进行的是按位或运算),可以看到和我们上面的结果是一致的。

再往下就是类索引、父类索引、接口索引:

1
2
3
4
u2             this_class;//当前类
u2 super_class;//父类
u2 interfaces_count;//接口数量
u2 interfaces[interfaces_count];//一个类可以实现多个接口

avatar

可以看到它们的值也是指向常量池中的值,其中2号常量正是存储的当前类信息,3号常量存储的是父类信息,这里就不再倒推回去了,由于没有接口,所以这里接口数量为0,如果不为0还会有一个索引表来引用接口。

Java 类的继承关系由类索引、父类索引和接口索引集合三项确定。类索引、父类索引和接口索引集合按照顺序排在访问标志之后,类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按 implements (如果这个类本身是接口的话则是extends) 后的接口顺序从左到右排列在接口索引集合中。

接着就是字段表了:

1
2
u2             fields_count;//字段数量
field_info fields[fields_count];//一个类会可以有个字段

由于我们这里没有声明任何字段,所以我们先给Main类添加一个字段再重新加载一下:

1
2
3
4
5
6
7
8
9
10
public class Main {

public static int a = 10;

public static void main(String[] args) {
int i = 10;
int a = i++;
int b = ++i;
}
}

avatar

  • access_flags: 字段的作用域(public ,private,protected修饰符),是实例变量还是类变量(static修饰符),可否被序列化(transient 修饰符),可变性(final),可见性(volatile 修饰符,是否强制从主内存读写)。
  • name_index: 对常量池的引用,表示的字段的名称;
  • descriptor_index: 对常量池的引用,表示字段和方法的描述符;
  • attributes_count: 一个字段还会拥有一些额外的属性,attributes_count 存放属性的个数;
  • attributes[attributes_count]: 存放具体属性具体内容。

上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型这些都是无法固定的,只能引用常量池中常量来描述。

接着就是我们的方法表了:

1
2
u2             methods_count;//方法数量
method_info methods[methods_count];//一个类可以有个多个方法

methods_count 表示方法的数量,而 method_info 表示方法表。

Class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。

method_info(方法表的) 结构:

avatar

最后,就是属性表了:

1
2
u2             attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合

在 Class 文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。与 Class 文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性。