序
本期想来聊聊关于java
的编译后的class
文件中到底藏了哪些内容,使得可以其支撑起了java
“一次编译,到处运行”的。我们将从java
自带的反解析命令javap
入手,通过二进制文件查看器来查看class
文件,再通过查表分析的方式,一步步分析出编译后的class
文件中蕴藏了什么信息。
反编译工具
我们先来看看javap
的官方定义:“javap
是JDK
自带的反解析工具。它的作用就是根据Class
字节码文件,反解析出当前类对应的code
区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等等信息。”,简单来说,javap
可以通过反编译的方式,将Class
文件从机器能够看懂的一系列指令反编译和整理成我们人类可以看懂的描述代码信息的文件,我们可以通过反编译下面的代码,查看其反编译结果。
|
|
通过运行javap -c cn/yinan/bytecode/MyTest1.class
命令来获取其一些基本信息:
|
|
上面的信息仅仅简单描述了Class
文件中的类以及方法信息,但是没有明确出常量池中的信息,我们可以使用javap -verbose cn/yinan/bytecode/MyTest1.class
命令来查看更加详细的信息:
|
|
以上的信息便是Class
文件中详细反映出的信息,而在下面的分析过程中,我们将会使用到上面反编译的信息。
类文件信息
在分析Class
文件内容之前,我们先来看看jvm
是如何分析和理解这些字节信息的。简单来说,反编译程序就是通过查表的方式来反解析的到这些指令信息,通过按照一定格式的指令规范,反编译程序能够依据这些规范来逐步逐步解析文件,之后通过得到的这些汇编指令,来让机器执行相关指令操作。下面我们就来说说这些“规范”。
Class文件格式
根据Java
虚拟机规范的规定,Class
文件格式采用一种类似于C
语言结构体的伪结构体来存储数据,这种伪结构体中只有两种数据类型:无符号数和表,后面的解析都要以这两种数据类型为基础。
无符号数
无符号数属于基本数据类型,以u1
、u2
、u4
、u8
来分表代表1
个字节、2
个字节、4
个字节和8
个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8
编码构成的字符串值。
表
表有多个无符号数或者其它表作为数据项构成的复合数据类型,所有表都习惯地以_info
结尾。表用于描述有层次的复合结构的数据,整个Class
文件本质上就是一张表。
整个Class
文件表的文件格式如下所示:
从图中我们可以知道整个Class
文件的格式和信息,比如每一个文件的头4
个字节是一个名字叫magic
(魔数)的字段,而紧接着这4
个字节后面的2
个字节表示的是minor_version
(次版本),之后2
字节表示的是major_version
(主版本)等等;而除此之外,我们依据我们上面的表的定义,文件中存在co_info
、field_info
、method_info
以及attribute_info
四张表信息。接下来我们详细说说Class
文件信息。
魔数与版本
魔数
每个Class
文件头4
个字节都是固定的:0xCAFEBABE
(咖啡宝贝)的魔数,它主要是用来确定这个文件是否是一个能够被虚拟机接受的文件,这种确定方式除了在java
领域被使用之外,像一般的图片信息等都是有类似这样的魔数,例如你可以修改图片的后缀名为png1
等自定义格式,然后使用浏览器进行打开,浏览器仍然可以将其识别成一张图片,这也是魔数在其中起作用。所以,如果更改Class
文件的后缀名,那么是否能够被jvm
识别呢?大家可以试试。
版本
紧跟着魔数后面的第5
和第6
个字节是次版本号(minor_version),第7
和第8
个字节是主版本号(major_version)。例如上面反编译出来的文件中的major version: 52
,表示的就是主版本为JDK1.8
。
常量池
紧跟着主版本号之后的是常量池入口,常量池可以理解为Class
文件中的资源仓库。常量池主要由常量池数量与常量池数组这两部分共同构成,常量池数量紧跟在主版本号后面,占据2
个字节;常量池数组紧跟在常量池数量之后。常量池数组(常量池表)与一般的数组不通的是,常量池数组中不同的元素类型、结构都是不同的,长度当然也就不同;但是,每一种元素的第一个数据都是u1
类型,该字节是个标志位,占据1
个字节,jvm
在解析常量池时,会根据这个u1
类型来获取元素具体类型。值得注意的是,常量池数组中的元素个数 = 常量池数 - 1 (其中0
暂时不使用),目的是满足某些常量池索引值的数据在特定情况下需要表达【不引用任何常量池】的含义,根本原因在于,索引为0
也是一个常量(保留常量),只不过它不位于常量表中,这个常量就对应null
值,所以,常量池的索引从1
而非0
开始。由于常量池中的常量有大概14
种类型,且这14
种类型各自均有自己的结构,具体可以查看下面的常量结构表格:
访问标志
在常量池结束之后,紧跟着的2
个字节代表访问标志(access_flags
),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class
是类还是接口;是否定义为public
类型;是否定义为abstract
类型;如果是类的话,是否被声明为final
等。具体标志位以及标志的含义见下表:
类索引、父类索引与接口索引集合
类索引(this_class
)和父类索引(super_class
)都是一个u2
类型的数据,而接口索引集合是一组u2
类型的数据集合,Class
文件中由这三项数据来确定这个类的继承关系,由于一个类只有一个父类但是却可以有多个接口,所以父类索引只有一个u2
类型的数据,而接口索引却是一个集合。
字段表
字段表(field_info
)用于描述接口或者类中声明的变量。字段(field
)包括类级变量以及实例变量,但是不包括在方法内部声明的局部变量。字段表的格式如下:
|
|
字段表中主要由访问标志(access_flags
)、字段名称索引(name_index
)、描述符索引(description_index
)以及属性信息(attribute_info
)构成。
其中字段访问标志和类的访问标志由一点相似,具体访问标志想必大家应该都可以猜测出来,如下表所示:
字段名称索引用来记录的是字段名称位置,需要通过索引去常量池中查找对应的值。
在jvm
规范中,每个变量/字段都有描述信息,描述信息主要的作用是描述字段的数据类型、方法的参数列表(包括数量、类型与顺序)与返回值。根据描述符规则,基本数据类型和代表无返回值的void
类型都用一个大写的字符表示,对象类型则使用字符L加对象的全限定名称来表示。为了压缩字节码文件的体积,对于基本数据类型,jvm
都只使用一个大写字母来表示,如下所示:B - byte
, C - char
, D - double
, F - float
, I - int
, J - long
, S - short
, Z - boolean
, V - void
, L - 对象类型
, 如 Ljava/lang/String
。
对于数组类型来说,每一个维度使用一个前置的[ 来表示, 如int[]
被记录为[I
, String[][]
被记录为[[Ljava/lang/String
。用描述符来描述方法的时候,按照先参数列表、后返回值的顺序来描述;参数列表按照参数的严格顺序放在一组()之内,如方法:String getRealNameByIdAndNickname(int id, String name)
的描述符为:(I, Ljava/lang/String;)Ljava/lang/String
;
方法表
如果理解了上一节关于字段表的内容,那关于方法表的内容将会变得很简单。Class
文件存储格式中对应方法的描述与对字段的描述几乎采用类完全一致的方式,方法表结构如同字段表一样,依次包括了访问标志(access_flags
)、名称索引(name_index
)、描述符索引(descriptor_index
)和属性表集合(attributes
)几项。具体方法表结构如下:
|
|
方法表中的访问标志随着方法和字段的差异性,也随之增加和减少了一些访问标志,具体访问标志信息如下表:
行文至此,也许大家会产生疑问,方法的定义可以通过访问标志、名称索引、描述符索引来表示清楚,但是方法最需要用到的方法体的代码到哪里去了?方法里面的Java
代码,经过编译器编译成字节码之后,存放在了方法的属性表集合中,在方法表的集合中,有一个名为Code
的属性,在这个属性中保存了方法的方法体信息。我们将在后续进行讲解。
属性表
属性表(attribute_info
)在前面的讲解中已经出现多次,在Class
文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。
与Class
文件中其它的数据项目要求严格的顺序、长度和内容不容,属性表集合的限制稍微宽松了一些,不再要求各个属性表具有严格的顺序,并且只要不与已有属性名重复,任何人实现的编译器都可以像属性表中写入自己定义的属性信息,Java
虚拟机在运行时会忽略掉它不认识的属性。
在《Java虚拟机规范(第2版)》中预定义了9
项虚拟机实现应当能识别的属性,而之后随着Java
版本升级,预定义的属性又随着增加了很多,这里我们介绍一下刚刚提到的Code
属性,以及LineNumberTable
和LocalVariableTable
属性,方便之后我们针对Class
文件分析时使用。
Code
Java
程序方法体在代码经过编译后,最终变为字节码指定存储在Code
属性内。Code
属性出现在方法表的属性集合之中,单并非所有的方法表都必须存在这个属性,比如接口或者抽象类中的方法就不一定存在Code
属性。
Code
属性表的结构如下所示:
|
|
attribute_name_index
:指向常量池的索引,常量值固定为Code
。
max_stack
:代表了操作数栈(Operand Stacks)深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧中的操作栈深度。
max_locals
:代表了局部变量所需存储的存储空间。
code_length
和code
:用来存储Java
源程序编译后生成的字节码指令。code_length
代表字节码长度,code
是用于存储字节码指令的一系列字节流。
exception_table_length
和exception_table
:方法体中异常信息的纪录
LineNumberTable
LineNumberTable
属性用于描述Java
源码行号与字节码行号(字节码的偏移量)之间的对应关系。
LineNumberTable
属性表的结构如下所示:
|
|
LocalVariableTable
LocalVariableTable
属性用于描述栈帧中局部变量表中的变量与Java
源码中定义的变量之间的关系,默认会生成到Class
文件中,可以在Javac
中分别使用-g:none
或-g:vars
选项来取消或要求生成这项信息。
LocalVariableTable
属性表的结构如下所示:
|
|
Class文件分析
说了这么久的理论知识,下面我们开始实战,以一个简单的java
代码入手,然后来尝试读一读字节码文件,看看能不能翻译出这个文件中的内容。
首先我们以上面提到的的java
代码为例:
|
|
我们使用Hex Fiend工具打开上面代码的字节码文件,看到了一系列的十六进制数据:
依靠上文的分析步骤,我们来看看这些十六进制数据表示什么含义
首先,java
字节码文件的头4
个字节是魔数,且值为固定的:0xCAFEBABE
后面的依次两两共四个字节分别表示版本号,这里是00 00 00 34
,其中前两个字节表示的是次版本号(minor_version
),这里是0
,后两个自己表示主版本号(major_version
),这里是3*16+4=52
,因为52
表示的是jdk8
,所以这里的版本是1.8.0
。
跟在版本号之后的,便是常量池的一些信息,从上面的分析,我们可以知道,常量池中前2
个字段表示的是常量池数量,我们这里看到的值是:00 18
,所以常量池元素个数应该是:16+8-1=23
,具体原因上面已经提到过,这里不再赘述。
下面我们来看看到底有哪23
个常量。
首先,每个常量的第一个字节表示的都是一个tag
值,通过这个值可以找到当前常量的类型,从而确认该常量结构,我们可以看到这个tag
值是:0A
,即10
,所以它的类型是CONSTANT_Methodref_info
类型,通过查表得出该常量结构类型如下图:
CONSTANT_Methodref_info
型常量的第二个数据项为index
,类型是u2
,index
存储的是一个索引值,从class
文件中查得该值为0x0004=4
,即它指向常量池中第4
个常量;第三个数据项也是索引其值为0X0014=20
,指向常量池种第20
个常量。
到此为止,第一个常量项是CONSTANT_Methodref_info
型常量项,该类型常量项用来表示类中方法的符号引用,其内容为tag=10,index1=4,index2=20
,因为其表示的是类中方法的符号引用,所以index
中存放的不是一个具体得内容,而是一个索引位置,所以说其具体内容存放在另一个常量项中。下面我们就来看看其索引指向的常量项(即第4
个常量项)的内容到底是什么?
找第4
个常量项之前需要知道第4
个常量项的开始位置,所以需要知道前3
个常量项所占字节数。那好就看第2
个常量项,由于第一个常量项共占了5
个字节,则紧接着的字节就为第2
个常量项的tag
,如下图可得其值为0x09=9
,说明第2
个常量项得项目类型为CONSTANT_Fieldref_info
。查表得其该类型得字节长度固定占5
个字节。
依次类推查的第3,4
个常量项为CONSTANT_Class_info
型。如下图:
下面就看第四个常量项CONSTANT_Class_info
的内容0X070017
。 CONSTANT_Class_info
存放的是指向类或接口的符号引用。
根据CONSTANT_Class_info
项常量项的结构可知其index数据项又是一个索引项,指向全限定名常量项索引,index
数据项的值为0x17=23
,表示指向第23
个常量项,正好在最后一个,但是要知道23
个常量项必须知道前22
个常量项所占字节,这里就不一一找了,最后找到第23
个常量项CONSTANT_Utf8_info
在class
文件中包含的内容如下:
根据tag
等于1
得第23
项是CONSTANT_Utf8_info
型,该类型存储UTF-8
编码的字符串,在MyTest1.class
文件种该常量项种个数据项的内容如下:
length(u2)
:表示UTF-8
编码的字符串占用的字节数,值为0x0010=16
。bytes(u1)
:表示长度为length
的UTF-8
编码的字符串。- 因为
length=16
,所以length
后面紧跟的长度为16
个字节的连续数据是一个使用UTF-8
缩略编码表示的字符串。后面紧跟的第一个字节为0x6A=106
,那该编码代表的字符为j
,我们发现106
其实就是字符j
对应的ASCII
码。后面16
个字节代表的字符就是:java/lang/Object
。
根据上面的找法我们就可以找出常量池中包含的内容:字面量和符号引用。所以我们可以发现,其实整个查找过程就是查表,我们依据现有的表,就可以很容易地找到我们需要的数据,而最终查找到的结果就是我们在整篇文章开头所使用的命令:javap -verbose cn/yinan/bytecode/MyTest1.class
所获取到的结果;后续的字节码翻译就不再继续下去,只要找到相关的表,就可以找到对应的结果值。
总结
整篇文章我们从开始讲述了java
字节码文件的结构,以及这些结构中的几张非常重要的表结构,之后通过一个实例来和大家一起动手去翻译了一部分字节码信息,相信通过这篇文章的学习能够更加理解java
字节码相关的信息,为自己打下牢固的基础。
参考
- 《深入理解Java虚拟机》 - 周志明
- Class文件结构–常量池(一)