java类文件结构

从我开始接触计算机开始,到现在,计算机还是只认识0和1,所以我们写的程序必然要经过一通操作最后变成机器码才可以执行。但是越来越多的程序语言选择了类似java字节码这种和平台无关、和操作系统无关也和机器指令码完全无关的存储格式。

除了平台无关性,还有一点是我之前没有注意到的,它希望能够实现语言无关性:让其他语言运行在java虚拟机之上。这是因为java虚拟机只和字节码文件进行绑定,而不是和java文件进行绑定。所以我完全可以下载一个能够对python进行解释的解释器,让它把我的Python代码变成字节码,然后扔到java虚拟机上去执行。

你可能会奇怪,java是一种强类型的语言,而Python是弱类型的,为什么两者可以同时放到java虚拟机上去运行呢?因为像java的这些关键字,其本质上是多条字节码组合而成的,所以才能够实现如此不可思议的事情。

class类文件结构

首先需要明确,每个class文件都对应唯一一个类或者接口的信息。反之不一定,即类或者接口它可以不在文件里(我们可以动态生成呀)。class文件中最小的单位是字节(8比特为一个字节),如果不足一个字节,就进行高位在前的填充。

class文件格式采用的是一种类似结构体的数据结构来进行数据的存储,而且这种数据结构中只有两种数据类型:无符号数和表:

  • 无符号数可以分别使用u1u2u4u8来分别表示1个字节,2个字节,4个字节和8个字节的无符号数,而无符号数是可以用来表示数字、索引引用、数量值或者是用utf-8表示的字符串。
  • 表就是由无符号数以及其他表构成的数据类型,习惯上用“_info”结尾,而整个class文件其本质上也是一张表。由于可以套娃,所以其实整个结构非常复杂。
  • 这里特别提到一种,如果需要描述同一类型(即都是无符号数或者都是表)但是数量不定的多个数据时,会使用一个前置容量计数器加若干个连续数据项的形式。

PS:其实这部分我是推荐使用可视化工具来进行的,既方便又直观,classviewer体验就还行,idea环境下的jclasslib插件也是很清楚明了,当然你要用javap来查看也是OK的。下面的我以idea的插件为例进行演示,整个class文件如下图所示。

image-20200613132329366

magic number和版本号

每个字节码最开始的4个字节被称为magic number,用来确认这个字节码文件能否被虚拟机接受。就像JPG或者GIF这种文件开头的字节一样,java字节码的头四个字节一定是0xCAFEBABE(咖啡宝贝)

image-20200114091745669

接下来的四个字节,第五和第六字节是次版本号(在上图中是0x0000)和主版本号(在图中是0x0037),转换成十进制就是55,而有个规定就是JVM只会处理版本号小于等于它版本号的class文件(我用的是open jdk11,所以能够处理几乎所有的class文件,而如果你用较低版本的jdk来运行上图所示的class文件,会出现以下错误:

image-20200114092630716

很明显告诉你了本机上用的是52版本的jdk(即1.8),而这个class文件中用的是55open jdk11),根据规定是不让执行的,就算这个文件可以执行。

该书的作者提供了下面的代码来作为示例:

1
2
3
4
5
6
7
package org.fenixsoft.clazz;
public class TestClass{
private int m;
public int inc(){
return m+1;
}
}

由于open jdk11对于这本书来说确实有点过于“新”了,所以接下来我用的是open jdk1.8这个版本。

常量池

紧接着的就是常量池,由于每个类中的常量池中的常量数量是不同的,所以会先有2个字节(u2)来代表常量池容量的计数值,注意!!这里的这个值是从1开始的(0代表不引用任何常量)。当然如果是用工具的话,清晰明了。

在我的机器上这个值是0x0013,即为十进制的19,那么索引就是1~19(注意这里可能书的内容比较老旧,我后面自己做实验得到的结果是1-18,而不是书中说的1-19)。而0则是被用来表示“不引用任何一个常量池项目”。

