JVM的全称是Java Virtual Machine,俗称Java虚拟机。"虚拟"的意思是它是一套用于计算设备的规范,是一个抽象计算机,基于此规范,各厂商提供了自己的实现,如Oracle官方的HotSpot、阿里的TaobaoVM、IBM的J9、zual公司的zing等。
我们用编程语言写的程序,最终要变成机器可执行的二进制指令,这个过程就是编译。Java程序编译过后会生成二进制的.class文件,虚拟机负责解释执行文件中的指令。不同平台(如Windows、Linux等)有专属的JVM,只要该平台能够安装对应版本的JVM,就可以运行.class文件,也就是常说的Java"一次编译,到处运行(Compile once,run anywhere)",这也是Java具有跨平台特性的根本所在。
JVM虽然叫做Java虚拟机,但是JVM并不只针对Java语言,任何能编译成class的语言都能在JVM中运行,如Scala、kotlin、groovy等。所以也可以说,JVM是一个跨语言平台。
那么.class文件里到底有些什么?我们从一个最简单的Java类入手,了解.class的文件结构。
package com.menglaoshi.test;
public class TestClass01 {}
推荐通过开发工具(如IntelliJ IDEA,简称idea)编译,查看编译后的class,开发工具会帮我们反编译转成可读的模式:
忽略自动生成的注释,可以看到编译后的文件比源java文件多了一个无参构造方法。学过Java的都知道,当一个类不手动添加构造方法的时候,它默认会有一个无参构造,现在从编译后的.class中可以看到这一点。
使用javap可以查看字节码信息:
使用javap -verbose XX.class查看编译信息 |
---|
接下来用二进制的形式查看.class,选择一种适合的文本工具,如Notepad++(需安装HEX-Editor插件),idea(安装BinEd插件),二进制形式查看文件如下:
class文件的二进制字节码 |
---|
以16进制的形式打开文件如下,从"ca fe ba be"到"00 0c"这部分内容就是指令内容。
16进制的字节码文件 |
---|
根据官方文档(Chapter 4),class文件由8bit的流组成,8bit是一个byte字节(8-bit bytes),byte是读取class文件的最小单元,也就是说16位、32位、64位的量是通过读取2个、4个、8个8-bit bytes实现的。为了方便表示,文档中用u1代表1个字节,u2代表2个字节,u4代表4个字节。
class文件结构如下:
The ClassFile Structure(from Section 4.1) |
---|
|
文件开头的4个字节叫做魔数(Magic Number),用于识别文件类型。我们通常会通过文件拓展名识别文件类型,比如.txt、.mp3,但是拓展名可修改,即使把一个txt文本拓展名改成.mp3音乐类型,在计算机中也无法正常播放音乐。这是因为每种文件的前几个字节都有固定的含义,代表真实的文件类型,这就是魔数的作用。
Java class文件的魔数 |
---|
如果文件开头是"ca fe ba be",代表这是一个class文件,否则它就不是class。魔数的值可能是在文件编码设计时随意填充的没有特殊意义的值,也可能是设计者故意为之。比如"ca fe ba be"的由来就有一个小故事,相传Java之父詹姆斯·高斯林(James Gosling)经常吃午餐的地方,是著名美国乐队Grateful Dead成名前演出的地方,后来为了纪念乐队去世的成员,这个地方被称为"cafe dead"。当时高斯林刚好在做文件编码维护,他发现"cafe dead"这几个字母刚好都是16进制中的字母,而Java名字的由来也与咖啡(cafe)有关,于是灵机一动,选择了"babe"(宝贝,可用于称呼爱人)作为魔数,“cafe babe"这个既是能表达"咖啡 宝贝”,又是16进制数字的魔数就这样诞生了,可见高斯林还是很宝贝Java的。
次版本号minor version、主版本号major version |
---|
接下来两个字节内容"00 00"是次版本号minor version,"00 34"是主版本号,对应的是Java的版本。如果主版本号是M,次版本号是m,那么文件版本可以表示为M.m。主、次版本号共同决定了类文件格式的版本。
16进制的"34"对应10进制是52,52对应的是Java 8。版本号从Java 6开始对应50,Java 7是51,以此类推。我们在idea中打开class文件,可以看到提示:
因为Java语言一直在发展,会加入新特性,如果class文件的版本号超出虚拟机所能处理的版本范围,虚拟机不会执行该文件,如用Java 8编译后的class,放到Java 5的虚拟机中是不能执行的。
常量池大小constant_pool_count |
---|
接下来两个字节是常量池里有多少内容(constant_pool_count),常量池里能存constant_pool_count-1个常量。本案例中"00 10"对应十进制是16,也就是类中有16-1=15个常量。虚拟机在执行指令时,要依靠常量池中的保存的符号信息。
借助idea插件jclasslib可以看到常量池:
以"CONSTANT_"标记的内容就是常量,常量池中的每种常量都有一个u1的tag标记位,可以理解为类型编号,截止到Java 19的常量类型和tag值如下。常量池中的常量,索引从1开始,到constant_pool_count-1结束。常量池中存放着字符串常量、类、接口名、字段名、以及类文件和其子结构中引用的其他常量。
常量类型 |
---|
细心的读者可能发现tag并不是连续的,比如没有tag2、tag13、tag14。有资料说在Java1.0草案的里有tag2,但今天在Java官网找不到对应的说明。不过对比Java 6、Java 7、Java 19可以看到,Java 6时已经存在tag12,而从Java 7开始直接从15开始新增;Java 7没有tag 17,而Java 19有tag 17。由此推断这些中断的tag在Java的研发过程中是存在的,可能设计的不够完善,在推进过程中被废弃了,考虑到兼容性,这些tag没有释放出来,未来完善之后也可能重新释放出来。
篇幅所限,本文只介绍当前案例中涉及到的常量类型,一个常量内容包含几个部分,由多个字节组成,其中第一个字节都是tag值,每个tag后跟2个或更多个字节来描述常量信息。
常量结构 |
---|
本案例中用到的四种常量如下,以CONSTANT_Utf8_info为例,它表示Utf-8字符串类型,由u1+u2+u1共4个字节组成,第一个字节是tag,第二到三个字节是字符串占用的字节数length,第四个字节是长度为length的utf8字符串。后面查看二进制文件时,每个字节的含义需要对照下面的思维导图:
结合二进制文件看,常量池第一个字节是"0a",对应的十进制是10,也就是tag为10的常量,是CONSTANT_Methodref_Info,代表方法声明,它由u1+u2+u2共5个字节组成,对应"0a 00 03 00 0d"。
其中"0a"是tag,"00 03"是声明方法的类的索引(十进制3),"00 0d"是方法声明对应的索引(十进制13)。直接从二进制文件上不方便找出索引3和索引13对应的字节码,可以通过idea先查看一下它的含义。
接下来的"07"又是下一个常量的tag,它对应CONSTANT_Class_info,是类的信息,包含u1+u2共3个字节组成,即"07 00 0e",其中"00 0e"(十进制14)是类的全限定名的索引:
查看常量14,它是一个字符串类型,值是当前类的全限定名。
按照上述方法继续向后解析,"07 00 0f"又是一个tag为7的常量,指向的是常量15。
之后是一个"01"类型的常量,CONSTANT_Utf8_info字符串类型,"01"是tag,"00 06"是字符串长度,值是6代表接下来6个字节是字符串内容:
表示字符串内容的6个字节是"3c 69 6e 69 74 3e"查找对应的ASCII码表,可以看到字符串的值是""(后续讲到,它对应构造方法)。
按照此方法,可以看到第4~12、14、15个变量都是字符串:
第13个变量的tag是"0c",对应十进制的13,是CONSTANT_NameAndType_info类型,由u1+u2+u2共5个字节组成,第23字节指向常量4,第45字节指向常量5。
到此常量池中的内容已经读完了,从常量1开始梳理一下引用关系:
大体上可以明白,常量1描述的是Object类默认的无参构造方法。在字符串中我们可以看到"“、”()V"这样的值,这些值是Java规范中约定的描述。其中""是一个特殊的字符串,专门代指构造方法。
普通方法的描述的时候,方法名用一个字符串表示,方法参数和返回值用另一个字符串表示,其规则为:
(参数描述)返回值描述
参数描述指的是方法入参的数据类型,返回值描述包括返回值数据类型和void两种。void用大写字母V表示。数据类型见下表:
字段描述符 (from Section 4.3.2) |
---|
大家可以看下面的例子:
//例:void t(int a)
(I)V
//例:String toString()
()Ljava/lang/String
//一维数组int[]
[I
//二维数组String[][]
[[Ljava/lang/String
//例:long test(int[] a,float b)
([IF)J
到此常量池解读完毕。在查看官方文档时,如果看到命名为xx_index的属性,基本都是引用的常量池。
常量池结束之后,接着是两个字节的访问标志access_flags,这个标志用于识别一些类或者接口层次的访问信息,如:这个Class是类还是接口、是否定义为public类型、是否定义为abstract类型、如果是类的话,是否被声明为final等。访问标志列表如下,其中ACC_SUPER是在Java1.0.2以后默认都为真。
当前二进制文件中可以看到access_flags值为"00 21",实际是ACC_PUBLIC和ACC_SUPER进行或运算的结果,即:
0x0001 | 0x0020=0x0021。
接下来几个字节是:
- 当前类描述 this_class(u2)
- 父类描述 super_class(u2)
- 接口数量 interfaces_count(u2)
如果接口数量不为0,后面会有interfaces_count个接口描述,每个描述都是引用常量池中的地址,如果接口数为0,则不占字节。
当前this_class值为"00 02",指的是2号常量,可以看到是当前类的全限定名:
super_class值为"00 03",指的是3号常量,可以看到父类是Object类。当super_class为0时,那么当前这个类一定是Object类。
接口数量interfaces_count为0,所以后面接口描述不占字节。
接下来是字段数(成员变量数)fields_count(u2),如果字段数不为0,后面会接fields_count个字段描述,字段描述只包含当前类或接口中声明的字段,不包含父类和父接口中声明的字段。当前fields_count是0,所以后面不占字节。
再后来是方法数量methods_count(u2),后面会有methods_count个方法描述,方法描述格式如下:
前两个字节是访问修饰符,访问修饰符含义见下图,当前文件中访问修饰符为"00 01",即ACC_PUBLIC,也就是public方法。
name_index名称索引是指向常量池中的索引,当前方法name_index是"00 04",即4号常量,也就是"",可见当前方法是默认的构造方法。
descriptor_index描述索引也是指向常量池中的索引,当前指向的是5号常量,即"()V",也就是没有返回值没有入参的方法。
attribute_count是属性数,表示方法的其他附加属性数量,当前属性数量为1,所以后面会跟一个属性描述。属性描述格式如下,每个属性都有2字节的attribute_name_index,指向常量池,常量值就是属性名,接着是4字节的属性长度,每个属性的info不同,具体见官方文档:
attribute_name_index当前指向6号索引,是字符串常量"Code","Code"虚拟机中的预定义属性,用于描述方法如何实现。native和抽象方法没有Code属性,其余方法都必须有Code属性。
接下来四个字节是描述该属性的长度,当前属性长度是"00 00 00 2f"。
接下来是max_stack(u2),表示该方法操作数栈的最大深度,运行时需根据此值分配栈帧中的操作栈深度,本案例中值为"00 01"。
之后是max_locals(u2),表示局部变量表的大小,本案例中值为"00 01"。
之后是code_length(u4),表示字节码指令长度,其实只用了2字节,code_length必须大于0,且小于65536。本案例中值为"00 00 00 05",即5个字节。
接下来的code_length个字节就是真正的字节码指令,也就是具体的代码实现。在官方文档中可以查到详细的虚拟机指令集。
当前要执行的指令是"2a b7 00 01 b1"。对照文档,"2a"对应"aload_0"指令,表示将第0个槽中的局部表变量推到操作栈中。
“b7"对应"invokespecial”,调用实例方法;父类、私有和实例初始化方法调用的特殊处理。后面两个字节是参数索引,无符号indexbyte1和indexbyte2用于将索引构造到当前类的运行时常量池中,其中索引的值为(indexbyte1<<8)|indexbyte2。当前为(0x00<<8)|0x01,结果为1,即1号常量,1号常量对应的是Object类的无参构造方法。
“b1"对应的是"return”,如果是非synchronized方法且没有发生异常,则丢弃当前帧操作数堆栈上的所有值。
在jclasslib里可以看到方法具体实现和我们刚才分析的一样,一个类默认的无参构造方法里,自动调用了Object类的无参构造。
指令集之后的2个字节是异常表的长度,当前为0,代表没有异常表。
再之后的两个字节是attributes_count,表示"Code"属性中的属性数(子属性),当前有2个属性。
子属性也遵照attribute的格式,属性名(u2)+属性长度(u4)+属性信息。"00 07"表示属性名是7号常量(“LineBumberTable”),"00 00 00 06"表示属性长度为6。
LineNumberTable属性是Code属性的属性表中可选的可变长度属性。调试器可以使用它来确定代码数组的哪个部分对应于原始源文件中的给定行号。
line_number_table_length(u2)表示line_number_table中关系的数量,当前为"00 01",即1组关系。start_pc(u2)表示字节码行号,原始源文件中新行的代码从该索引开始,当前为"00 00",第0行。line_number(u2)是源文件中的代码行号,当前为"00 03",即字节码中的第0行对应源文件的第3行。
Code的第二个属性是"00 08",即LocalVaribleTable,属性长度是"00 00 00 0c",即12。LocalVaribleTable属性是Code属性的属性表中可选的可变长度属性,调试器可以使用它来确定方法执行期间给定局部变量的值。
local_variable_length = 0x0001,有一个局部变量;start_pc = 0x0000,作用域从0开始;length = 0x0005,作用域长度为5;name_index = 0x0009,指向常量9 = this;descriptor_index = 0x000a,指向常量10,即“Lcom/menglaoshi/test/TestClass01;”,index = 0x0000,即变量在位置0。
到此"Code"属性解析完毕,也就结束了对方法的解析。
最后一个部分是Class文件的属性。attribute_count(u2)记录属性数量,当前有1个属性,接下来按照属性格式,是属性名索引attribute_name_index,指向11号常量"SourceFile"。
属性长度attribute_length是2,source_file_index指向常量12,为"TestClass01.java",表示编译该类文件的源文件的名称(不含绝对路径),因为绝对路径属于特定于平台的附加信息,必须在实际使用文件名时由运行时解释器或开发工具提供。
到此一个最简单的Class文件解析完毕。