JVM字节码与类的加载——class文件结构


一段Java程序编写完成后,会被存储到以.java为后缀的源文件中,源文件会被编译器编译为以.class为后缀的二进制文件,之后以.class为后缀的二进制文件会经由类加载器加载至内存中。本贴要讲的重点就是以.class为后缀的二进制文件,也简称为class文件或者字节码文件。接下来将会介绍class文件的详细结构,以及如何解析class文件。

1、概述

1.1、class文件的跨平台性

Java是一门跨平台的语言,也就是我们常说的“Write once,run anywhere”,意思是当Java代码被编译成字节码后,就可以在不同的平台上运行,而无须再次编译。但是现在这个优势不再那么吸引人了,Python、PHP、Perl、Ruby、Lisp等语言同样有强大的解释器。跨平台几乎成为一门开发语言必备的特性。

虽然很多语言都有跨平台性,但是JVM却是一个跨语言的平台。JVM不和包括Java在内的任何语言绑定,它只与class文件这种特定的二进制文件格式关联。无论使用何种语言开发软件,只要能将源文件编译为正确的class文件,那么这种语言就可以在JVM上执行,如下图所示:
在这里插入图片描述
比如Groovy语言、Scala语言等。可以说规范的class文件结构,就是JVM的基石、桥梁。

JVM有很多不同的实现,但是所有的JVM全部遵守Java虚拟机规范,也就是说所有的JVM环境都是一样的,只有这样class文件才可以在各种JVM上运行。在Java发展之初,设计者就曾经考虑并实现了让其他语言运行在Java虚拟机之上的可能性,他们在发布规范文档的时候,也刻意把Java的规范拆分成了Java语言规范及Java虚拟机规范。官方虚拟机规范如下图所示:
在这里插入图片描述
想要让一个Java程序正确地运行在JVM中,Java源文件就必须要被编译为符合JVM规范的字节码。前端编译器就是负责将符合Java语法规范的Java代码转换为符合JVM规范的class文件。常用的javac就是一种能够将Java源文件编译为字节码的前端编译器。javac编译器在将Java源文件编译为一个有效的class文件过程中经历了4个步骤,分别是词法解析、语法解析、语义解析以及生成字节码。

Oracle的JDK软件中除了包含将Java源文件编译成class文件外,还包含JVM的运行时环境。如下图所示:
在这里插入图片描述
Java源文件(Java Source)经过编译器编译为class文件,之后class文件经过ClassLoader加载到虚拟机的运行时环境。需要注意的是ClassLoader只负责class文件的加载,至于class文件是否可以运行,则由执行引擎决定。

1.2、编译器分类

Java源文件的编译结果是字节码,那么肯定需要有一种编译器将Java源文件编译为class文件,承担这个重要责任的就是配置在path环境变量中的javac编译器。javac是一种能够将Java源文件编译为字节码的前端编译器。

HotSpot VM并没有强制要求前端编译器只能使用javac来编译字节码,其实只要编译结果符合JVM规范都可以被JVM所识别。

在Java的前端编译器领域,除了javac,还有一种经常用到的前端编译器,那就是内置在Eclipse中的ECJ(Eclipse Compiler for Java)编译器。和javac的全量式编译不同,ECJ是一种增量式编译器。

在Eclipse中,当开发人员编写完代码,使用Ctrl+S快捷键保存代码时,ECJ编译器会把未编译部分的源码逐行进行编译,而不是每次都全量编译。因此ECJ的编译效率更高。

ECJ不仅是Eclipse的默认内置前端编译器,在Tomcat中同样也是使用ECJ编译器来编译jsp文件。由于ECJ编译器是采用GPLv2的开源协议进行开源的,所以大家可以在Eclipse官网下载ECJ编译器的源码进行二次开发。另外,IntelliJ IDEA默认使用javac编译器。

我们把不同的编程语言类比为不同国家的语言,它们经过前端编译器处理之后,都变成同一种class文件。如下图所示:
在这里插入图片描述
前端编译器把各个国家的“你好”编译为一样的“乌拉库哈吗哟”,这个“乌拉库哈吗哟”就好比class文件中的内容。class文件对于执行引擎是可以识别的,所以JVM是跨语言的平台,其中起关键作用的就是前端编译器。JIT编译器可以对程序做栈上分配、同步省略等优化。为了区别前面讲的javac,把JIT称为后端编译器。

除了上面提到的前端编译器和后端编译器,还有AOT编译器和Graal编译器。

1.3、透过字节码指令看代码细节

通过学习class文件,可以查看代码运行的详细信息。代码清单如下所示,测试不同Integer变量是否相等。
在这里插入图片描述
运行结果如下:

     true
     false

