深入理解Java虚拟机(第3版)读书笔记——类文件结构

java

第6章 类文件结构

Java虚拟机不与包括Java语言在内的任何程序语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集、符号表以及若干其他辅助信息。

Class类文件的结构

任何一个Class文件都对应着唯一的一个类或接口的定义信息,但是反过来说,类或接口并不一定都得定义在文件里(如类或接口也可以动态生成,直接送入类加载器中)

Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中,中间没有添加任何分隔符。当遇到需要占用8个字节以上空间的数据项时,则会按照高位在前(大端)的方式分割成若干个8个字节进行存储。

Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:

  • 无符号数:基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
  • 表:多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯性地以“_info”结尾。用于描述有层次关系的复合结构的数据,整个Class文件本质上也可以视作是一张表。

Class结构:

魔数

Class文件的头4个字节被称为魔数(Magic Number),作用是确定这个文件是否为一个能被虚拟机接受的Class文件。值为 0xCAFEBABE。

版本号

minor version:第5和6字节存储次版本号。

次版本号,曾经在Java 2出现前被短暂使用过,从JDK 1.2以后,直到JDK 12之前次版本号均未使用,全部固定为零。

而到了JDK 12时期,由于JDK提供的功能集已经非常庞大,有一些复杂的新特性需要以“公测”的形式放出,所以设计者重新启用了副版本号,将它用于标识“技术预览版”功能特性的支持。如果Class文件中使用了该版本JDK尚未列入正式特性清单中的预览功能,则必须把次版本号标识为65535,以便Java虚拟机在加载类文件时能够区分出来。

major_version:第7和8字节存储主版本号。

Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1,高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件。

例如:JDK 1.1能支持版本号为45.0~45.65535的Class文件,无法执行版本号为46.0以上的Class文件,而JDK 1.2则能支持45.0~46.65535的Class文件。

JDK 11

openjdk 11.0.6 2020-01-14

cafe babe 0000 0037 ...

JDK 8

java version "1.8.0_241"

cafe babe 0000 0034 ...

常量池

常量池可以比喻为Class文件里的资源仓库,它是Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一。

由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值,从1开始计数。

0x23代表35,即常量池中有34项常量,索引1~34。

cafe babe 0000 0037 0023

设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于,如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,可以把索引值设置为0来表示。

Class文件结构中只有常量池的容量计数是从1开始,对于其他集合类型,容量计数都与一般习惯相同,是从0开始。

常量池主要存放:

  • 字面量(Literal):接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。
  • 符号引用(Symbolic References):属于编译原理方面的概念

    • 被模块导出或者开放的包(Package)
    • 类和接口的全限定名(Fully Qualified Name)
    • 字段的名称和描述符(Descriptor)
    • 方法的名称和描述符
    • 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
    • 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)

常量池中每一项常量都是一个表,表结构起始的第一位是个u1类型的标志位,代表着当前常量属于哪种常量类型,之后的结构与常量类型相关。

如:

cafe babe 0000 0037 0023 0a00 0700 1409

0x0a代表Methodref,接下来u2(0x0007)代表声明方法的类描述符(Class)的索引项,后面的u2(0x0014)代表名称及类型描述符(NameAndType)的索引项。

使用javap -verbose Test.class查看字节码:

Classfile .../Test.class

Last modified 2020年3月6日; size 494 bytes

MD5 checksum 895a2d8401ca34e5c6d1da1a6cb7f5f2

Compiled from "Test.java"

public class Test

minor version: 0

major version: 55

flags: (0x0021) ACC_PUBLIC, ACC_SUPER

this_class: #3 // Test

super_class: #7 // java/lang/Object

interfaces: 0, fields: 2, methods: 2, attributes: 1

Constant pool:

#1 = Methodref #7.#20 // java/lang/Object."<init>":()V

#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;

#3 = Class #23 // Test

#4 = String #24 // this is a test

#5 = Methodref #25.#26 // java/io/PrintStream.println:(Ljava/lang/String;)V

#6 = Fieldref #3.#27 // Test.count:I

#7 = Class #28 // java/lang/Object

#8 = Utf8 TEST

#9 = Utf8 Ljava/lang/String;

#10 = Utf8 ConstantValue

#11 = Utf8 count

#12 = Utf8 I

#13 = Utf8 <init>

#14 = Utf8 ()V

#15 = Utf8 Code

#16 = Utf8 LineNumberTable

#17 = Utf8 print

#18 = Utf8 SourceFile

#19 = Utf8 Test.java

#20 = NameAndType #13:#14 // "<init>":()V

#21 = Class #29 // java/lang/System

#22 = NameAndType #30:#31 // out:Ljava/io/PrintStream;

#23 = Utf8 Test

#24 = Utf8 this is a test

#25 = Class #32 // java/io/PrintStream

