200字范文,内容丰富有趣,生活中的好帮手!
200字范文 > 【笔记】深入理解 Java 虚拟机:类文件结构

【笔记】深入理解 Java 虚拟机:类文件结构

时间:2019-04-23 22:25:10

相关推荐

【笔记】深入理解 Java 虚拟机:类文件结构

概述

代码编译的结果从本地机器码编程字节码,是存储格式发展的一小步,却是编程语言发展的一大步。Java 在诞生之初,曾经有一个著名的口号“一次编译,到处运行”,这句话充分表达了软件开发人员对冲破平台界限的渴求。

各种不同平台的虚拟机都统一使用的存储格式 —— 字节码,是构成平台无关性的基石。虚拟机的另外一种特性是语言无关性,目前已经出现了一大批在 Java 虚拟机之上运行的语言,比如 Scala、Clojure、Groovy 等。实现语言无关特性的基础仍然是虚拟机和字节码存储格式,Java 虚拟机不和包括 Java 在内的任何语言绑定,它只与 class 文件这种特定的二进制文件格式关联。

Class 文件格式

Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件中,中间没有添加任何分隔符。

Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据,这种伪结构只有两种数据类型:无符号数和表。无符号数属于基本的数据类型,以 u1、u2、u4、u8 分别代表 1 个字节、2 个字节、4 个字节、8 个字节的无符号数,可以用来描述数字、索引引用、数量值或者 UTF8 编码构成的字符串。

表是由多个无符号数或其他表作为数据项构成的复合数据类型,所有表都习惯性地以 _info 结尾。表用于描述有层次关系的复合结构的数据,整个 Class 文件本质上就是一张表,它由以下数据项构成:

接下来我们一起看看这张表里各个数据项的具体含义。

魔数与版本号

Class 文件的头 4 个字节称为魔数,其唯一作用是确定该文件是否是一个能被虚拟机接受的 Class 文件,其值固定为 0xCAFEBABE。

紧接着魔数的 4 个字节是 Class 文件的版本号,第 5、6 字节是次版本号 minor version,第 7、8 字节是主版本号 major version。jdk7 的主版本号是 51。

常量池

紧接着主次版本号之后的是常量池,常量池可以认为是 Class 文件中的资源仓库,它是 Class 文件中与其他项目关联最多的数据类型。

常量池的入库放置一个 u2 类型的数据,表示常量池容量计数值。与 Java 中语言习惯不同的是,这个容量计数从 1 而非从 0 开始。如果常量池容量为十六进制的 0x0016,即十进制的 22,那么表示常量池里有 21 项数据,索引值范围是 1 到 21。设计者将第 0 项常量空出来,是由特殊考虑的,方便在特定情况下表达“不引用任何一个常量池项目”的含义。Class 文件结构中,只有常量池的容量计数从 1 开始,其他都是从 0 开始。

常量池中主要存放两类数据:字面量和符号引用。字面量比较接近于 Java 语言层面的常量概念,如文本字符串、声明为 final 类型的常量值等。而符号引用则属于编译原理方面的概念,包括以下三类常量:

类和接口的全限定名;字段的名称和描述符;方法的名称和描述符。

常量池中的每一项常量都是一个表,JDK1.7 一共支持 14 种常量类型:

访问标志

在常量池结束之后,紧接着的 2 个字节代表访问标志 access flags,用于识别一些类或接口级别的访问信息,比如:这个 Class 是类还是接口?是否 public?如果是类的话,是否被声明为 final?具体标志见下表:

类索引、父类索引和接口索引的集合

Class 文件中由这三项数据来确定这个类的继承关系。由于 Java 不允许多重继承,因此父类索引也只有一个,类索引和父类索引使用两个 u2 类型的索引值表示,各自执行一个类型为 CONSTANT_Class_info 的类描述符常量。对于接口索引集合,入口的第一项 u2 类型数据表示索引表的容量,也就是接口数量,其他 u2 类型数据同样指向类描述符常量。

字段表集合

字段表 field_info 用于描述接口或类中声明的变量,包括类级变量和实例级变量,但不包括方法内部声明的局部变量。在 Java 中描述一个字段的信息有哪些呢?

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

字段表结构如下图所示,第一项数据是 access_flags,与类中的访问标志类似。紧随其后的是两个索引值:name_index、descriptor_index,分别表示字段的简单名称以及字段和方法的描述符。

这里解释一下,什么是简单名称、描述符,以及前面提到的“全限定名”?全限定名和简单名称好理解,比如 com/apache/util/TestClass 是类的全限定名,就是把类全名里的 . 换成了 /,结尾加上分号 ; 用于分割多个连续的全限定名。简单名称是没有类型和参数修饰的方法或字段名称,比如 int m(),其简单名称就是 m。

