字节码层面分析class类文件结构

字节码层面分析class类文件结构

1. 思考:Java中的String字符串的长度有限制吗?

平时项目的开发中,我们经常会用到String来声明字符串,比如String str = ”abc“,但是你可能从来没有想过等于号之后的字符串常量到底有没有长度限制。要彻底答对这道题,就需要了解-class文件。

2. class 的来龙去脉

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

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

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

img

3. 上帝视角看class文件

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

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

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

img

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

//无符号数
u1=byte[1];
u2=byte[2];
u4=byte[4];
u8=byte[8];
//表
class_table{
	u1 tag;
    u2 index2;
    ...
// 表中也可以引用其它表
    method_table mt;
    ...
}

4. class文件结构

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

img

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

img

5. 实例分析

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

import java.io.Serializable;

public class Test implements Serializable,Cloneable{
	private int num = 1;
	private String str = "abc";
	public int add(int i){

		int j = 10;
		num = num + i;
		return num;
	}
}

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

img

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

1. 魔数 magic number

img

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

2. 版本号

img

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

3. 常量池(重点)

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

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

img

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

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

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表具体结构如下:

table CONSTANT_Utf8_info{
    u1 tag;
    u2 length;
    u1[] bytes;
}

解释说明:

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

而我们在Java代码中声明的String字符串最终在class文件中的存储格式就是CONSTANT_Utf8_info。因此

一个字符串最大长度也就是u2所能代表的最大值65536个,但是需要使用2字节来保存null值,因此一个字符串的最大长度为65536 - 2 = 65534。

上述解释中说的是字符串最大长度为65534个字节,并不代表一个字符串中就可以保存65534个字符。因为在utf-8编码下,一个数字和一个英文字母占一个字节,但是一个汉字却可以占用2~4个字节。因此如果使用字面量的方式声明中文字符串的长度会远远小于65534。

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

img

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

img

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

第一个常量,如下所示:

img

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

table CONSTANT_Methodref_info{
	u1 tag = 10;
	u2 class_index;			指向此方法的所属类
	u2 name_type_index;		指向此方法的名称和类型
}

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

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

至此第一个常量就解答完毕了。紧接着的就是第2个常量,如下所示:

img

tag 09表示是字段引用表CONSTANT_Fieldref_info,其结构如下:

table CONSTANT_Fieldref_info{
	u1 tag;
	u2 class_index;			指向此字段的所属类
	u2 name_type_index;		指向此字段的名称和类型
}

同样也是4个字节,前后都是两个索引:

  • 0005:指向常量池中第五个常量。
  • 0016:指向常量池中第22个常量。

到现在为止我们已经解析除了常量池中的两个常量。剩下的常量的解析过程大同小异,这里就不一一解析了。实际上我们可以用javap命令来帮助我们查看class常量池中的内容:

javap -v Test

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

img

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

table CONSTANT_NameAndType_info{
    u1 tag;
    u2 name_index;		指向某字段或方法的名称字符串
    u2 type_index;		指向某字段或方法的类型字符串
}

而下标在21的NameAndType的name_index和type_index分别指向了13和14,也就是"< init>“和”()V"。因此最终解析下来常量池中第一个常量的解析过程以及最终值如下图所示:

img

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

4. 访问标志(access_flags)

紧跟在常量池之后的常量是访问标志,占用两个字节,如下图所示;

img

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

img

我们定义的Test.java是一个普通的Java类,不是接口,枚举,或注解。并且被public修饰但没有被声明为final和abstract,因此它所对应的access_flags为0021(0x0001和0x0020相结合)。

5. 类索引,父类索引与接口索引计数器

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

img

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

img

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

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

综上所述,可以得出两个结论:当前类为Test继承自Object类,并实现了”Serializable“和”Cloneable“这两个接口。

6. 字段表

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

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

img

0002表示类中声明了2个变量(在class文件中叫字段),字段计数器之后会紧跟着2个字段表的数据结构。

字段表的具体结构如下:

table CONSTANT_Fieldref_info{
    u2 access_flags;		字段的访问标志
    u2 name_index;			字段的名称索引
    u2 descriptor_index;	字段的描述索引
    u2 attributes_count;	属性计数器
    attribute_info attributes;
}

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

img

字段访问标志

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

img

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

img

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

注意事项:

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

7. 方法表

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

img

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

方法表的结构如下所示:

table CONSTANT_Methodref_info{	
    u2 access_flags;		方法的访问标志
    u2 name_index;			指向方法名的索引
    u2 descriptor_index;	指向方法类型的索引
    u2 attributes_count;	方法属性计数器
    attribute_info attributes;
}

可以看到,方法也是有自己的访问标志,具体如下:

img

我们主要看add方法:

img

从图中我们可以看到add方法的以下字段的具体值:

  • access_flags = 0X0001 也就是访问权限为public
  • name_index = 0X0011 指向常量池中的第17个常量,也就是”add“。
  • type_index = 0X0012 指向常量池中的第18个常量,也就是(I)I。这个方法接收int类型参数,并返回int类型参数

8. 属性表

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

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

table CONSTANT_Attribute_info{
    u2 name_index;
    u2 attribute_length length;
    u1[] info;
}

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

  • Code属性表

    我们可以接着刚才解析方法表的思路继续往下分析:

    img

可以看到,在方法类型索引之后跟着的就是”add“方法的属性。0X0001是属性计数器,代表只有一个属性。0X000f是属性表类型索引,通过常量池可以看出它是一个Code属性表,如下所示:

img

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

img

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

猜你喜欢

转载自blog.csdn.net/qq_43621019/article/details/105401471
今日推荐