显而易见,两次运行结果并不相同。定义的变量是Integer类型,采用的是直接赋值的形式,并没有通过某一个方法进行赋值,所以无法看到代码底层的执行逻辑是怎样的,那么只能通过查看class文件来分析问题原因。通过IDEA中的插件jclasslib查看class文件,如下图所示:
在这里插入图片描述
class文件中包含很多字节码指令,分别表示程序代码执行期间用到了哪些指令。这里仅说一下Integer i1 = 10语句执行的是<java/lang/Integer.valueOf>方法,也就是Integer类中的valueOf方法,我们查看源代码如下图所示:
在这里插入图片描述
可以发现对Integer赋值的时候,通过i和IntegerCache类高位值和低位值的比较,判断i是否直接从IntegerCache内cache数组获取数据。IntegerCache类的低位值为-128,高位值为127。如果赋值在低位值和高位值范围内,则返回IntegerCache内cache数组中的同一个值;否则,重新创建Integer对象。这也是为什么当Integer变量赋值为10的时候输出为true,Integer变量赋值为128的时候输出为false。

2、虚拟机的基石:class文件

源代码经过编译器编译之后生成class文件,字节码是一种二进制的文件,它的内容是JVM的指令,其不像C、C++经由编译器直接生成机器码。

2.1、字节码指令

JVM的指令由一个字节长度的、代表着某种特定操作含义的操作码(opcode)以及跟随其后的零至多个代表此操作所需参数的操作数(operand)所构成。虚拟机中许多指令并不包含操作数,只有一个操作码。如下图所示:
在这里插入图片描述
其中aload_0是操作码,没有操作数。bipush 30中的bipush是操作码,30是操作数。

2.2、解读字节码方式

由于class文件是二进制形式的,所以没办法直接打开查看,需要使用一些工具将class文件解析成我们可以直接阅读的形式。解析方式主要有以下三种。

1、使用第三方文本编辑工具:我们常用的第三方文本编辑工具有Notepad++和Binary Viewer。以NotePad++为例,需要在插件中安装“HEX-Editor”插件。安装完插件之后,打开一个class文件,如下图所示:
在这里插入图片描述
展示结果为乱码。如果想要以十六进制视图展示,单击“插件”→“HEX-Editor”→“View in HEX”即可,如下图所示:
在这里插入图片描述
2、使用javap指令:JDK自带的解析工具。

3、jclasslib工具:jclasslib工具在解析class文件时,已经进行了二进制数据的“翻译”工作,可以更直观地反映class文件中的数据。各位读者可以下载安装jclasslib Bytecode viewer客户端工具或者在IDEA的插件市场安装jclasslib插件,如下图所示:
在这里插入图片描述

3、class文件结构

任何一个class文件都对应着唯一一个类或接口的定义信息,但是并不是所有的类或接口都必须定义在文件中,它们也可以通过类加载器直接生成。也就是说class文件实际上并不一定以磁盘文件的形式存在。class文件是一组以8位字节为基础单位的二进制流,它的结构不像XML等描述语言,由于它没有任何分隔符号,所以在其中的数据项,无论是字节顺序还是数量,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变,就好像一篇没有标点符号的文章。这使得整个class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构只有无符号数和表两种数据类型。

无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。对于字符串,则使用u1数组进行表示。

表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个class文件本质上就是一张表。由于表没有固定长度,所以通常会在其前面加上长度说明。在学习过程中,只要充分理解了每一个class文件的细节,甚至可以自己反编译出Java源文件。

class文件的结构并不是一成不变的,随着JVM的不断发展,总是不可避免地会对class文件结构做出一些调整,但是其基本结构和框架是非常稳定的。class文件的整体结构如下表所示:
在这里插入图片描述
官方对class文件结构的详细描述如下图所示:
在这里插入图片描述
上面class文件的结构解读如下表所示:
在这里插入图片描述
下面我们按照上面的顺序逐一解读class文件结构。首先编写一段简单的代码,对照上面的结构表来分析class文件,代码清单如下所示:
在这里插入图片描述

这段代码很简单,只有一个成员变量num和一个方法fun()。将源文件编译为class文件,我们使用命令javac编译,如下所示:

     javac ClassFileDemo.java

上面命令的执行结果是生成一个ClassFileDemo.class文件。使用安装好HEX-Editor插件的Notepad++打开ClassFileDemo.class文件,结果如下图所示:
在这里插入图片描述
篇幅原因展示部分截图,可以看到每个字节都是十六进制数字,通过分析每个字节来解析class文件。

3.1、魔数:class文件的标识

每个class文件开头的4个字节的无符号整数称为魔数(Magic Number)。魔数的唯一作用是确定class文件是否有效合法,也就是说魔数是class文件的标识符。魔数值固定为0xCAFEBABE,如下图框中所示:
在这里插入图片描述
之所以使用CAFEBABE,可以从Java的图标(一杯咖啡)窥得一二。

如果一个class文件不以0xCAFEBABE开头,JVM在文件校验的时候就会直接抛出以下错误的错误。
在这里插入图片描述
比如将ClassFileDemo.java文件后缀改成ClassFileDemo.class,然后使用命令行解释运行,就报出上面的魔数不对的错误。

