(拉钩)Android工程师进阶34讲-03:字节码层面分析class类文件结构

0. 前言

面试题:Java中String字符串的长度有限制吗?

平时开发中,经常会用到String来声明字符串,比如String str = "abc";,但是有没有想过等于号后面的字符串常量有没有长度限制。要想知道这个,就需要先学习class文件。

1. class的来龙去脉

Java能实现“一次编译,到处运行”,这其中class文件占大部分功劳。为了让Java语言有良好的跨平台能力,Java提供了一种可以在所有平台上都能使用的一种中间代码——字节码类文件(.class文件)。有了字节码,无论哪种平台(如:Mac、Windows、Linux等),只要安装了虚拟机都可以直接运行代码。

并且,有了字节码,也解除了Java虚拟机和Java语言之间的耦合

Java虚拟机当初被设计出来目的不单单只是运行Java一种语言。目前Java虚拟机可以支持很多除Java语言之外的其他语言了,如Groovy、JRuby、Jython、Scala等。之所以可以支持其他语言,是因为这些语言经过编译之后能够被JVM解析并执行的字节码文件。而虚拟机并不关心字节码是由哪种语言编译而来的。如下图:

2. 上帝视角看class文件

如果从纵观的角度来看class文件,class文件里只有两种数据结构:无符号数

  • 无符号数:属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用,、数量值或者字符串(UTF-8编码)。
  • :表是由多个无符号数或者其他表作为数据项构成的复合数据类型,class文件中所有的表都以“_info”结尾。其实,整个class文件本质上就是一张表。

这二者之间的关系可以用下面这张图表示:

可以看出,在一张表中可以包含其他无符号数和其他表格。伪代码可以如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 无符号数
u1 = byte[1];
u2 = byte[2];
u4 = byte[4];
u8 = byte[8];

// 表
class_table {
    // 表中可以引用各种无符号数,
    u1 tag;
    u2 index2;
    ...

    // 表中也可以引用其它表
    method_table mt;
    ...
}

3. class文件结构

刚才说了class文件只存在无符号数和表这两种数据结构。而这些无符号数和表就组成了class中的各个结构。这些结构按照预先规定好的顺序紧密的从前向后排列,相邻的项之间没有任何缝隙。如下图:

当JVM加载某个class文件时,JVM就是根据上图中的结构去解析class文件的,加载class文件到内存中,并在内存中分配相应的空间。具体某一种结构需要占用多大空间,如下图:

到这里可能有点概念混淆,分不清无符号数、表格以及上面的结构是什么关系。举个例子:人体由H、O、C、N等元素组成的。但这些元素又是按照一定的规律组成人体的各个器官。class文件中的无符号数和表格就相当于人体的H、O、C、N等元素,而class结构图中的各项结构就相当于人体的各个器官,并且这些器官的组织顺序是有严格顺序要求的。

4. 实例分析

理清这些概念之后,下面通过一个Java代码实例,来看一下上面这几个结构的详细情况。首先编写一个Java代码Text.java,如下:

1
2
3
4
5
6
7
8
9
10
11
import java.io.Serializable;

public class Test implements Serializable, Cloneable {
private int num = 1;

public int add(int i) {
int j = 10;
num = num + i;
return num;
}
}

通过javac将其编译,生成Test.class字节码文件。使用16进制编辑器打开class文件,显示内容如下:

上图中都是一些16进制数字,每两个字符表示一个字节。乍一看各个字符之间毫无规律,但是在JVM的视角里,这些16进制字符是按照严格的规律排列的。接下来就一步一步看看JVN是如何解析它们的。

4.1 魔数(magic number)

如上图所示,在class文件开头的四个字节是class文件的魔数,它是一个固定的值–0XCAFEBABE。魔数是class文件的标志,也就是说它是判断一个文件是不是class格式文件的标准。如果开头的四个字节不是0XCAFEBABE,那么说明它不是class文件,不能被JVM识别或加载。

4.2 版本号

紧跟在魔数后面的四个字节代表当前class文件的版本号。前两个字节 0000 代表次版本号(minor_version),后两个字节 0034 是主版本号(major_version),对应的十进制值为52,也就是说当前class文件的主版本号为52,次版本号为0。所以综合版本号为52.0,也就是 jdk1.8.0

4.3 常量池(重点)

紧跟在版本号之后的是一个叫做常量池的表(cp_info)。在常量池中保存了类的各种相关信息,比如类的名称、父类的名称、类中的方法名、参数名称、参数类型等,这些信息都是以各种表的形式保存在常量池中。

常量池中的每一项都是一个表,其项目类型共有14种,如下表所示:

可以看出,常量池中的每一项都会有一个u1大小的tag值。tag值是表的标识,JVM解析class文件时,通过这个值来判断当前数据结构是哪一种表。以上14种表都有自己的结构,这里不再一一介绍,就以CONSTANT_Class_infoCONSTANT_utf8_info这两张表举例说明,因为其他表也基本类似。

首先,CONSTANT_Class_info表的具体结构如下:

1
2
3
4
table CONSTANT_Class_info {
    u1  tag = 7;
    u2  name_index;
}

说明:

  • tag:占用一个字节大小。比如值为7,说明是CONSTANT_Class_info类型表。
  • name_index:是一个索引值,可以将它理解为一个指针,指向常量池中索引为name_index的常量表。比如name_index=2,则它指向常量池中第二个常量。

再来看看CONSTANT_utf8_info表具体结构如下:

1
2
3
4
5
table CONSTANT_utf8_info {
    u1  tag;
    u2  length;
    u1[] bytes;
}

说明:

  • tag:值为1,表示是CONSTANT_utf8_info类型表。
  • length:表示u1[]的长度,比如length=5,表示接下来的数据是5个连续的u1类型数据。
  • bytesu1类型数组,长度为上面第二个参数length的值。

而在Java代码中声明的String字符串最终在class文件的存储格式就是CONSTANT_utf8_info。因此字符串最大长度也就是u2所能代表的最大值65536个,但是需要使用2个字节来保存null值,因此字符串的最大长度为65536 - 2 = 65534。

不难看出,在常量池内部的表也有相互之间的引用。用一张图来理解CONSTANT_Class_infoCONSTANT_utf8_info表格之间的关系,如下图:

理解了常量池内部的数据结构之后,接下来就看一下实例代码的解析过程。因为开发者平时定义的Java类各式各样,类中的方法与参数也不尽相同。所以常量池的元素数量也就无法固定,因此class文件在常量池的前面使用2个字节的容量计数器,来表示当前类中常量池的大小。如下图:

001d 转化成十进制就是29,也就是说常量池计数器的值为29.其中下标为0的常量被JVM留作特殊用途。因此Test.class中实际的常量池大小28个。

第一个常量,如下:

0a 转化成十进制为10,通常查看常量池14种表格图中,可以查到tag=10的表类型为CONSTANT_Methodref_info,因此常量池中的第一个常量类型为方法引用表。其结构如下:

1
2
3
4
5
CONSTANT_Methodref_info {
    u1 tag = 10;
    u2 class_index;     // 指向此方法的所属类
    u2 name_type_index; // 指向此方法的名称和类型
}

也就是说在 0a 之后的两个字节指向这个方法是属于哪个类,紧接的两个字节指向这个方法的名称和类型。它们的值分别为:

  • 0006:十进制为6,表示指向常量池中的第6个常量。
  • 0015:十进制为21,表示指向常量池中的第21个常量。

至此,第一个常量解读完毕。紧接着就是第二个常量,如图:

tag = 9表示字段引用表CONSTANT_Fieldref_info,其结构如下:

1
2
3
4
5
CONSTANT_Fieldref_info{
    u1 tag;
    u2 class_index;     // 指向此字段的所属类
    u2 name_type_index; // 指向此字段的名称和类型
}
  • 0005:指向常量池中第5个常量。
  • 0016:指向常量池中第22个常量。

这里已经解析了两个常量,剩下的21个常量的解析过程也是大同小异。

实际上可以借助javap命令帮助查看class常量池中的内容:

1
javap -v Test.class

执行上述命令之后,显示结果如下:

和刚才的分析一样,常量池中的第一个常量是Methodref类型,指向下标6和下标21的常量。其中下标21的常量类型为NameAndType,它对应的数据结构如下:

1
2
3
4
5
CONSTANT_NameAndType_info{
    u1 tag;
    u2 name_index; // 指向某字段或方法的名称字符串
    u2 type_index; // 指向某字段或方法的类型字符串
}

而下标在21的NameAndTypename_indextype_index分别指向了13和14,也就是<int>()V。因此最终解析下来常量池的第一个常量的解析过程以及最终值如下图:

仔细解析层层引用,最后可以看出,Test.clsss文件中常量池的第一个常量保存的是Object中的默认构造方法。

4.4 访问标志(access_flags)

紧跟在常量池后面的常量是访问标志,占用两个字节。

访问标志代表类或者接口的访问信息,比如:该class文件是类还是接口,是否被定义成public,是否是abstract,如果是类,是否被声明成final等。各种访问标志如下:

前面定义的Test.java是一个普通类,不是接口、枚举或者注解。并且被public修饰但是没有被声明为finalabstract,因此它所对应的access_falgs0021 (0X0001 和 0X0020 相结合)。

4.5 类索引、父类索引和接口索引计数器

在访问标志后的2个字节就是类索引,类索引后的2个字节就是父类索引,父类索引后的2个字节是接口索引计数器。如下如:

可以看出类索引指向常量池的第5个常量,父类索引指向常量池中的第6个常量,并且实现的接口个数为2个。再回顾一下常量池中的数据:

从图中可以看出,第5个常量和第6个常量均为“CONSTANT_Class_info”表类型,并且代表的类分别是”Test”和”Object”。再看接口计数器,因为接口计数器的值为2,代表这个类实现了2个接口。查看接口索引计数器之后的4个字节分别是:

  • 0007:指向常量池的第7个常量,从图中可以看出第7个常量值为”Serializable”。
  • 0008:指向常量池的第8个常量,从图中可以看出第8个常量值为”Cloneable”。

综上所述,可以得出如下结论:当前类为Test继承自Object类,并实现了SerializableCloneable两个接口

4.6 字段表

紧跟在接口索引集合后面的就是字段表,字段表的主要功能是用来描述类或者接口中声明的变量。这里的字段包含了类级别变量以及实例变量,但是不包括方法内部声明的局部变量。

同样,一个类中的变量个数是不固定的,因此在字段表集合之前还是使用一个计数器来表示变量的个数,如下入:

  • 0002:表示类中声明了两个变量,字段计数器之后紧跟着2个字段表的数据结构。

字段表的数据结构如下:

1
2
3
4
5
6
7
CONSTANT_Fieldref_info{
    u2  access_flags     // 字段的访问标志
    u2  name_index       // 字段的名称索引(也就是变量名)
    u2  descriptor_index // 字段的描述索引(也就是变量的类型)
    u2  attributes_count // 属性计数器
    attribute_info
}

继续解析Test.class中的字段表,其结构如下:

4.7 字段访问标志

对于Java类中的变量,也可以使用publicprivatefinalstatic等标识符进行标识。因此解析字段时,需要先判断它的访问标志,字段的访问标志如下所示:

字段表结构图中的访问标志的值为0002,代表它是private类型。变量名索引指向常量池中的第9个常量,变量名类型索引指向常量池中的第10个常量。第9和10个常量分别是numI,如下所示:

因此可以得知类中有一个名为num,类型为int类型的变量。对于第二个变量的解析过程也是一样。

注意事项 :

  • 1、字段表集合中不会列出从父类或者父接口中继承而来的字段。
  • 2、内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。

4.8 方法表

字段表之后紧跟着的就是方法表常量。方法表常量也是以一个计数器开始的,因为一个类中的方法数是不固定的,如图:

上图表示Test.class中有了两个方法,但是只在Test.java中声明了一个add()方法,这是为什么呢?这是因为默认构造器方法也被包含在方法表常量中。

方法表的结构如下:

1
2
3
4
5
6
7
CONSTANT_Methodref_info{
    u2  access_flags;         // 方法的访问标志
    u2  name_index;           // 指向方法名的索引
    u2  descriptor_index;     // 指向方法类型的索引
    u2  attributes_count;     // 方法属性计数器
    attribute_info attributes;
}

方法也有自己的访问标志,具体如下:

主要看add()方法,具体如下:

  • 1、access_flags = 0001:访问权限为public
  • 2、bame_index = 0011:指向常量池中的第17个常量,也就是add
  • 3、type_index = 0012:指向常量池中的第18个常量,也就是(I)。这个方法接收int类型参数,并返回int类型参数。

4.9 属性表

在之前解析字段和方法时,在它们的具体结构中可以看到一个叫做attributes_info的表,这就是属性表。

属性表没有固定的结构,各个不同的属性满足以下结构即可:

1
2
3
4
5
CONSTANT_Attribute_info{
    u2 name_index;
    u2 attribute_length length;
    u1[] info;
}

JVM中预定义了很多属性表,这里重点看一下Code属性表。

接着上面解析方法表,向下分析:

可以看到,在方法类型索引之后跟着的就是add方法的属性。

  • 0001:是属性计数器,代表只有一个属性。
  • 000f:属性表类型索引,通过查看常量池可以看出,它是一个Code属性,如下所示:

Code属性表中,最重要的就是一系列的字节码。通过javap -v Test.class之后,可以看到方法的字节码,如下显示了add方法的字节码指令:

JVM 执行add方法时,就是通过这一系列的指令来完成相应的操作的。

  • Copyrights © 2019-2020 Tyler Liu

请我喝杯咖啡吧~

支付宝
微信