文章目录
- 1、概述
- 1.1、class文件的跨平台性
- 1.2、编译器分类
- 1.3、透过字节码指令看代码细节
- 2、虚拟机的基石:class文件
- 2.1、字节码指令
- 2.2、解读字节码方式
- 3、class文件结构
- 3.1、魔数:class文件的标识
- 3.2、class文件版本号
- 3.3、常量池:存放所有常量
- 3.4、访问标识
一段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变量是否相等。
运行结果如下:
truefalse
显而易见,两次运行结果并不相同。定义的变量是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。