常量池里面主要存放两大类常量:

  • 字面量(Literal):int a = 1; 1被称为字面量。
    • String类型
    • 声明为final的常量等
  • 符号引用:int a = 1; a被称为符号引用。
    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符

java在编译的时候是没有“连接”这一步的,是在虚拟机加载class文件的时候进行动态链接的。所以class文件中没办法存储内存入口(因为只有在运行的时候才知道),所以它只能从常量池里获取到符号引用,然后才去找。

每一个常量其本质上是一张表,分别用不同的标志来进行标识。可以看到第一个常量的标识为0x0a,查阅可知这是一个类中方法的符号引用,当然你可能会觉得好麻烦还要查表,java提供了javap这个工具来对其进行分析,所以在命令行输入javap -verbose org.fenixsoft.clazz.TestClass,就可以看到对这个字节码文件进行详细的分析结果了,我在这里只摘选了其中一部分:

image-20200114103321167

如果通过工具查看就更加明了直观了:

image-20200613134648269

第一个常量是方法的符号引用,分别对应了第4个和第15个,追踪过去看第4个是一个类,就是java中的基类,而第15个是一个字段和方法的引用,然后继续追踪……其中你会发现一些奇怪的东西,比如<init>()V这种从来没在你代码中出现过的东西,这部分之后会提到,其余的相信稍微有点耐心都能看懂的吧。

当然如果使用工具,就真的太轻松惬意了….点点点就完事了。

这里稍微注意一点,Utf8_info有一个u2的length,而一个u2最大的长度也不过65535,所以说java程序中的变量名、方法名不可以超过65535个字符。

访问标志

就是你在写java的时候用到的一些修饰符,比如是否是public,是否声明为final等,这个是一个u2,所以理论上可以有16个标志位使用,现在只用了其中的9个。我们这只是一个普通的public class,所以通过查表可知它的access flags应该是0x0021,结果如图所示:

image-20200114104647964

类索引

java是具有继承和实现接口的功能的,所以需要提供一种数据来描述这个类的继承关系。类索引确定这个类的全名,父类索引确定这个类的父类的全名(java不允许多重继承,所以只能有一个父类,如果是Object类则父类索引为0),而由于可以实现多个接口,所以有一个接口索引集合

可以看到类索引是0x0003,父类索引是0x0004,而接口索引集合是0x0000(图中少了一个00),可以从常量池的截图中看到,0x0003确实是这个类的全名,而0x0004也确实是这个类的父类的全名,它没有实现任何一个接口,所以不会有接口索引集合。

字段表集合

有关字段(field)的表的一个集合。每一个字段可以包含以下信息(不一定全都有):

  • 作用域的修饰符,即publicprivateprotected
  • 是属于实例的还是属于类的:static
  • 能否被改变:final
  • 并发可见:volatile
  • 能否被序列化:transient
  • 数据类型:charint等等
  • 字段的名称(你自己给变量取的名字)