#26 = NameAndType #33:#34 // println:(Ljava/lang/String;)V

#27 = NameAndType #11:#12 // count:I

#28 = Utf8 java/lang/Object

#29 = Utf8 java/lang/System

#30 = Utf8 out

#31 = Utf8 Ljava/io/PrintStream;

#32 = Utf8 java/io/PrintStream

#33 = Utf8 println

#34 = Utf8 (Ljava/lang/String;)V

...

常量池中存在一些代码中并没有出现的常量,如LocalVariableTable等,他们是由编译器自动生成的。它们将会被用来描述一些不方便使用“固定字节”进行表达的内容,譬如描述方法的返回值是什么,有几个参数,每个参数的类型是什么等。

访问标志

接下来的u2代表访问标志(access_flags),用于识别一些类或者接口层次的访问信息。

各个标志之间取或后的值为flag值。

比如:0x0021代表ACC_PUBLIC,ACC_SUPER。

类索引 父类索引 接口索引集合

Class文件中由这三项数据来确定该类型的继承关系。

  • 类索引:u2数据类型,确定全限定名(如:java/lang/System)
  • 父类索引:u2数据类型,确定父类全限定名
  • 接口索引集合:一组u2类型数据集合,描述实现哪些接口,按implements/extends从左到右顺序排列。

    • 第一项u2代表索引表的容量n
    • 后n项u2代表接口索引

字段表集合

字段表用于描述接口或类中声明的变量。包括类级变量及实例变量,不包括局部变量,不会列出继承来的字段,可能出现代码中不存在的字段。

字段可以包括的修饰符:

  • 作用域:public/private/protected
  • 实例变量还是类变量:static
  • 可变性:final
  • 并发可见性:volatile
  • 可否被序列化:transient
  • 数据类型
  • 字段名称

下两项都是对常量池项的引用:

  • name_index:字段的简单名称
  • descriptor_index:方法的描述符