使用魔数而不是扩展名识别class文件,主要是基于安全方面的考虑,因为文件扩展名可以随意改动。除了Java的class文件以外,其他常见的文件格式内部也会有类似的设计手法,比如图片格式gif或者jpeg等在头文件中都有魔数。

3.2、class文件版本号

紧接着魔数存储的是class文件的版本号,同样也是4个字节。第5个和第6个字节所代表的含义是class文件的副版本号minor_version,第7个和第8个字节是class文件的主版本号major_version。它们共同构成了class文件的版本号,例如某个class文件的主版本号为M,副版本号为m,那么这个class文件的版本号就确定为M.m。版本号和Java编译器版本的对应关系如下表所示:
在这里插入图片描述
Java的版本号是从45开始的,JDK 1.1之后每发布一个JDK大版本,主版本号向上加1。当虚拟机JDK版本为1.k(k≥2)时,对应的class文件版本号的范围为45.0到44+k.0之间(含两端)。字节码指令集多年不变,但是版本号每次发布都会变化。

不同版本的Java编译器编译的class文件对应的版本是不一样的。目前,高版本的JVM可以执行由低版本编译器生成的class文件,可以理解为向下兼容。但是低版本的JVM不能执行由高版本编译器生成的class文件。一旦执行,JVM会抛出java.lang.UnsupportedClass VersionError异常。在实际应用中,由于开发环境和生产环境的不同,可能会导致该问题的发生。因此,需要我们在开发时,特别注意开发环境的JDK版本和生产环境中的JDK版本是否一致。

上面的ClassFileDemo.class文件使用JDK8版本编译而成,第5个字节到第8个字节如下图所示:
在这里插入图片描述

3.3、常量池:存放所有常量

紧跟在版本号之后的是常量池中常量的数量(constant_pool_count)以及若干个常量池表项(constant_pool[])。常量池是class文件中内容最为丰富的区域之一。常量池表项用于存放编译时期生成的各种字面量(Literal)和符号引用(Symbolic References),这部分内容在经过类加载器加载后存放在方法区的运行时常量池中存放。常量池对于class文件中的字段和方法解析起着至关重要的作用。随着JVM的不断发展,常量池的内容也日渐丰富。可以说,常量池是整个class文件的基石。

1、constant_pool_count(常量池计数器)
由于常量池的数量不固定,时长时短,所以需要放置两个字节(u2类型)来表示常量池容量计数值。常量池容量计数器从1开始计数,constant_pool_count=1表示常量池中有0个常量项。通常我们写代码时都是从0开始的,但是这里的常量池计数器却是从1开始,因为它把第0项常量空出来了,这是为了满足某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况可用索引值0来表示。如下图所示:
在这里插入图片描述

第9个字节和第10个字节表示常量池计数器,其值为0x001f,换算为十进制为31,需要注意的是,实际上只有30项常量,索引范围是1~30。

我们也可以通过jclasslib插件来查看常量池数量,如下图所示,可以看到一共有30个常量。
在这里插入图片描述
2、constant_pool[](常量池)
常量池是一种表结构,从1到constant_pool_count–1为索引。常量池主要存放字面量和符号引用两大类常量。常量池包含了class文件结构及其子结构中引用的所有字符串常量、类或接口名、字段名和其他常量。常量池中的每一项常量的结构都具备相同的特征,那就是每一项常量入口都是一个u1类型的标识,该标识用于确定该项的类型,这个字节称为tag byte(标识字节),如下图所示:
在这里插入图片描述
一旦JVM获取并解析这个标识,JVM就会知道在标识后的常量类型是什么。常量池中的每一项都是一个表,其项目类型共有14种,下表列出了所有常量项的类型和对应标识的值,比如当标识值为1时,表示该常量的类型为CONSTANT_utf8_info。
在这里插入图片描述
这14种类型的结构各不相同,各个类型的结构如下表所示:
在这里插入图片描述
在这里插入图片描述
根据上表中对每个类型的描述,我们可以知道每个类型是用来描述常量池中的字面量、符号引用,比如CONSTANT_Integer_info是用来描述常量池中字面量信息,而且只是整型字面量信息。标识值为15、16、18的常量项类型是用来支持动态语言调用的,它们在JDK7时加入。下面按照标识的大小顺序分别进行介绍。

  • (1)CONSTANT_Utf8_info用于表示字符常量的值。
  • (2)CONSTANT_Integer_info和CONSTANT_Float_info表示4字节(int和float)的数值常量。
  • (3)CONSTANT_Long_info和CONSTANT_Double_info表示8字节(long和double)的数值常量;在class文件的常量池表中,所有的8字节常量均占两个表项的空间。如果一个CONSTANT_Long_info或CONSTANT_Double_info的项在常量池表中的索引位n,则常量池表中下一个可用项的索引为n+2,此时常量池表中索引为n+1的项仍然有效但必须视为不可用的。
  • (4)CONSTANT_Class_info用于表示类或接口。
  • (5)CONSTANT_String_info用于表示String类型的常量对象。
  • (6)CONSTANT_Fieldref_info、CONSTANT_Methodref_info表示字段、方法。
  • (7)CONSTANT_InterfaceMethodref_info表示接口方法。
  • (8)CONSTANT_NameAndType_info用于表示字段或方法,但是和之前的3个结构不同,CONSTANT_NameAndType_info没有指明该字段或方法所属的类或接口。
  • (9)CONSTANT_MethodHandle_info用于表示方法句柄。
  • (10)CONSTANT_MethodType_info表示方法类型。
  • (11)CONSTANT_InvokeDynamic_info用于表示invokedynamic指令所用到的引导方法(Bootstrap Method)、引导方法所用到的动态调用名称(Dynamic Invocation name)、参数和返回类型,并可以给引导方法传入一系列称为静态参数(Static Argument)的常量。

