第6章 类文件结构《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》
第6章 类文件结构
代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。
6.1 概述
老师说过,计算机只认识0和1,所以我们写的程序需要被编译器翻译成由0和1构成的二进制格式才能被计算机执行。
但十多年过去了,把程序编译成二进制本地机器码(Native Code)不是唯一选择,很多语言选择与操作系统和机器指令集无关的、平台中立的格式作为存储格式。
6.2 无关性的基石
指令集只有x86,系统只有Windows,也许不会有Java语言的出现。
所有平台支持**字节码(Byte Code)**构成平台无关性的基石。
6.3 Class类文件的结构
Class文件结构稳定,很多年来没咋变化,后面的版本兼容前面。
Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的排列在文件之中,中间没有添加任何分隔符,整个内容全部是程序运行的必要数据,没有空隙存在。
根据规范,Class文件格式采用类似于C语言结构体的伪结构来存储数据。
包含两种数据类型:“无符号数"和"表”.
- 无符号数属于基本的数据类型,以u1,u2,u4,u8来分别代表1个字节、2个字节、4个字节、8个字节的无符号数(没有负数),可以描述数字、索引引用、数量值或UTF-8编码的字符串值。
- 表由多个无符号数或其他表作为数据项的复合数据类型,便于区分,表的命名以"_info"结尾,表用于描述有层次关系的复合结构的数据,整个Class文件本质上视为一张表,看如下排列顺序。
无论是无符号数或表,描述同一类型但数量不定的多个数据时,会使用前置的容量计数器加若干连续的数据项的形式,称为"集合"。
数据行顺序和数量都是严格定义,不允许改变。
6.3.1 魔数与Class文件的版本
每个Class文件头4个字节被称为魔数(Magic Number),作用是确定此文件是否是一个能被虚拟机接受的Class文件。GIF,JPEG等文件头中都存有魔数。
魔数值为:0xCAFEBABE(咖啡宝贝,设计类的大佬有趣,暗指Java是一种咖啡)。
5,6字节是次版本号(Minor Version),7,8是主版本号(Major Version)。
简单Java代码编译成Class来分析
public class TestClass {private int m;public int inc() {return m + 1;}}
Ctrl+1,0等转换为十进制或十六进制。
主版本为61,减去44,JDK为17,可支持6-17的代码。
基本上JDK12之后,主要主版本,次版本都是0.
6.3.2 常量池
与其他项目关联最多,空间也最大。
常量池入口放一个常量池计数值(constant_pool_count),从1开始,不是0.
这里是22,则有21项常量。第0项特殊考虑,表示不引用任何一个常量池项目,可以把索引设为0.
常量池中存放两大类常量:字面量(Literal),不用变量保存,是固定值,int a = 10,10是字面量和符号引用(Symbolic References),例如方法名a调b对象,存b的方法printMessage,加载类后内存中解析为直接引用(内存地址或句柄),这就是动态链接。
字面量接近为常量概念,符号引用是编译原理的概念。
包含的常量:
- 模块导出的或开发的包(Package)
- 类和接口的全名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
- 方法的名称和描述符
- 方法句柄和方法类型(Method Handle、MethodType、Invoke Dynamic)
- 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)
字段描述符
- 定义:字段描述符是一种特定的字符串格式,用于在字节码层面描述字段的数据类型。它遵循特定的规则,不同的数据类型有不同的表示方式。这种描述方式使得 Java 虚拟机(JVM)能够准确识别字段的数据类型,即使在字节码中无法直接看到像 Java 代码中那样直观的类型声明。
- 规则及示例:
- 基本数据类型:
boolean
用Z
表示。byte
用B
表示。char
用C
表示。short
用S
表示。int
用I
表示。long
用J
表示(由于long
类型占 64 位,在描述符中需要特殊标识)。float
用F
表示。double
用D
表示(同理,double
类型占 64 位,有特定描述符)。
- 引用数据类型:类和接口的描述符是其全限定名,不过斜杠(
/
)代替点(.
)作为包名分隔符,并使用L
作为前缀和分号(;
)作为后缀。例如,java.lang.String
的描述符是Ljava/lang/String;
。 - 数组类型:对于数组,描述符以左方括号(
[
)开头,后面跟着元素类型的描述符。例如,int[]
的描述符是[I
,String[]
的描述符是[Ljava/lang/String;
。
- 基本数据类型:
常量池中每一项常量都是一个表,共17种,仔细看标志不连续。
这17种没有共性,要逐项讲解。
整体计算常量就不手动了,这里通过JDK的bin目录中,javap工具。
javap -verbose .\TestClass.class
LineNumberTable,LocalVariableTable,this,SourceFile等都是编译器生成的。
后面的字段表,方发表,属性表会引用的。
6.3.3 访问标志
常量池结束后,接下来的2个字节代表访问标志(access_flags),此标志用于类或接口的访问信息。
1111 0110 0011 0001,按位存储,16个标志位,定义了9个,其他都为0.
上面的TestClass案例,访问标志为 0000 0000 0010 0001,16进制0x0021。
结果也确实如此。
6.3.4 类索引、父类索引和接口索引集合
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据集合。这三项能决定类型的继承关系。
类索引和父类索引指向常量池#1,#3,则是两个类的类名,接口为0表示没有实现的接口。
6.3.5 字段表集合
字段表(field_info)用于描述接口或者类中声明的变量。
字段(Field):包含类变量(静态变量),实例变量(普通变量),并不包括方法声明的局部变量(这些变量在栈中加载,引用的实例也在堆中)。
可以修饰字段的有public,private,pretected,static,final,volatile(并发可见),transient,数据类型。
修饰符都是布尔值适合用标志位来表示。
字段的名字通过引用常量池中的常量来描述。
access_flags
字段修饰符与类修饰符非常类似,u2的16位标志位。
0101 0000 1101 1111,9位有效的1,从右往左数,1,2,3只能三选一,5,7位不能同时为1。接口1,4,5位必须是1,也就是public static final InterA{}。
name_index
长度u2,表示堆常量池索引的引用,代表字段的简单名称。
全限定名:“a/b/c/TestClass”,把类中".“换成”/“,使用时最后加”;"。
简单名称:没有修饰的字段和方法的名称,int()方法和m字段,简单名分别为inc和m。
descriptor_index
字段描述符:来描述字段的数据类型,方法参数列表,返回这和是否无返回值以及返回值类型,也就是如下。
具体前面也讲过了。
二维字符串数组:[[Ljava/lang/String;
整型数组:[I。
方法void inc():()V,V这里是无返回值void的意思。
long add(int a,int b,int c):(III)J
1,2,5,6分别代表字段数是1个,访问标志为私有的ACC_PRIVATE,名称的常量池索引5为m,描述符的常量池索引6为I,即int型,那么整体为:private int m; 。
attribute_info后面讲
如果final static int m = 123;,可能会有ConstantValue的属性,值指向常量123。
总结:Java中字段无法重载,但在字节码文件中,理论上字段描述符不同,字段重名是合法的,实际中也没人这么干。
6.3.6 方法表集合
方法表与字段表结构类似,仅在访问标志和属性表集合有所区别。
由于volatile关键字和transient关键字不能修饰方法,所以方法表中的访问标志中没有ACC_VOLATILE标志和ACC_TRANSIENT标志。
与之相对,synchronized,native,strictfp和abstract关键字可以修饰方法,方法表中也相应增加ACC_SYNCHRONIZED,ACC_NATIVE,ACC_STRICTFP和ACC_ABSTRACT标志。
方法的定义可以通过访问标志、名称索引、描述符索引来表达清楚。
但方法里面的代码哪里去了?Java代码经过Javac编译器编译成字节码指令之后,存放在方法属性表集合中一个名为"Code"的属性里面,属性表作为Class文件格式中最具拓展性的一种数据项目。
具体自己查吧,我的眼睛已经花了。
6.3.7 属性表集合
Class文件对其他数据项要求严格,对属性表集合的限制稍微宽松一些。
Java SE 12版本中,预定义属性已经增加到29项。
对于每一个属性,名称都要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示。属性值结构完全自定义,只需要通过一个u4的长度属性去说明属性值所占用的位数即可。
1.Code属性
Java程序方法体里面的代码经过Javac编译器处理之后,最终变为字节码指令存储在Code属性内,Code属性出现在方法表的属性集合中,并非所有方法表必须存在这个助兴,接口和抽象类中的方法就不存在Code属性。
max_stack代表了操作数栈(Operand Stack)深度的最大值,在方法执行的任意时刻,操作数栈都不会超过这个深度,虚拟机运行的时候需要根据这个值类分配栈帧(Stack Frame)中的操作栈深度。
max_locals代表了局部变量表所需的存储空间,这里的单位是变量槽(Slot),变量是虚拟机为局部变量分配内存使用的最小单位。byte,char,float,int,short,boolean,returnAddress等长度不超过32位的数据类型,每个局部变量占用一个变量槽,double和long的64位数据类型需要2个变量槽。
这里需要查查什么是栈帧,槽等概念。
接下来的就更细了,没耐心,脑子乱,等待后续更新吧。。。
2.Exceptions属性
等等吧
6.4 字节码指令简介
操作码(Opcode):Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字。
操作数(Operand):跟随在操作数后零到多个代表此操作所需的参数。
Java虚拟机采用面向操作数栈而不是面向寄存器的架构。
大多数指令不包含操作数,只有一个操作码,指令参数都存放在操作数栈中(参考计算器的设计原理)。
劣势:Class文件格式放弃编译后代码的操作数长度对齐,处理超过1字节的数据时,在运行时从字节中重建出具体数据的结构。某种程度上损失了一些性能。
优势:指令集操作数码不超过256条,放弃了操作数长度对齐,省掉大量的填充和间隔符号,尽可能获得短小精干的编译代码。
不考虑异常的话,解释器可以使用下面这个模型来进行有效正确的工作:
6.4.1 字节码与数据类型
多数指令包含操作所对应的数据类型信息。
iload用于从局部变量表中加载int型的数据到操作数栈。
fload加载的则是float类型的数据。
int,long,short,byte,char,float,double都是首字母,引用类型reference是a。
arraylength指令,后面的操作数只能是一个数组类型的对象。
并非每种数据类型和每一种操作都有对应的指令,有些指令可将不支持的类型转为支持的类型。
例如:load指令有操作int类型的Iload,但没有操作byte的同类指令。
Java虚拟机指令集支持的数据类型
编译器或运行期将byte或short带符号拓展为相应int类型数据。
将boolean和char类型数据零位扩展为相应的int类型数据。
对应的数组也会转为int类型字节码指令来处理,大多数类型都是用int类型作为运算类型。
将字节码指令分为9类。
6.4.2 加载和存储指令
加载和存储指令:用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。
- 将一个局部变量加载到操作数栈:iload,iload_,…,aload,aload_。
- 将一个数值从操作数栈存储到局部变量表:istore,istore_,…,astore,astore_。
- 将常量加载到操作数栈:bipush,sipush,…,
6.4.3 运算指令
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。
加减乘除等单词:
Addition,Subtraction,Multiplication,Division,Remainder,Negation,Shifting Left,Shifting Right,Unsign
- 加法指令:iadd,ladd,fadd,dadd
- 减法指令:isub,lsub,fsub,dsub
- 乘法指令:imul,lmul,fmul,dmul
- 除法指令:idiv,ldiv,fidv,ddiv
- 求余指令:irem…
- 取反:ineg…
- 位移:ishl,ishr,iushr,lshl,lshr,lushr
6.4.4 类型转换指令
将两种不同的数值类型相互转换。
小范围往大范围转换。
- int->long,float->double
- long->float,double
- float->double
窄化类型用显式转换指令来完成。
i2b,i2c,i2s,l2i,f2i,f2l,d2i,d2l,d2f,过程可能丢失精度。
6.4.5 对象创建与访问指令
类实例和数组都是对象,但创建和操作使用不同字节码指令。
- 创建类实例指令:new
- 创建数组的指令:newarray,anewarray,multianewarray
- 访问类字段,getfield,putfield,getstatic,putstatic
- 数组元素加载到操作数栈的指令:baload,caload,saload…aaload,(ByteArrayLoad)
- 取数组长度的指令:arraylength
- 检查实例类型的指令:instanceof,checkcast
6.4.6 操作数栈管理指令
和操作普通栈操作类似,JVM提供了直接操作操作数站的指令:
- 栈顶1个或2个元素出栈:pop、pop2.
- 复制栈顶一个或两个元素数值并将复制值或双份的复制值重新压入栈顶:dup,dup2,dup_x1,dup2_x1,dup_x2,dup2_x2.
- 将栈最顶端的两个数值互换:swap
6.4.7 控制转移指令
控制指令就是修改PC寄存器的值,使的指向其他指令。
- 条件分支:ifeg,iflt,ifle.ifne,ifgt,ifge,ifnull,ifnonnull等等。
- 复合条件分支:tableswitch,lookupswitch
- 无条件分支:goto,got_w,jsr,jsr_w,ret。
JVM提供的int类型的分支指令最强大,丰富。
6.4.8 方法调用和返回指令
用于方法的调用:
- invokevirtual指令:用于调用对象的实例方法。
- invokeinterface指令:用于调用接口方法,运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
- invokespecial指令:调用特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
- invokestatic指令:用于调用类静态方法(static方法)。
- invokedynamic指令:用于运行时动态解析出调用点限定符所引用的方法。前四条无法改,这条用户可以设定引导方法。
调用指令与数据类型无关,返回值指令时根据返回值的类型区分的,包括:ireturn(boolean,byte,char,short,int使用),lreturn,freturn,dreturn,areturn,还有return供void的方法、实例初始化方法、类和接口的类初始化方法使用。
6.4.9 异常处理指令
athrow指令来实现,整体采用异常表来完成。
6.4.10 同步指令
JVM可以支持方法级的同步和方法内部一段指令序列的同步。
使用管程来实现(Monitor,更常见的是称为“锁”)来实现。
方法级同步是隐式的,无须通过指令来控制,JVM可以通过常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否被声明为同步方法。
同步一段指令集序列通过Java中的synchronized语句来表示,指令集中有monitorenter和monitorexit两条指令来支持。
monitorenter指令和monitorexit指令都是成对出现的。
6.5 公有设计,私有实现
遵循《Java虚拟机规范》是共有设计。
但更希望在约束下对具体实现做出修改和优化的私有实现更鼓励。
虚拟机实现的方式主要有以下两种:
- 将输入的Java虚拟机代码在加载时或执行时翻译成另一种虚拟机的指令集。
- 将输入的Java虚拟机代码在加载时或执行时翻译成宿主机处理程序的本地指令集(即时编译器代码生成技术)。
6.6 Class文件结构的发展
《Java虚拟机规范》已经超过二十年,Java技术体系翻天覆地,但Class文件结构一直处于相对稳定的状态,主体结构,字节码指令和数量几乎没有出现过变动,所有的改建,都集中在访问标志、属性表这些可拓展的数据结构。
访问标志新加入ACC_SYNTHETIC,ACC_ANNOTATION,ACC_ENUM,ACC_BRIDGE,ACC_VARARGS。
属性表增加了20项属性,枚举、变长参数、泛型、动态注解等.