集合中一共有1个access_flags,1个name_index,1个descriptor_index,1个attributes_count和若干个attributes_infoattributes_info将在之后介绍,这部分先介绍前面4个)。

  • 大部分都可以用boolean类型来实现,比如有没有public这个修饰符?有没有private这个修饰符这样子的,access_flags是一个u2,可以用来表示16个修饰符,但是其实只使用了9个。

  • name_index是一个u2,它是对常量池的引用,代表了字段的简单名称。简单名称就是,把方法和字段的修饰符之类的全部抛弃掉之后留下的。我举个例子,比如一个方法public static void inc(int x){具体实现省略},那么它的简单名字就是inc;再比如一个字段是private static int i;,那么它的简单名称就是i,很简单吧?

  • descriptor_index是字段和方法的描述符,同样是对常量池的引用。描述符顾名思义就是用来描述字段和方法的,具体包括字段的数据类型、方法的参数列表和返回值。

    • 如果用来描述字段的数据类型,基本上只需要把基本数据类型的第一个字符变成大写即可,因为booleanbyte都是b,所以用Z来代表boolean,同时因为L被用来描述对象了,所以用J来描述long。而对于数组则可以直接用一个左中括号[来代表,比如一个int的二维数组可以用[[I来表示。
    • 如果用来描述方法,则需要描述方法的参数列表和返回值,括号内表示参数列表,之后跟一个返回的类型,比如我public int func(int a,int b){return a+b},这个函数的描述符是(II)I

image-20200114113158066

可以看到fields_countaccess_flagsname_indexdescriptor_index的值分别是0x00010x00020x00050x0006,说明只有1个字段表数据,同时因为access_flag的值是2,所以说明这个字段是private的,而name_index是5,而5在这个常量池是m,最后descriptor_index是6,6是I,说明了这是一个private int m;

方法表集合

和之前的字段表集合几乎一致,只不过因为方法和字段的修饰符稍微有点出入,所以也稍微有点出入:

image-20200114114141346

用红框括起来的分别是12个字节,每2个字节为一个。第一个是计数器容量,值为0x0002,说明有2个方法。很奇怪对么,我们只定义了一个inc(),怎么会有两个方法呢?那你是不是忘记了java默认会添加一个构造器方法了呢?这个方法就是前面看到的init()方法,接下来是access_flags,值为0x0001,查表就知道这代表这public,接下来是name_index,值是0x0007,常量表里查一查,是<init>,嗯构造方法没有问题。接下来是descriptor_index,值为0x0008,查一下常量池,值为()V,确实构造方法没有参数也没有返回值。attributes_count的值为0x0001,说明这个方法的属性表集合中有一个属性表,就是之后的0x0009,常量池中为Code,说明这个属性是方法的字节码描述,相当于这个值就是该方法的实现。

属性表(attribute_info)

前面特意没有讲这个,是因为要单独拿出来进行讲解。注意每个字段和每个方法,都可以有属性表。对于每个属性,都需要一个u2来规定它的名字在常量表中的位置,同时用一个u4来说明属性所占的位数,最后用若干个u1来代表属性即可。接下来介绍的各种属性,并不是所有的都有的,像一个字段就不可能会有code属性(它连方法体都没有哪来的code属性)。

code属性

一个方法中的代码,经过编译器的处理之后,就会变成字节码的指令存储在code属性内部,当然像接口这样的是不存在code属性的。

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 max_stack 1
u2 max_locals 1
u4 code_length 1
u1 code code_length
u2 exception_table_length 1
exception_info exception_table exception_table_length
u2 attributes_count 1
attribute_info attributes attributes_count
  • 第一个指向常量池,固定为Code
  • 第二个是长度
  • 第三个是操作数栈的深度最大值,在任何时候操作数栈都不会超过这个值,虚拟机就是根据这个来分配栈帧的操作深度的。
  • 第四个是局部变量表需要的存储空间,这个单位是slots,那些32位的是一个slot,而longdouble是两个slots。方法的参数、catch中定义的异常、局部变量都需要局部变量表来存放。
  • 之后两个是code_lengthcode,真正用来存放源程序编译后生成的字节码指令。每一个指令都是一个字节,因为总共有约200条属性,而一个字节可以表示256种可能,所以是够的。而一个code_lengthu4,所以理论上是20亿的长度,但是规范中写明了一个方法是不能超过65535条字节码指令的,就是说一个方法里面所能执行的代码是有限的,当然我们平时是遇不到的,只有写JSP的时候,可能会因为JSP编译器把一些内容输出都归并到一个方法里面,会出现这个问题。

由于code的重要性,所以要拿来分析一下:

image-20200114144732831

一开始是0x0009,常量池里9号也确实是Code,然后是一个0x0000001d,说明长度是29。接下来是0x0001,说明操作数栈的最大深度是1,而本地变量表同样长度也是1。接下来4个字节是0x00000005,即接下来的5个字节都是字节指令码。也就是我们这里着重需要看下0x2a0xb70x000x010xb1这五条指令。

  • 2A对应的指令是aload_0,将第0个slot中的为reference的本地变量(存疑:可能就是this)推送到操作数栈顶。
  • B7对应的指令是invokespecial,以栈顶的reference类型的数据所指向的对象作为方法接收者,而这个方法有一个u2的参数说明调用了哪个方法。
  • 0001,这里不是指令,而是上面的那个u2参数,查看一下常量表就知道了是Object的init方法。
  • B1对应return

当然上面的分析你用javap -verbose也是能看到的:

image-20200114145829985可以注意到一点,stack=1,这没问题,确实只用了一个栈。那localsargs_size为什么都是1呢?我们明明没有任何的局部变量,同时也没有传递任何参数呀?那你可能又忘记了java中的this了,这里也可以看到其实this就是在编译的时候变成了一个局部变量并且由参数传递进来的。

  • 继续上面的,接下来是一个异常处理表,这个如果你没写异常处理就不会存在,所以这里需要用到另外一断代码来进行演示。
1
2
3
4
5
6
7
8
9
10
11
12
13
public int inc() {
int x;
try {
x = 1;
return x;
} catch (Exception e) {
x = 2;
return x;
} finally {
x = 3;
//注意这里没有return!
}
}

首先列出三条可能的代码执行路径:

  1. try中没有出现Exception或它子类的异常,到finally中处理,即最后返回1。
  2. try中出现了Exception或它子类的异常,转到catch中处理。最后返回2。
  3. catch中出现了异常,转到finally中执行,不返回东西。

从字节码的角度来看看为什么会这样:

image-20200613143745633

  • 首先是将x赋值为1,然后将x的值复制一份到最后一个本地变量的变量槽中,可以理解为有一个额外的变量叫returnValue。

  • 如果没有异常,会走到finally中,把x赋值为3。然后读取出returnValue的值为1,并将其返回。

  • 如果出现了异常,自然是走到catch里面,把x赋值成2,同时会把returnValue置为2,并将其返回。

如果还是有点没理解,没事,之后会单独拿出来说。然后code里面居然还能够再嵌套attribute_info.

Exceptions属性

请不要和刚刚和上面的异常表进行混淆,在这里的属性和code是平行的。这个是你写代码是自己throws关键字后面的受查异常。

LineNumberTable属性

能够在你程序抛出异常的时候显示出错的行号。

LocalVariableTable属性

就是别人引用方法的时候,参数名字还在不在。默认希望是在的,不然就只能通过arg0这种变量名称来进行引用了。

SourceFile属性

用来记录是哪个源码文件生成了这个class文件。

ConstantValue属性

JVM对于int x = 1static int x = 1这两种的实现是有所区别的,对于前者就是在init()中,而对于后者则是在类构造器clinit或者使用ConstantValue属性。

InnerClasses属性

这个也很好理解,要是有内部类的话就会有这个属性。需要注意的是要是是匿名内部类的话,那么名字是0

Deprecated和Synthetic属性

Deprecated用来声明某个类、字段或者方法已经不被推荐使用了。

Synthetic用来声明这个是由编译器自动帮忙生成的。

StackMapTable属性

用来进行类型检查。

还有不少

这里先跳过了,到相应的功能的时候在进行介绍。

指令介绍

之前也说了一个字节码指令就是一个字节,所以它其实是一个数字,每个数字都对应了一条指令。而指令大多离不开操作数,所以还需要后面跟零个或者多个操作数,所以其实JVM的解释器非常好理解:

  1. PC数值加1
  2. 根据PC数值来获得操作码
  3. 如果有操作数就取出操作数
  4. 执行相对应的命令
  5. 如果没指令了就结束,还有指令就回到第一步

其实不少的指令都包括了它所要操作的数据的类型信息,比如iload就是从局部变量表里面加载int到操作数栈里面,而fload则是加载float数据。其实绝大部分的指令都只需要查看第一个字母就能知道它操作的类型了,a是reference类型。但是你想过这个的危害没有,那就是一共也才256个指令作为上限,如果要为每一种类型都设计一个指令,那妥妥放不下啊。所以其实byteshort这种类型所支持的指令是很少的。编译器会在编译或者运行的时候将byte(8bits)和short(16bits)进行带符号扩展成int,而booleanchar则会零位扩展成int。所以其实bytecharshortboolean都会转成int,再结合之前的slot机制,我们可以理解为整数可以只有int和long两种类型,除非为了防止自己出错,不然一律使用int。

加载和存储

加载和存储就是用来把数据从栈帧中的局部变量表和操作数栈之间进行传输。

注意!每一个栈帧都有它自己的局部变量表和操作数栈。

  • 将局部变量加载入操作数栈:xloadxload_<n>
  • 将操作数栈中的数字运送到局部变量表:xstorexstore_<n>
  • 加载常量到操作数栈里面:bipushsipushldc

运算

基本上都是intfloat这两种,包含加减乘除取余,还有一些位操作和自增自减、比较指令。

虚拟机规范只规定了除法和取余中出现了除数为零的情况需要抛出异常,其余任何情况都不应该抛出运行时异常

同时JVM也使用IEEE 754中的小数舍去的方法来把浮点数转化成整数。

类型转化

从小范围到大范围由于是安全转化,所以这里不多解释。

在将intlong进行转换成小数据的时候,转换的过程仅仅把简单丢弃。比如java中的int是32位,byte是8位,所以就是简单把int丢掉前面的24位即可。但是这样子显然是会出事的,比如int a = 129;,当我把它转成byte的时候,就会变成-127,这是因为129是二进制的10000001,而到了byte因为第一个比特是1,所以最后的结果是-127。

而如果是把浮点数转换成整形的话,如果浮点数是NaN,那就转换成0。如果不是则使用向零舍入模式取整,取得一个整数值,如果这个整数值能够被表示,那就是答案;如果不能,那么就用一个最接近的数字来代替。(比如double是30E,那显然int是不够的,那样就只能选一个最接近的,也就是2147483647来表示了。

注意虽然会发生溢出、精度丢失等问题,但是规范中规定了是永远也不可能抛出虚拟机运行时异常的。

创建对象与访问

创建类的实例和数组用的指令是不同的:

  • 类实例:new
  • 创建数组的指令:newarrayanewarray
  • 访问类字段(即static修饰的)和实例字段:getfieldputfieldgetstaticputstatic
  • 数组元素加载到操作数栈里:baloadcaload
  • 把操作数栈的值存储到数组中:bastorecastore
  • 获得数组长度:arraylength
  • 检查某个对象是否是某个类的实例:instanceofcheckcast

直接操作操作数栈

那当然是pop啦,但是没想到还能直接让两个元素出栈pop2

控制转移指令

就是跳转指令,包含有条件的跳转和无条件的跳转。本质上其实就是修改了PC的值,这样就做到了所谓的“跳转”

方法调用和返回

  • invokevirtual用于调用对象的实例方法。
  • invokeinterface指令用于调用接口方法,它能够搜索实现了这个方法的对象并找出合适的方法进行调用。
  • invokespecial指令用于调用一些需要特殊处理的实例方法,比如实例初始化方法、私有方法和父类方法。
  • invokestatic指令调用类方法。
  • invokedynamic指令,跳过

同步指令

包含两种同步,分别是方法级的同步和方法内部一段指令序列的同步。

由之前可知,可以通过有没有修饰符来知道一个方法是否被声明为同步方法。当调用的时候,如果是同步方法,那么就会先要求持有管程(Monitor)然后再执行方法,执行完成之后再释放管程。

而对一段指令集序列的同步则是由synchronized语句块来实现的。主要是两条指令,分别是monitorentermonitorexit这两条指令。由于需要确保最后能够执行monitorexit指令被执行,所以编译器会产生一个能够处理所有异常的处理器,这样就能够确保monitorexit指令被执行。

总结

字节码的格式大体上来说就是这样的:

1
2
cafebabe(定死的,如果不是它开头就不算字节码文件) - jdk的版本号  - 常量池(非常庞大...可以用javap -verbose来查看,自己动手算会死的) - 
访问标志(仅仅占用2字节,说明这个类属性) - 类索引(类的父类、实现的接口) - 该类的所有字段(属性) - 该类的所有方法