这14种表(或者常量项结构)的共同点是表开始的第一位是一个u1类型的标识位(tag),代表当前这个常量项使用的是哪种表结构,即哪种常量类型。在常量池列表中,CONSTANT_Utf8_info常量项是一种使用改进过的UTF-8编码格式来存储诸如文字字符串、类或者接口的全限定名、字段或者方法的简单名称以及描述符等常量字符串信息。这14种常量项结构还有一个特点是,其中13个常量项占用的字节固定,只有CONSTANT_Utf8_info占用字节不固定,其大小由length决定。因为从常量池存放的内容可知,其存放的是字面量和符号引用,最终这些内容都会是一个字符串,这些字符串的大小是在编写程序时才确定,比如定义一个类,类名可以取长取短,所以在代码源文件没编译前,大小不固定;代码源文件编译后,可以通过utf-8编码知道其长度。

常量池可以理解为class文件之中的资源仓库,它是class文件结构中与其他项目关联最多的数据类型(后面讲解的很多数据结构都会指向此处),也是占用class文件空间最大的数据项目之一。

Java代码在进行javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载class文件的时候进行动态链接。也就是说,在class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。本章先弄清楚class文件中常量池中的字面量符号引用。

(1)字面量和符号引用。常量池主要存放两大类常量字面量和符号引用。字面量和符号引用的具体定义如下表所示:
在这里插入图片描述
字面量很容易理解,例如定义String str = “xiaoyang”和final int NUM = 10,其中atguigu和10都是字面量,它们都放在常量池中,注意没有存放在内存中。符号引用包含类和接口的全限定名、简单名称、描述符三种常量类型。

  • ①类和接口的全限定名:com/yung/ClassFileDemo就是类的全限定名,仅仅是把包名的“.”替换成“/”,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“;”表示全限定名结束。
  • ②简单名称:简单名称是指没有类型和参数修饰的方法或者字段名称。
  • ③描述符:描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。

(2)常量解读:针对ClassFileDemo.class文件,我们解读其中的常量池中存储的信息。首先是第一个常量,其标识位如下图所示:
在这里插入图片描述

其值为0x0a,即10,查找表可知,其对应的项目类型为CONSTANT_Methodref_info,即类中方法的符号引用,其结构如下图所示:
在这里插入图片描述
可以看到标识后面还有4个字节的内容,分别为两个索引项,如下图所示:
在这里插入图片描述
其中前两位的值为0x0006,即6,指向常量池第6项的索引;后两位的值为0x0013,即19,指向常量池第19项的索引。至此,常量池中第一个常量项解析完毕。再来看下第二个常量,其标识位如下图所示:
在这里插入图片描述
标识值为0x09,即9,查找表可知,其对应的项目类型为CONSTANT_Fieldref_info,即字段的符号引用,其结构如下图所示:
在这里插入图片描述
同样后面也有4字节的内容,分别为两个索引项,如下图所示:
在这里插入图片描述
同样也是4字节,前后都是两个索引。分别指向第5项的索引和第20项的索引。后面常量项就不一一去解读了,这样的class文件解读起来既费力又费神,还很有可能解析错误。我们可以使用“javap -verbose ClassFileDemo.class”命令去查看class文件,如下图所示:
在这里插入图片描述
可以看到,常量池中总共有30个常量项,第一个常量项指向常量池第6项的索引以及指向常量池第19项的索引,第二个常量项指向常量池第5项的索引和指向常量池第20项的索引。和我们上面按照字节码原文件解析结果一样。虽然使用javap命令很方便,但是通过手动分析才知道这个结果是怎么出来的,做到知其然也知其所以然。

3.4、访问标识

常量池后紧跟着访问标识。访问标识(access_flag)描述的是当前类(或者接口)的访问修饰符,如public、private等标识使用两个字节表示,用于识别一些类或者接口层次的访问信息,识别当前Java源文件属性是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等。访问标识的类型如下表所示:
在这里插入图片描述