对于数组类型,每一维度将使用一个前置的'['字符来描述,如一个定义为java.lang.String[][]类型的二维数组将被记录成[[Ljava/lang/String;,一个整型数组int[]将被记录成[I

用描述符来描述方法时,按照先参数列表、后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。

如:int indexOf(char[]source,int sourceOffset, int sourceCount,char[]target,int targetOffset,int targetCount,int fromIndex)的描述符为“([CII[CIII)I”

方法表集合

Class文件存储格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,仅在访问标志和属性表集合的可选项中有所区别。

如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样地,有可能会出现由编译器自动添加的方法,最常见的便是类构造器<clinit>()方法和实例构造器<init>()方法。

属性表集合

Class文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有的信息。

属性:

Code

Java程序方法体里面的代码经过Javac编译器处理之后,最终变为字节码指令存储在Code属性内。Code属性出现在方法表的属性集合之中。

  • attribute_name_index:固定为Code
  • atrribute_length:属性值长度 = 属性表长度 - 6
  • max_stack:操作数栈深度最大值
  • max_locals:局部变量表所需空间,单位为变量槽(Slot)。槽根据变量的作用域可以重用。因此max_locals = 最大局部变量占用槽数
  • code_length:字节码指令长度。理论上最大值可以达到2的32次幂,但是《Java虚拟机规范》中明确限制了一个方法不允许超过65535条字节码指令
  • code:字节码指令
  • exception表集合:如果当字节码从第start_pc行到第end_pc行之间(不含end_pc行)出现了类型为catch_type或者其子类的异常,则转到第handler_pc行继续处理。当catch_ype的值为0时,代表任意异常情况都需要转到handler_pc处进行处理。

Exceptions:列举出方法中可能抛出的受查异常(Checked Excepitons),也就是方法描述时在throws关键字后面列举的异常。

LineNumberTable:描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。

LocalVariableTable:描述栈帧中局部变量表的变量与Java源码中定义的变量之间的关系。

LocalVariableTypeTable:使用字段的特征签

名来完成泛型的描述。

ConstantValue:通知虚拟机自动为静态变量赋值。只有被static关键字修饰的变量才可以使用这项属性。

  • 实例变量赋值:在实例构造器<init>()方法中进行
  • 静态变量赋值

    • 在类构造器<clinit>()方法中
    • 使用ConstantValue属性

InnerClass:记录内部类与宿主类之间的关联。

StackMapTable:在虚拟机类加载的字节码验证阶段被新类型检查验证器(Type Checker)使用,目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器。

Signature:记录泛型签名信息。可以出现于类、字段表和方法表结构的属性表中。

RuntimeVisibleAnnotations:记录了类、字段或方法的声明上记录运行时可见注解。

字节码指令

Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operand)构成。

由于Java虚拟机采用面向操作数栈而不是面向寄存器的架构,所以大多数指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中。

如果不考虑异常处理的话,那Java虚拟机的解释器可以使用下面这段伪代码作为最基本的执行模型来理解:

do {

自动计算PC寄存器的值加1;

根据PC寄存器指示的位置,从字节码流中取出操作码;

if (字节码存在操作数) 从字节码流中取出操作数;

执行操作码所定义的操作;

} while (字节码流⻓度 > 0);

在Java虚拟机的指令集中,大多数指令都包含其操作所对应的数据类型信息。

如:iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。

因为Java虚拟机的操作码长度只有一字节,Java虚拟机的指令集对于特定的操作只提供了有限的类型相关指令去支持它。大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的对int类型作为运算类型(Computational Type)来进行的。

加载和存储

用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。

  • 加载局部变量到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>
  • 从操作栈存储到变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>
  • 加载常量到操作栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>
  • 扩充局部变量表的访问索引的指令:wide

注:_<n>中n指1,2,3。iload_0等价于操作数为0的iload

运算

对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。

  • 加法指令:iadd、ladd、fadd、dadd
  • 减法指令:isub、lsub、fsub、dsub
  • 乘法指令:imul、lmul、fmul、dmul
  • 除法指令:idiv、ldiv、fdiv、ddiv
  • 求余指令:irem、lrem、frem、drem
  • 取反指令:ineg、lneg、fneg、dneg
  • 位移指令:ishl、ishr、iushr、lshl、lshr、lushr
  • 按位或指令:ior、lor
  • 按位与指令:iand、land
  • 按位异或指令:ixor、lxor
  • 局部变量自增指令:iinc
  • 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp

类型转换

Java虚拟机直接支持以下数值类型的宽化类型转换:

  • int类型到long、float或者double类型
  • long类型到float、double类型
  • float类型到double类型

窄化类型转换必须使用显式转换:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。

对象创建与访问

  • 创建类实例的指令:new
  • 创建数组的指令:newarray、anewarray、multianewarray
  • 访问类字段和实例字段的指令:getfield、putfield、getstatic、putstatic
  • 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
  • 将一个操作数栈的值储存到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore
  • 取数组长度的指令:arraylength
  • 检查类实例类型的指令:instanceof、checkcast

操作数栈管理

  • 将操作数栈的栈顶一个或两个元素出栈:pop、pop2
  • 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
  • 将栈最顶端的两个数值互换:swap

控制转移

  • 条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、

    if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、

    if_acmpeq和if_acmpne

  • 复合条件分支:tableswitch、lookupswitch
  • 无条件分支:goto、goto_w、jsr、jsr_w、ret

方法调用与返回

  • invokevirtual指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。
  • invokeinterface指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
  • invokespecial指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
  • invokestatic指令:用于调用类静态方法(static方法)。
  • invokedynamic指令:用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法。

前面四条调用指令的分派逻辑都固化在Java虚拟机内部,用户无法改变,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

方法返回指令是根据返回值的类型区分的,包括ireturn(boolean、byte、char、short和int类型使用)、lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法、类和接口的类初始化方法使用。

异常处理

使用athrow显式抛出异常,用异常表处理异常。

同步

Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor,锁)来实现的。

方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否被声明为同步方法。

同步一段指令集序列通常是由Java语言中的synchronized语句块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义。

void onlyMe(Foo f) {

synchronized(f) {

doSomething();

}

}

生成如下字节码:

Method void onlyMe(Foo)

0 aload_1 // 将对象f入栈

1 dup // 复制栈顶元素(即f的引用)

2 astore_2 // 将栈顶元素存储到局部变量表变量槽 2中

3 monitorenter // 以栈定元素(即f)作为锁,开始同步

4 aload_0 // 将局部变量槽 0(即this指针)的元素入栈

5 invokevirtual #5 // 调用doSomething()方法

8 aload_2 // 将局部变量Slow 2的元素(即f)入栈

9 monitorexit // 退出同步

10 goto 18 // 方法正常结束,跳转到18返回

13 astore_3 // 从这步开始是异常路径,⻅下面异常表的Taget 13

14 aload_2 // 将局部变量Slow 2的元素(即f)入栈

15 monitorexit // 退出同步

16 aload_3 // 将局部变量Slow 3的元素(即异常对象)入栈

17 athrow // 把异常对象重新抛出给onlyMe()方法的调用者

18 return // 方法正常返回

Exception table:

From To Target Type

4 10 13 any

13 16 13 any

编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须有其对应的monitorexit指令,而无论这个方法是正常结束还是异常结束。编译器会自动产生一个异常处理程序,这个异常处理程序声明可处理所有的异常,它的目的就是用来执行monitorexit指令。





参考资料:《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》 周志明 著

以上是 深入理解Java虚拟机(第3版)读书笔记——类文件结构 的全部内容, 来源链接: utcz.com/z/393607.html

回到顶部