小伙伴们,大家好:
今天是JVM系列的第二篇,字节码结构详解。在讲解字节码文件之前,我觉得有必要聊聊我们为什么要学习字节码。很多小伙伴可能会有这样的想法,我编程用不上字节码文件,我学它干啥?那小伙伴们想一想,在你没有学习虚拟机之前,假设你的项目再线上遇到一个CPU占用率高的问题,你是不是只能逛逛博客,百度,寻求问题的解决方案,但是在你学习虚拟机之后,你可能会尝试着使用jstack等故障诊断工具区排查问题。又比如在你未曾看JDK源码之前,你遇到一个异常,是否又只能去百度,但是如果你看了源码,你会想着去从源码的角度分析出现这个异常的原因。学习字节码的道理同样如此,它能够带给你不一样的分析问题的角度。帮助你更好的理解Java底层的原理。说一个博主真实的经历,去年我参加京东的面试时,面试官问我,syncronized的实现原理是什么?如果我当时看过字节码文件,我可以很自信的回答这个问题,可是事实上没有如果。好啦,话不多说,我们进入今天的主题。
一、字节码文件简介
字节码文件由8位字节流组成。所有16位、32位和64位数量都是通过分别读入2、4和8个连续的8位字节来构造的。多字节数据项总是按大端顺序存储,其中高字节优先。Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”和“表”。
-
无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
-
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯性地以“_info”结尾。即如果类型是info结尾表示这一项是一张表。表用于描述有层次关系的复合结构的数据,整个Class文件本质上也可以视作是一张表,这张表由如下列构成。
上图对应的解释如下:
看了这张图大家就能明白,一个class文件就好比是表中的一行数据。表的每一列数据的类型只有u1、u2、u4或者其他的表。现在清楚了吧,下面我们就通过填表的方式来详细的说明字节码文件结构。下面咱们给出一个class文件,按照Excel表中的每一项找出对应的数据填入到Excel表中。Java代码如下:
package chapter05;
public class ClassCode {
private int i;
}
将上述的类经过javac命令编译后即可,注意本文基于JDK1.8。由于字节码文件中是以十六进制的数表示的,所以普通的文本编辑器不能打开,大家可以下载一个binary viewer工具进行查看,直接在网上下载即可,下图是在binary viewer中看到的class文件结构。
根据上面的表我们逐一进行解释:
二、魔数
我们通过查表可知,它占据class文件的前面四个字节,它的作用是标识这个文件是否为虚拟机所接受,可能大家有点疑问,虚拟机可以通过文件的后缀名可以判断呀,但是大家是否想过文件名可以随意更改,所以采用这样的方法并不安全。文件格式的制定者可以随意的取魔数值,只要不会引起混淆,class文件的魔数值中文含义为咖啡宝贝。也就是前四个字节, CA FE BA BE。
三、次要版本号
在JDK1.2之后,JDK12之前均未使用,全部置为0。从表中看出,它的数据类型是u2,即00 00。填入表中:
四、主要版本号
标识当前JDK使用的版本。本文基于JDK1.8,主要版本号为52。虚拟机可以向下兼容,即它能够接受版本号比它小的class文件,但是不能接收比它版本号高的文件。填入表中:
五、常量池数量
因为常量池中结构体的数量是不固定的,那么就需要一个标志来统计常量池中结构体的数量,知晓应该在哪儿是常量池的结尾。注意,常量池结构体的数量是以1开始计数的,如果该数量为22,那么常量池中结构体的个数为21。之所以从0开始计数,目的在于:后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池结构体”的含义,可以把索引值设置为0来表示。查表可知,常量池数量占据两个字节,对应的十六进制为:00 12,对应的asscii码为18,代表常量池中存在17个结构体。我们使用javap -v Hello.class 来查看一下,如下图所示,正好17个结构体。
将其填入表中:
六、常量池
常量池中的常量分为字面量和符号引用。字面量包括我们声明文本字符串,常量等。符号引用用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,符号引用和虚拟机的布局无关。这些符号引用不经过虚拟机在运行期转换是无法得到真正的内存地址的。在类解析阶段,再讲这些符号引用转为直接引用。(直接引用是直接指向目标的指针,比如Class对象)。为什么需要符号引用呢?这是因为在javac编译阶段是无法知道这些方法,类在运行期间的内存地址的。所以只能先以符号引用来替代。常量池中的符号引用主要分为下面几类:
1. 被模块导出或者开放的包(Package)
2. 类和接口的全限定名(Fully Qualified Name)
3. 字段的名称和描述符(Descriptor)
4. 方法的名称和描述符
5. 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
6. 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)
常量池中的每一项都可以看做是一张表。一共存在17种表结构。如下图所示:
上述17种表结构每个都不相同,唯一相同之处就是它们的结构中都包含一个类型为u1的tag,即这个tag占用一个字节,用来表明它们属于哪种表结构,待会儿我们通过这个tag找到常量池当前这一项属于哪种结构,我们就介绍哪一种结构。
刚刚我们介绍到了常量池数量,以00 0F结尾,往后数一个字节。0A的Asscii码为10,查询上面常量池表项可知,常量池第一个结构体是CONSTANT_MethodDef_info(类中的方法符号引用类型)。该项的结构体表示为:
从上述CONSTANT_MethodDef_info结构的描述中,发现它还依赖CONSTANT_Class_info以及CONSTANT_NameAndType结构体,读者可以理解为数据库的左连接和右链接。CONSTANT_Class_info代表一个类或者接口的符号引用,CONSTANT_NameAndType代表一个字段或者方法的部分符号引用。它们二者的结构体如下:
到这呀,我们发现,上述两个结构体,都使用到了CONSTATN_Utf8_info类型的结构体。它是用以存储UTF-8编码的字符串,Java的类、方法、字段名称都是用它来存储。
1、CONSTANT_Utf8_info 的结构如下:
好了,到目前位置我们就能够继续填表,CONSTANT_Method_Def第一项之后是class_index,它占据两个字节,00 03,即该索引指向常量池中的第三项,接下来是name_and_type_index,它也占据两个字节00 0C,它的值等于12,指向常量池中第十二项。填入表格吧:
继续看常量池中的第二项,tag = 07,查表可知它是一个CONSTANT_Class_info类型,上述已经又关于该类型的介绍,在此就不再赘述,我们知道该类型之后是占据2个字节的name_index,对应的十六进制为:00 0D。即指向常量池中的第13项。填入表格吧:
由于该常量池的的项目较多,我们不必每个都去使用这样的方式去看,相信从上面的讲解,大家已经了解了class文件常量池的存储结构,所以接下来我们使用javap命令更加直观的来看一下常量池的结构。如下图所示:
绿色框画出的是该项再常量池中的位置,红色框代表的是该项的类型,蓝色框代表的是该项的数据结构简写,以第一项为例,和我们上边分析的相同,是Method_def类型,它的数据结构包含一个Class类型和NameAndType类型的索引,分别是#3和#15。
#3表示Class类型,它的数据结构又包含一个Utf8类型的索引,也就是该类的符号引用(一个字符串,表明是哪个类就行,包名+类名),可以看出#3又指向了#17,#17的值为java/lang/Object。从这儿就可以看出,它是Object类。小伙伴们不要感到疑惑,虽然我们ClassCode并没有继承任何类,但是Java中Object是一切类的父类,子类在调用构造方法时,会先调用父类的构造方法,即super(),所以第一项所描述的方法,就是属于Object类。
#15使用#6和#7分别表示方法的名称和方法的描述符,方法的描述符指的是方法的参数及参数的顺序和返回值。()表示没有参数,V代表void。方法名称< init >是编译器给我们生成的实例构造器,也就是构造方法在字节码中的显示。此处就可以得出,常量池中的第一项就是Object类的构造方法。在子类进行实例化的时候,就是通过这些符号去找到对应的类和方法(注意:运行期间此处的符号已经替换成了对应的内存地址,字节码中是符号引用,任何字符串都可以,只要能够唯一的描述该类即可,当然,这是编译器应该做的事情,我们不需要关心)。怎么样,各位小伙伴觉得有趣吗。此外,更多常量池中的结构,请大家百度一下,搜索字节码文件常量池中的数据结构即可。
七、访问标志
接下来是访问标志这一项。这个标志用以说明这个类或者接口的访问信息,包括当前类访问权限,private、public、protected等、还说明当前Class是类还是接口。该标志取值说明如下:
从字节码文件简介中的表可知,该字段占据两个字节,一共可以定义16位,目前只定义了9位。当前类定义为public,且使用JDK1.8版本编译,所以访问标志位应该取值位0x0001|0x0020 即0x0021。如图中红色框所示:
八、类索引
访问标志之后是类索引,该项是一个u2类型的数据,它指向刚刚我们所描述的常量池中的某一项,如图中红色框所示:
它显示的值位00 02,所以指向常量池中的第二项,通过前文常量池的图片可知,该项是一个CONSTATN_Class_info类型,就是描述的当前类。查找过程如下图所示:
九、父类索引
在类索引之后是父类索引,它同样指向常量池的某一项,Java中只允许继承一个父类,该类并没有显示的继承类,但是Object是一切类的父类。该项值如图中红色框所示:
查找过程仍和类索引查找方式相同。
十、接口索引
由于Java中接口可以继承多个,所以在接口索引之前还存在一个接口计数器,如果接口计数器为0,代表没有继承任何接口,后面就不占据任何字节。如果存在,和上述类索引和父类索引查找方法一致。由于这个类没有实现接口,所以接口计数器中的数值为0,如图中红色框所示:
十一、字段表集合
接口索引结束后的项目是字段表集合,由于字段的数量是不固定的,所以也需要一个字段计数器来统计字段的个数。在该类中只定义了一个字段,所以字段表集合中只有一个元素,如图中红色框所示:
每个字段的描述也可以看作是一张表,字段的数据结构如图所示:
access_flags是该字段的访问标志,它和类中的访问标志很类似,用以描述该字段的权限类型:private、protected、public;并发可见性:volatile;可变性:final;详细访问标志如下图所示:
由于Java语法规则的限制,上述表格中ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED三个标志最多只能选择其一,ACC_FINAL、ACC_VOLATILE不能同时选择。在此类中声明的变量类型为private int i,所以只是存在标志值0x0002,如图红色框所示:
name_index:是指向常量池中的一个元素的索引,是该字段的简单名称。方法和字段的简单名称就是指这个变量名叫啥,也就是编程人员对该字段的命名。如图红色框所示:
表明它指向常量池中的第四项。通过上文使用javap命令得到的常量池结构可知,第四项是一个Utf8类型的字符串,也正是字段名称 i 。
descriptor_index:也是指向常量池中的一个元素的索引,是该字段的描述符。方法和字段的描述符,它用来描述字段的类型、方法的参数列表(包括数量、类型以及顺序)和返回值。描述符的标识符对应关系如下:
对于数组类型,每一维度将使用一个前置的“[”字符来描述,如一个定义为“java.lang.String[][]”类型的二维数组将被记录成“[[Ljava/lang/String;”,一个整型数组“int[]”将被记录成“[I”。用描述符来描述方法时,按照先参数列表、后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。如方法void inc()的描述符为“()V”,方法java.lang.String toString()的描述符为“()Ljava/lang/String;”,方法int indexOf(char[]source,int sourceOffset,int sourceCount,char[]target,int targetOffset,int targetCount,int fromIndex)的描述符为“([CII[CIII)I”。
该字段描述符索引对应的值为图中红色框所示:
表示它指向常量池中的第五个元素,通过上文使用javap命令得到的常量池结构可知,第五项是一个Utf8类型的字符串,其值为 I ,表明这是一个int类型的字段。
attribute_count:在descriptor_index之后是一个属性表集合计数器,属性表用于存储一些字段额外的信息,字段表可以附加一个属性表集合。详情等到我们在讲解属性表时在进行分析。由于该字段不含额外的信息,所以attribute_count为0,后面的属性表也就不占据任何字节。如图中红色框所示:
十二、方法表集合
同字段表集合一样,方法在类中的数量也是不固定的,所以也需要一个方法计数器来统计方法的个数。如图中红色框所示:
在方法表集合中存在一个方法,也就是编译器给我们自动生成的构造方法。方法表的数据结构和字段表一模一样,如下图所示:
虽然它们的数据结构相同,但是其中有部分还是有区别的。
access_flag:访问标志中的内容和字段表中的不一样,这也很有道理,因为能够修饰字段的并不一定适用于方法,比如volatile关键字。下图给出方法表的访问标志:
构造方法是public类型的,所以仅有该项值被设置,所以访问标志这一项被设置为00 01,如图红色框中所示:
访问标志之后是name_index,它同样指向常量池中的一个元素的索引。其值如下所示:
如上图所示,该方法的名称为< init >。
之后是descriptor_index,如图红色框所示:
同理,在常量池中它指向第七个元素,名称为()V,代表没有参数和返回值。
之后是关于该方法的属性表集合,此处属性表计数器不再为0,因为它需要一个属性表中名叫Code类型的数据结构来存储咱们代码翻译过来的字节码指令。可能读者关心的也正是如此。所以属性表计数器为00 01。如图所示:
十三、属性表集合
这儿谈到code属性,那么咱们就顺利的进入到下一章,也是最后一项属性表集合。在字段表集合和方法表集合中我们多次就提到了属性表集合,属性表集合就如同常量池一样,包含了众多表结构。方法表或者字段通过在常量池中的一个Utf8类型的字符串来表示使用了哪些属性。
总的来说,属性表集合中的表项如下图所示:
注意:千万别去背,咱们用到哪一个查表就行。
虚拟机不再要求各个属性表具有严格顺序,并且《Java虚拟机规范》允许只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不认识 的属性。属性表的基本结构如下图所示:
任何属性都可以在此基础上继续扩展。
好了,继续分析这个类文件:在属性表计数器之后,按照属性表结构的定义,就应该是attribute_name_index,该项取值如图所示:
它指向常量池中的一个Utf8类型的字符串,表示该属性的名称。该属性的名称为Code。
之后呢是属性表的长度,占据四个字节,如图所示:
该项的值为00 00 00 2F对应十进制为47,但是需要减去属性名索引和属性长度的6个字节。
关于Code属性的数据结构如下图所示:
attribute_name_index和attribute_length就不再赘述了。
-
max_stack代表了操作数栈深度的最大值,虚拟机在运行时需要根据这个值来分配栈帧中操作数栈深度。如下图所示:
-
max_locals:代表了局部变量表所需的存储空间。在这里,max_locals的单位是变量槽(Slot),变量 槽是虚拟机为局部变量分配内存所使用的最小单位。对于byte、char、float、int、short、boolean和 returnAddress等长度不超过32位的数据类型,每个局部变量占用一个变量槽,而double和long这两种64 位的数据类型则需要两个变量槽来存放。方法参数(包括实例方法中的隐藏参数“this”)、显式异常处 理程序的参数(Exception Handler Parameter,就是try-catch语句中catch块中所定义的异常)、方法体中 定义的局部变量都需要依赖局部变量表来存放。注意,并不是在方法中用了多少个局部变量,就把这 些局部变量所占变量槽数量之和作为max_locals的值,操作数栈和局部变量表直接决定一个该方法的栈 帧所耗费的内存,不必要的操作数栈深度和变量槽数量会造成内存的浪费。Java虚拟机的做法是将局 部变量表中的变量槽进行重用,当代码执行超出一个局部变量的作用域时,这个局部变量所占的变量 槽可以被其他局部变量所使用,Javac编译器会根据变量的作用域来分配变量槽给各个变量使用,根据 同时生存的最大局部变量数量和类型计算出max_locals的大小。max_locals的值如下所示:
-
code_length代表代表字节码长度,占据四个字节,如图所示:
-
code是用于存储字节码指令的一系列字节流。我们编写每行代码都会翻译成字节码指令,每个字节码指令占据一个字节,虚拟机读取该字节后,会通过查表得到该指令,以及得出该指令是否需要参数。如果需要,就读取对应的字节的参数。我们知道一个u1 数据类型的取值范围为0x00~0xFF,对应十进制的0~255,也就是一共可以表达256条指令。字节码指令如图所示:
使用javap命令得到的字节码指令如下所示:
虚拟机翻译字节码指令的过程如下:
1)读入2A,查表得0x2A对应的指令为aload_0,这个指令的含义是将第0个变量槽中为reference类 型的本地变量推送到操作数栈顶。
2)读入B7,查表得0xB7对应的指令为invokespecial,这条指令的作用是以栈顶的reference类型的 数据所指向的对象作为方法接收者,调用此对象的实例构造器方法、private方法或者它的父类的方法。这个方法有一个u2类型的参数说明具体调用哪一个方法,它指向常量池中的一个 CONSTANT_Methodref_info类型常量,即此方法的符号引用。
3)读入000A,这是invokespecial指令的参数,代表一个符号引用,查常量池得0x000A对应的常量 为实例构造器“< init >()”方法的符号引用。
4)读入B1,查表得0xB1对应的指令为return,含义是从方法的返回,并且返回值为void。这条指 令执行后,当前方法正常结束。 -
eception_table_length:它的值为0x0000,即异常表长度为0,所以其异常表也就没有了,如图所示:
-
attributes_count:的值为0x0002,即Code属性表里面还有两个其他的属性表。如图所示:
之后的分析过程和上述分析方法表中的属性一致,此处就不再赘述了。大家可以去尝试去翻译一下,有问题可以私信博主哟,所有属性表的结构,等博主整理出来,会第一时间分享给大家。
好啦,字节码文件结构基础版本就讲到这儿啦,下面我们将从字节码的角度去分析一些面试问题。有兴趣的小伙伴们可以先关注博主。本文若有不足之处,请大家多多指正,谢谢大家。