比如当标识值为0x0001的时候,访问标识的类型是public。从上表中可以看到类的访问权限通常是以ACC_开头的常量。一个public final类型的类,该类标识为ACC_PUBLIC|ACC_FINAL。带有ACC_INTERFACE标识的class文件表示的是接口而不是类,其他标识则表示的是类而不是接口。下面介绍访问标识的设置规则。

  • (1)如果一个class文件被设置了ACC_INTERFACE标识,那么同时也得设置ACC_ABSTRACT标识。它不能再设置ACC_FINAL、ACC_SUPER或ACC_ENUM标识。
  • (2)如果没有设置ACC_INTERFACE标识,那么这个class文件可以具有上表中除ACC_ANNOTATION外的其他所有标识。当然,ACC_FINAL和ACC_ABSTRACT这类互斥的标识除外,这两个标识不得同时设置。
  • (3)ACC_SUPER标识用于确定类或接口里面的invokespecial指令使用的是哪一种执行语义。针对JVM指令集的编译器都应当设置这个标识。使用ACC_SUPER可以让类更准确地定位到父类的方法super.method()。ACC_SUPER标识是为了向后兼容由旧Java编译器所编译的代码而设计的。对于JavaSE 8及后续版本来说,无论class文件中这个标识的实际值是什么,也不管class文件的版本号是多少,JVM都认为每个class文件均设置了ACC_SUPER标识。也就是说JavaSE 8及后续版本不再支持没有设置ACC_SUPER标识的class文件了。ACC_SUPER这个标识位在JDK 1.0.2之前的版本中没有任何含义,即使设置了标志,Oracle的JVM实现也会忽略该标志。
  • (4)ACC_SYNTHETIC标识意味着该类或接口的class文件是由编译器生成的,而不是由源代码生成的。
  • (5)注解类型必须设置ACC_ANNOTATION标识。而且,如果设置了ACC_ANNOTATION标识,那么也必须设置ACC_INTERFACE标识。
  • (6)ACC_ENUM标识表明该类或其父类为枚举类型。

访问标识占用2字节,表示其有16位可以使用,目前只定义了8种类型,表中没有使用的标识是为未来扩充而预留的,这些预留的标识在编译器中设置为0。

我们把ClassFileDemo.class文件中的内容全部放到表格中展示,访问标识的值如下图所示:
在这里插入图片描述
其值为0x0021,我们上面的表格里没有0x0021,那么0x0021只能是组合后的数值,0x0021只能是0x0020和0x0001的并集,即这是一个public的类,再回头看看我们的源码,该类是由public修饰的。

3.5、类索引、父类索引、接口索引集合

在访问标识后,会指定该类的类别、父类类别以及实现的接口,这三项数据来确定这个类的继承关系,格式如下表所示:
在这里插入图片描述
类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个,注意java.lang.Object类除外。一个类如果没有继承其他类,默认继承java.lang.Object类。

接口索引集合用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句后面接口的顺序从左到右排列在接口索引集合中。如果这个类本身是接口类型,则应当是按extends语句后面接口的顺序从左到右排列在接口索引集合中。

1、this_class(类索引):
类索引占用2字节,指向常量池的索引,它提供了类的全限定名,如ClassFileDemo文件的全限定名为com/yang/ClassFileDemo。类索引的值必须是对常量池表中某项的一个有效索引值。常量池在这个索引处的成员必须为CONSTANT_Class_info类型结构体,该结构体表示这个class文件所定义的类或接口。我们直接来看下ClassFileDemo字节码中的值,如下图所示:
在这里插入图片描述
类索引的值为0x0005,即为指向常量池中第五项的索引。这里就用到了常量池中的值。接下来查看常量池中第五项的值,如下所示:
在这里插入图片描述
通过类索引我们可以确定到类的全限定名。

2、super_class(父类索引):
父类索引占用2字节,指向常量池的索引。它提供了当前类的父类的全限定名。如果我们没有继承任何类,其默认继承的是java/lang/Object类。同时,由于Java不支持多继承,所以其父类只有一个。super_class指向的父类不能是final。

从上图可以看出,父类索引的值为0x0006,即常量池中的第六项,接下来查看常量池中第六项的值,如下所示:
在这里插入图片描述
这样我们就可以确定到父类的全限定名。可以看到,如果我们没有继承任何类,其默认继承的是java/lang/Object类。同时,由于Java不支持多继承,所以其父类只有一个。对于类来说,super_class的值要么是0,要么是对常量池表中某项的一个有效索引值。如果它的值不为0,那么常量池在这个索引处的成员必须为CONSTANT_Class_info类型常量,它表示这个class文件所定义的类的直接超类。在当前类的直接超类,以及它所有间接超类的ClassFile结构体中,访问标识里面均不能有ACC_FINAL标志。