相对于全限定名和简单名称来说,方法和字段的描述符更复杂些,因为是包含的信息更多:字段类型、方法参数和返回值类型。基本数据类型以及 Void 类型都用一个大写字符标识,对象类型用 L 加对象的全限定名标识,对于数组每个维度使用一个 [ 来表示,二维数组就是 [[,一个整型数组 int[] 会被记录为 [I。

当使用描述符来描述方法的时候,按照先参数列表后返回值的顺序表示,参数列表按照顺序放在 () 之内。比如 void inc(),描述符就是 ()V;java.lang.String toString(int v) 的描述符是 “(I)Ljava/lang/String;”。

字段表最后是属性表集合,多数时候为空,如果自动是常量,则用来存放常量值信息。

方法表集合

class 文件中对方法的描述和对字段的描述格式一样,依次包括:访问标志、名称索引、描述符索引、属性表集合。方法里的代码位于属性表中 Code 属性里,属性表是 class 文件中最具扩展性的数据项。

属性表集合

属性表是一个很常见的数据项,在 class 文件、字段表、方法表里都可以携带自己的属性表,用于描述某些场景专有的信息。

与 Class 文件中其他数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍微宽松些,不再要求各个属性表具有严格顺序。只要不与已有属性名重复,任何人实现的编译器都可以往属性表中写入自定义信息,Java 虚拟机运行时会忽略掉它不认识的属性。

Java 虚拟机规范 (Java SE 7) 中定义了 21 种属性,属性表里包含三项数据:指向属性名常量的索引值、属性值长度、属性值。下面讲几个最常见的属性。

Code 属性

Java 程序方法体中的代码经过编译器处理后,最终变为字节码指令存储在 Code 属性内。Code 属性出现在方法表的属性集合中,其结构如下图所示。

前两项是属性名和属性值长度,max_stack 代表了操作数栈深度的最大值,max_locals 代表了局部变量表需要的存储空间,其单位为 Slot。Slot 是虚拟机为局部变量分配内存的最小单位,对于 byte、int 等长度不超过 32 位的数据类型,占用一个 slot,对于 double、long 等 64 位数据,占用 2 个 slot。方法参数(包括实例中的隐藏参数 this)、显式异常处理器的参数(catch 块定义的异常)、方法体中定义的局部变量都需要使用局部变量表来存放。为了省空间,当代码执行超过一个局部变量的作用域时,这个局部变量所占的 slot 可以被重用。

code_length 和 code 用于存储 Java 程序编译后生成的字节码指令,每个指令名用一个 u1 类型数据表示,也就是说最多可以表达 256 条指令,目前 Java 虚拟机规范定义了约 200 条指令,详细指令可以看文章指令大全。关于 code_length 需要说明,虽然它是一个 u4 类型的值,但是虚拟机规范明确限制了一个方法不允许超过 65535 条字节码指令。

Code 属性是 Class 文件中最重要的一个属性,如果把 Java 程序中的信息分为代码和元数据两部分,那么在整个 class 文件中,Code 属性用于描述代码,所以其他数据项目用于描述元数据。

Exceptions 属性

该属性与 Code 属性平级,和异常表不是一回事儿,存储的是方法描述里 throws 关键字后面列举的异常,其属性表结构如下所示:

LineNumberTable 属性

该属性用于描述 Java 源码行号和字节码行号之间的对应关系,它并不是运行时必须属性,但默认会生成到 class 文件中。在 javac 中分别使用 -g:none 和 -g:lines 选项来取消或要求生成这项信息。如果没有 LineNumberTable 属性,对程序运行的影响就是,当抛出异常时堆栈中不会显示出错的行号,调试时也无法按照源码行来断点。

属性表结构里主要有两个数据:长度字段 line_number_table_length、行号对照表 line_number_info,line_number_info 表包括了两个 u2 类型的数据项:start_pc、line_number,前者是字节码行号,后者是 Java 源码行号。

LocalVariableTable 属性

LocalVariableTable 属性用于描述栈帧中局部变量表与 Java 源码定义的变量之间的关系,它也不是运行时必须的属性,但默认会生成到 class 文件中,使用编译参数 -g:none 或 -g:vars 可以取消或要求生成这项信息。如果没有生成这项信息,其他人在引用该方法时参数名称会丢失,而且调试期间无法根据参数名称来获取参数值。

local_variable_info 描述了栈帧与源码中局部变量的关联,结构如下所示:

start_pc 和 length 分别代表了局部变量生命周期开始的字节码偏移量及其作用范围的覆盖长度,两者结合起来就是该局部变量在字节码之中的作用域范围。

name_index 和 descriptor_index 都是常量池中 CONSTANT_utf8_info 型常量的索引,分别代表了局部变量的名称以及描述符。index 是局部变量在栈帧局部变量表 slot 中的位置,当代表的数据类型为 64 位时,占用的 slot 为 index 和 index+1 两个。

在 JDK1.5 引入泛型后,多了一个姐妹属性 LocalVariableTypeTable,和 LocalVariableTable 很像,仅仅是把字段描述符 descriptor_index 换成了特征签名 signature。对于非泛型类型来说,描述符和特征签名是一致的,由于描述符中泛型的参数类型被擦除,描述符不能准确描述泛型类型了,因此出现了 LocalVariableTypeTable。

SourceFile 属性

用于记录 Class 文件的源文件名称,可以通过 javac 的 -g:none 和 -g:source 取消或开启此项属性。如果不生成此项属性,抛出异常时,堆栈中不会显示出错代码所属文件名。

ConstantValue 属性

ConstantValue 属性的作用是通知虚拟机自动为静态变量赋值,只有 static 关键字修饰的类变量才可以使用这些属性。非 static 变量是在实例构造器方法中进行赋值的,而类变量既可以在类构造器中赋值,也可以使用 ConstantValue 属性赋值。目前 sun javac 编译器会把 final static 修饰的基本类型或 String 变量放在 ConstantValue 属性里,其他使用类构造器赋值,不过 Java 虚拟机规范只是规定了 ConstantValue 属性只能存放 static 变量,并不要求是 final 的。ConstantValue 属性结构如下图所示:

InnerClass 属性

InnerClass 属性用于记录内部类与宿主类之间的关联,其表结构如下所示:

数据项 number_of_classes 代表记录了多少内部类信息,每一个内部类的信息都有一个 inner_class_info 表存储,inner_class_info_index 和 outer_class_info_index 都是指向常量池中 CONSTANT_class_info 类型常量的索引,分别代表了内部类和宿主类的符号引用。inner_name_index 指向常量池中 CONSTANT_utf8_info 类型的常量的索引,代表内部类的名称,如果是匿名内部类,这项值为 0。inner_class_access_flags 是内部类的访问标志。

Deprecated 和 Synthetic 属性

Deprecated 和 Synthetic 属性属于标志类型的 bool 属性,只存在有和没有的区别,没有属性值。Deprecated 用于表示某个类、字段或方法已经被程序作者定位不再推荐使用,它可以通过在代码中使用 @deprecated 注释进行设置。Synthetic 属性表示某字段或方法不是 Java 源码产生的,而是编译时自行添加的。JDK 1.5 之后,要标记类、字段、方法是编译器自动产生的,也可以设置访问标志中的 ACC_SYNTHETIC 标志位。所有非用户代码产生的类、字段、方法都需要设置两者中的一个,类构造器和实例构造器除外。

StackMapTable 属性

该属性是一个复杂的变长属性,位于 Code 属性的属性表里。这个属性在虚拟机类加载的字节码验证阶段被新类型检查验证器使用,目的在于代替之前比较消耗性能的基于数据流分析的类型推导验证器。新的验证器在同样保证 Class 文件合法性的前提下,省略了在运行期通过数据流分析去确认字节码的行为逻辑合法性的步骤,而是在编译器将一系列的验证类型记录在 Class 文件中,通过检查这些验证类型代替类型推导过程,从而大幅提升了字节码验证的性能。其属性结构如下图所示:

Signature 属性

Signature 属性是在 JDK1.5 中增加的,任何类、接口、初始化方法或成员的泛型签名包含了类型变量或参数化类型,Signature 属性就会为其记录泛型签名信息。之所以要使用这样一个属性去记录泛型类型,是因为 Java 语言的泛型采用的是擦除法实现的伪泛型,在字节码中,泛型信息编译之后都被擦除。使用擦除法的好处是实现简单、运行期节省内存,坏处是无法将 C# 等真泛型语言那样将泛型类型和用户定义的普通类型同等对待,例如运行期做反射时无法获得泛型信息。Signature 属性就是为了弥补这个缺陷而设的,其属性结构如下所示:

其中 signature_index 项是一个指向常量池 CONSTANT_utf8_info 类型数据的索引,可以表示类签名、方法签名、字段类型签名。

BootstrapMethods 属性

该属性在 JDK1.7 发布后增加到了 Class 文件规范中,它是一个复杂的变长属性,位于类文件的属性表中,用于保存 invokedynamic 指令引用的引导方法限定符。虚拟机规范规定,如果某个类文件结构的常量池中出现了 CONSTANT_InvokeDynamic_info 类型常量,那么属性表中必须存在 BootstrapMethods 属性。其属性结构如下图所示:

其中引用的 bootstrap_method 结构如下图所示:

bootstrap_method_ref 是一个指向常量池 CONSTANT_MethodHandle_info 数据的索引值,紧随其后的是数量字段,bootstrap_arguments 数组的每个成员必须是一个队常量池的有效索引。

字节码指令简介

字节码与数据类型

Java 虚拟机的指令由一个字节长度的代表某种特定含义的数字(称作操作码,OpsCode)以及紧随其后的零到多个参数构成。由于 Java 指令是面向操作数栈,而非寄存器的架构,所以大多数指令都不包含操作数,只有一个操作码。

在 Java 虚拟机指令集中,大多数指令都包含了操作对应的数据类型信息。例如,iload 是从局部变量表中加载 int 数据到操作数栈,而 fload 加载的是 float 类型的数据。对于大多数与数据类型相关的指令,它们的操作码助记符里都有特殊的字符来表明专门为哪种数据类型服务:i 代表 int 类型数据,l 代表 long,s 代表 short,b 代表 byte,c 代表 char,f 代表 float,d 代表 double,a 代表 reference。

由于 Java 虚拟机的操作码长度只有一个字节,所以包含了数据类型的操作码就为指令设计带来了巨大的压力:如果每种与数据类型相关的指令,都支持所有数据类型的话,那么指令数量就会超出一个字节所能表示的范围了。

下表列出了 Java 虚拟机所支持的指令集与数据类型之间的关系,从中可以看出大部分指令都没有支持 byte、char、short,甚至没有指令支持 boolean。编译器会在编译时,将 byte、short 类型的数据带符号扩展为 int 类型数据,将 boolean、char 类型数据零位扩展至 int 类型数据。因此,大多数对于 byte、short、boolean、char 类型数据的操作,实际上都是使用 int 类型作为运算类型。

指令分类

Java 虚拟机中主要有以下几种指令:

加载和存储指令:用于将数据在栈帧中的局部变量表和操作数栈直接来回传输。包括以下几类: 将局部变量加载到操作数栈: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 运算指令 加法指令: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虚拟机对于宽化类型转换直接支持,并不需要指令执行。窄化类型转换指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。窄化类型转换很可能会造成精度丢失。 对象创建与操作指令 创建类实例的指令:new创建数组的指令:newarray,anewarray,multianewarray访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者成为实例变量)的指令:getfield、putfield、getstatic、putstatic把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload将一个操作数栈的值储存到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore取数组长度的指令:arraylength检查类实例类型的指令:instanceof、checkcast 控制转移指令 条件分支: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 操作数栈管理指令 将栈顶元素出栈:pop、pop2赋值栈顶元素再重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2栈顶两个元素互换:swap 方法调用和返回指令 invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。invokeinterface指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。invokestatic指令用于调用类方法(static方法)invokedynamic指令用于调用以绑定了invokedynamic指令的调用点对象(call site object)作为目标的方法。调用点对象是一个特殊的语法结构,当一条invokedynamic指令首次被Java虚拟机执行前,Java虚拟机将会执行一个引导方法(bootstrap method)并以这个方法的运行结果作为调用点对象。因此,每条invokedynamic指令都有独一无二的链接状态,这是它与其他方法调用指令的一个差异。方法返回指令则是根据返回值的类型区分的,包括:ireturn、lreturn、freturn、dreturn、areturn、return(返回 void)。 显示抛出异常指令 athrow同步指令:monitorenter 和 monitorexit。

公有设计与私有实现

Java 虚拟机规范描绘了 Java 虚拟机应有的共同程序存储格式:Class 文件格式以及字节码 指令集。这些内容与硬件、操作系统以及具体的虚拟机实现之间是完全独立的,Java 虚拟机实现必须能够读取 Class 文件并精确实现包含在其中的 Java 虚拟机代码的语义。

一个优秀的 Java 虚拟机实现,在满足虚拟机规范的约束下,对具体实现做出修改和优化也是可行的,并且虚拟机规范明确鼓励实现者这样做。虚拟机实现者可以利用这种伸缩性,来让 Java 虚拟机获得更高的性能、更低的内存消耗或更好的可移植性,选择哪种特性取决于 Java 虚拟机实现的目标和关注点是什么。虚拟机实现方式主要有以下两种:

将输入的 Java 虚拟机代码在加载或执行时翻译成另一种虚拟机的指令集。将输入的 Java 虚拟机代码翻译成宿主机 CPU 的本地指令集(JIT 代码生成技术)。

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。