如果class文件的super_class的值为0,那这个class文件只可能用来表示Object类,因为它是唯一没有父类的类。
3、interfaces
指向常量池索引集合,它提供了一个符号引用到所有已实现的接口。由于一个类可以实现多个接口,因此需要以数组形式保存多个接口的索引,表示接口的每个索引也是一个指向常量池的CONSTANT_Class(当然这里就必须是接口,而不是类)。和常量池计数器以及常量池的设计一样,interfaces同样设计了接口计数器和接口索引集合。

1)interfaces_count(接口计数器):interfaces_count项的值表示当前类或接口的直接超接口数量。从上图可以看出,接口索引个数的值为0x0000,即没有任何接口索引,ClassFileDemo的源码也确实没有去实现任何接口。

2)interfaces[](接口索引集合):interfaces []中每个成员的值必须是对常量池表中某项的有效索引值,它的长度为interfaces_count。每个成员interfaces[i]必须为CONSTANT_Class_info结构,其中0≤i<interfaces_count。在interfaces[]中,各成员所表示的接口顺序和对应的源代码中给定的接口顺序(从左至右)一样,即interfaces[0]对应的是源代码中最左边的接口。

由于ClassFileDemo的源码没有去实现任何接口,所以接口索引集合就为空了,不占空间。可以看到,由于Java支持多接口,因此这里设计成了接口计数器和接口索引集合来实现。

3.6、字段表集合

接口计数器或接口索引集合后面就是字段表了,用于描述接口或类中声明的变量。字段包括类级变量以及实例级变量,但是不包括方法内部、代码块内部声明的局部变量。

字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。它指向常量池索引集合,它描述了每个字段的完整信息,比如字段的标识符、访问修饰符(public、private或protected)、是类变量(static修饰符)还是实例变量、是否为常量(final修饰符)等。

需要注意的是字段表集合中不会列出从父类或者实现的接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段,例如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。

在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于字节码来讲,如果两个字段的描述符不一致,那字段重名就是合法的。由于存储在字段表项中的字段信息并不包括声明在方法内部或者代码块内的局部变量,因此多个字段之间的作用域就都是一样的,那么Java语法规范必然不允许在一个类或者接口中声明多个具有相同标识符名称的字段。

和常量池计数器以及常量池的设计一样,字段表同样设计了字段计数器和字段表,在接口计数器或接口索引集合后面就是字段计数器,占用2个字节,后面便是字段表了。

1、fields_count(字段计数器):
fields_count的值表示当前class文件fields表的成员个数,使用两个字节表示。fields表中每个成员都是一个field_info结构,用于表示该类或接口所声明的所有类字段或者实例字段,不包括方法内部声明的变量,也不包括从父类或父接口继承的那些字段。查看ClassFileDemo字节码中的值,如上图所示。其值为0x0001,表示只有一个字段。
2、fields[](字段表)
fields表中的每个成员都必须是一个fields_info结构的数据项,用于表示当前类或接口中某个字段的完整描述。

一个字段的信息包括如下这些:作用域(public、private、protected修饰符);是实例变量还是类变量(static修饰符);可变性(final);并发可见性(volatile修饰符,是否强制从主内存读写);可否序列化(transient修饰符);字段数据类型(基本数据类型、对象、数组);字段名称。
在这里插入图片描述
字段表作为一个表,同样有它自己的结构,如下表所示:
在这里插入图片描述
下面分别介绍每个结构所代表的含义:
1)字段表访问标识:我们知道,一个字段可以被各种关键字去修饰,比如作用域修饰符(public、private、protected)、static修饰符、final修饰符、volatile修饰符等。因此,和类的访问标识类似,使用一些标识来标识字段。字段的访问标识分类如下表所示:
在这里插入图片描述
2)字段名索引:根据字段名索引的值,查询常量池中的指定索引项即可。
3)描述符索引:字段描述符的作用是用来描述字段的数据类型。我们知道数据类型分为基本数据类型和引用数据类型。基本数据类型(byte、short、int、long、float、double、boolean、char)都用一个大写字符来表示。引用数据类型中的对象类型用字符L加对象的全限定名来表示。对于数组类型,每一维度将使用一个前置的“[”字符来描述,如下表所示:
在这里插入图片描述
4)属性表集合:一个字段还可能拥有一些属性,用于存储更多的额外信息。比如字段的初始化值、一些注释信息等。属性个数存放在attribute_count中,属性具体内容存放在attributes数组中,以常量属性为例,结构如下:
在这里插入图片描述
注意,对于常量属性而言,attribute_length值恒为2。

3、解析字段表:
我们在ClassFileDemo中定义的字段为num,如下所示:

     private Integer num;

查看ClassFileDemo字节码中的值,如下图所示:
在这里插入图片描述
访问标识的值为0x0002,查询上面字段访问标识的表格,可得字段为private。

字段名索引的值为0x0007,查询常量池中的第7项,如下所示,可以得到字段名为num:

     #7 = Utf8              num

描述符索引的值为0x0008,查询常量池中的第8项,如下所示,可以得到其为Integer类型的实例。如果定义数据类型的时候写为int类型,就会显示为I。属性计数器的值为0x0000,表示没有任何其他属性。

3.7、方法表集合

字段表之后就是方法表信息了,它指向常量池索引集合,它完整描述了每个方法的信息。在class文件中,一个方法表与类或者接口中方法一一对应。方法信息包含方法的访问修饰符(public、private或protected)、方法的返回值类型以及方法的参数信息等。如果这个方法不是抽象的或者不是native的,那么字节码中会体现出来。方法表只描述当前类或接口中声明的方法,不包括从父类或父接口继承的方法,除非当前类重写了父类方法。方法表有可能会出现由编译器自动添加的方法,最典型的便是编译器产生的方法信息,比如类或接口的初始化方法(),以及实例初始化方法()。

Java语法规范中,要重载(Overload)一个方法,要求参数类型或者参数个数必须不同,方法返回值不会作为区分重载方法的标准。但是在class文件中,如果两个方法仅仅返回值不同,那么也是可以合法共存于同一个class文件中。方法表和常量池计数器以及常量池的设计一样,同样设计了方法计数器和方法表。

1、methods_count(方法计数器):
methods_count的值表示当前class文件methods表的成员个数。使用两个字节来表示。methods表中每个成员都是一个method_info结构。
2、methods[](方法表):
方法表中的每个成员都必须是一个method_info结构,用于表示当前类或接口中某个方法的完整描述。如果某个method_info结构的access_flags项既没有设置ACC_NATIVE标识也没有设置ACC_ABSTRACT标识,那么该结构中也应包含实现这个方法所用的JVM指令。

method_info结构可以表示类和接口中定义的所有方法,包括实例方法、类方法、实例初始化方法和类或接口初始化方法。方法表的结构实际跟字段表是一样的,方法表结构如下表所示:
在这里插入图片描述
1)方法表访问标识:跟字段表一样,方法表也有访问标识,而且它们的标识有部分相同,部分则不同,方法表的具体访问标识如下表所示:
在这里插入图片描述
2)方法名索引:根据方法名索引的值,查询常量池中的指定索引项即可。
3)描述符索引:根据描述符索引的值,查询常量池中的指定索引项即可。用描述符来描述方法时,按照参数列表、返回值的顺序描述,参数列表严格按照参数的顺序放在一组小括号“()”之内。如方法java.lang.String toString()的描述符为()LJava/lang/String;,方法int abc(int[] x,int y)的描述符为([II)I。
4)属性计数器:根据属性计数器的值,判断出方法中属性的个数。
5)属性表:属性计数器后面就是属性表。
3、解析方法表:
前面两个字节依然用来表示方法计数器,我们在ClassFileDemo中定义的方法如下:
在这里插入图片描述
查看ClassFileDemo字节码中的值,如下图所示:

在这里插入图片描述
前面两个字节依然用来表示方法表的容量,值为0x0002,表示有两个方法。ClassFileDemo源码中只定义了一个方法,但是这里却显示两个方法,这是因为它包含了默认的构造方法。

继续分析字节码,在方法计数器之后是方法表,方法表中前两个字节表示访问标识,即0x0001,对应访问标识表可知访问标识为public。

接下来2个字节是方法名索引的值为0x0009,查询常量池中的第9项,这个名为的方法实际上就是默认的构造方法了。

     #9 = Utf8         <init>

描述符索引的值为0x000a,查询常量池中的第10项,如下所示,可以得到该方法是一个返回值为空的方法。

     #10 = Utf8        ()V

属性计数器的值为0x0001,即这个方法表有一个属性。属性计数器后面就是属性表了,由于只有一个属性,所以这里也只有一个属性表。

属性表的前两个字节是属性名称索引,这里的值为0x000b,查下常量池中的第11项,如下所示,表示这是一个Code属性,我们方法里面的代码就是存放在这个Code属性里面。相关细节暂且不表。

     #11 = Utf8         Code

再继续看第二个方法的字节码,如下图所示:在这里插入图片描述

访问标识的值为0x0001,查询上面字段访问标识的表格,可得字段为public。方法名索引的值为0x0010,查询常量池中的第16项,可知方法名称为fun。可以看到,第二个方法表就是我们自定义的fun()方法了。

     #16 = Utf8         fun![在这里插入图片描述](https://img-blog.csdnimg.cn/direct/ee651ad1e6774067b1a153556f21aacd.png)

描述符索引的值为0x000a,查询常量池中的第10项,可以得到该方法同样也是一个返回值为空的方法。对照源代码,结果一致。

     #10 = Utf8         ()V

属性计数器的值为0x0001,即这个方法表有一个属性。属性名称索引的值同样也是0x000b,即这也是一个Code属性。

3.8、属性表集合

方法表集合之后的属性表集合,指的是class文件所携带的辅助信息,比如该class文件的源文件的名称以及任何带有RetentionPolicy.CLASS或者RetentionPolicy.RUNTIME的注解。这类辅助信息通常被用于JVM的验证和运行,以及Java程序的调试,一般无须深入了解。此外,字段表、方法表都可以有自己的属性表。用于描述某些场景专有的信息。属性表集合的限制没有那么严格,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,但JVM运行时会忽略掉它不认识的属性。前面我们看到的属性表都是Code属性。Code属性就是存放在方法体里面的代码,像接口或者抽象方法,它们没有具体的方法体,因此也就不会有Code属性了。和常量池计数器以及常量池的设计一样,属性表同样设计了属性计数器和属性表。

1、attributes_count(属性计数器): attributes_count的值表示当前class文件属性表的成员个数。
2、attributes[](属性表): 属性表的每个项的值必须是attribute_info结构。属性表的结构比较灵活,各种不同的属性只要满足以下结构即可。

1)属性的通用格式:属性表的通用格式如下表所示,只需说明属性的名称以及占用位数的长度即可,属性表具体的结构可以自定义。
在这里插入图片描述
2)属性类型:属性表实际上可以有很多类型,上面看到的Code属性只是其中一种,Java虚拟机规范里面定义了23种属性。下面这些是虚拟机中预定义的属性,如下表所示表格按照属性可能出现的位置排序。
在这里插入图片描述
通过手动去解读class文件,终于大概了解到其构成和原理了。实际上,可以使用各种工具来帮我们去解读class文件,而不用直接去看这些十六进制的数据。下面介绍javap指令解析class文件。

4、使用javap指令解析class文件

通过解析反编译生成的class文件,可以帮助我们深入地了解Java代码的工作机制。但是,手动解析class文件结构太麻烦,除了使用第三方的jclasslib工具之外,Oracle官方也提供了javap命令工具。

javap是JDK自带的反编译工具。它的作用就是根据class文件,反编译出当前类对应的字节码指令、局部变量表、异常表和代码行偏移量映射表、常量池等信息。例如通过局部变量表,我们可以查看局部变量的作用域范围、所在槽位等信息,甚至可以看到槽位复用等信息。

解析class文件得到的信息中,有些信息(如局部变量表、指令和代码行偏移量映射表、常量池中方法的参数名称等)需要在使用javac编译成class文件时,指定参数才能输出。比如直接执行javac xx.java,就不会在生成对应的局部变量表等信息,如果使用javac -g xx.java就可以生成所有相关信息了。如果使用Eclipse或IDEA,则默认情况下Eclipse、IDEA在编译时会帮助生成局部变量表、指令和代码行偏移量映射表等信息。

javap的用法格式如下:

     javap <options><classes>

其中,classes就是要反编译的class文件。在命令行中直接输入javap或javap -help可以看到javap命令有如下选项,如下表所示:
在这里插入图片描述
一般常用的是-l、-c、-v三个选项。

     javap -l 会输出行号和本地变量表信息。
     javap -c 会对当前class字节码进行反编译生成汇编代码。
     javap –v 除了包含-c内容外,还会输出行号、局部变量表信息、常量池等信息。

1、使用举例
通过一段代码来查看使用javap命令的效果,Java源文件如代码清单如下所示:
在这里插入图片描述
输入如下命令可以看到比较完整的字节码信息。

     javap -v -p JavapTest.class

结果如下,相关的信息在字节码中有注释。

下面的内容用来描述字段表集合信息,包括字段名称(例如private int num表示字段名称为num),字段描述符(例如descriptor:I表示字段类型为int)和字段的访问权限(例如flags:ACC_PRIVATE表示字段访问权限为private)。如果包含常量则用ConstantValue来表示。
在这里插入图片描述
接着就是方法表集合的信息,包含了类中方法信息.
2、总结
通过javap命令可以查看class文件版本号、常量池、访问标识、局部变量表、指令代码行号表等信息。无法看到类索引、父类索引、接口索引集合,但是可以看到本类的全限定名,父类的全限定名,以及接口的全限定名。另外我们还无法看到()和()字样,但是可以看到()对应源程序的静态代码块(比如JavapTest中的static{}就可以被展示出来),也可以看到()对应的JavapTest中的构造方法信息。

一个方法的执行由很多字节码指令构成,会涉及虚拟机栈、堆、常量池,以及其他内存(比如方法区)等多区域的协同合作。比如“getstatic #7”表示从常量池中获取索引地址为“#7”的数据放入操作数栈中,“istore_1”指令表示将操作数栈中的数据放入到局部变量表中索引为1的位置。

平常,我们比较关注的是Java类中每个方法的反汇编中的指令操作过程,这些指令都是顺序执行的。

5、小结

主要介绍了class文件的基本格式,从class文件一步一步解析其中包含的内容以及最后使用javap指令解析class文件。随着Java平台的不断发展,在将来,class文件的内容也一定会做进一步的扩充,但是其基本的格式和结构不会做重大调整。从JVM的角度看,通过class文件可以让JVM平台支持更多的计算机语言。因此,class文件结构不仅是JVM的执行入口,更是Java生态圈的基础和核心。

  • 13
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值