class文件不光是Java文件的特定二进制格式文件,还有很多其他的语言都可以编译成class文件格式,例如Jruby,jython,Clojure等等语言。
class文件只对虚拟机负责。虚拟机也不关心class文件是何种语言。
class类文件结构如下
class文件是一组以8字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的排列在class文件之中。中间没有任何分隔符。
class文件格式采用一种类似C语言结构体的伪结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。
1,无符号数属于基本类型数据,以u1,u2,u4,u8来分别代表1个字节,2个字节,4个字节和8个字节。无符号数可以用来描述数字,索引引用,数量值或者按照UTF-8编码构成的字符串值。
2,表示由多个无符号数或者其他表作为数据项构成的复合数据类型。所有表都习惯性的以-info结尾。整个class文件本质上就是一张表。
class结构不像XML那样,由于它没有任何分隔符号,无论是顺序还是数量,甚至数据存储的字节序,都是被阉割限定的,哪个字节代表什么含义,长度是多少,顺序都不允许改变。
魔数与class文件的版本
每个class文件的头4个字节称为魔数,唯一作用就是确定这个文件是否为一个能被虚拟机接受的class文件,紧接着魔数的4个字节存储的是class文件的版本号。高版本的JDK能向下兼容以前的版本的class文件,但不能运行以后版本的class文件。虚拟机也必须拒绝执行超过其版本号的class文件。
常量池
紧接着版本号之后的是常量池入口,常量池可以理解为class文件之中的资源仓库。它是class文件结构中与其它项目关联最多的数据类型,也是占用class文件空间最大的数据项目之一。同时它还是在class文件中第一个出现的表类型数据项目。由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项U2类型的数据,代表常量池容量计数值。
常量池中主要存放两大类常量:字面量和符号引用。字面量比较接近于java语言层面的常量概念,如文本字符串,声明为final的常量值等等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
1,类和接口的全限定名
2,字段的名称和描述符
3,方法的名称和描述符
java代码在进行javac编译的时候并没有像C一样链接,而是在虚拟机加载class文件的时候进行动态链接。也就是说class文件中不会保存各个方法字段的最终内存信息,保存的都是一些符号引用。等到运行类加载的时候再进行翻译成具体的内存地址信息。
常量池中每一项常量都是一个表,每个表开始的第一位是一个U1类型的标志位(tag),代表当前这个常量属于哪种常量类型,一共有14种类型,分别是
1,UTF-8编码的字符串
2,整型字面量
3,浮点型字面量
4,长整型字面量
5,双精度浮点型字面量
6,类或接口的符号引用
7,字符串类型字面量
8,字段的符号引用
9,类中方法的符号引用
10,接口中方法的符号引用
11,字段或方法的部分符号引用
12,表示方法句柄
13,标识方法类型
14,表示一个动态方法调用点
有些常量例如<init>等等会被后面即将降到的字段表,方法表属性表引用到,它们会用来描述一些不方便使用“固定字节”进行表达的内容,譬如描述方法的返回值是什么?有几个参数,每个参数的类型,因为Java中的类是无穷无尽的,无法简单的通过无符号字节来描述一个方法用到了什么类,因此在描述方法的这些信息时候,需要引用常量表中的符号引用进行表达。
在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息。包括,这个class是类还是接口,是否public,是否final等等。
类索引,父类索引与接口索引集合
类索引,父类索引和接口索引集合都按顺序排列在访问标志之后,class文件中由这三项数据来确定这个类的继承关系。类索引和父类索引都是一个u2类型的数据,而接口索引集合是一组u2类型的数据集合,类索引用于确定这个类的全限定名,父类索引用于确定整个类的父类的全限定名。接口索引集合用来描述这个类实现了哪些接口。
字段表集合
字段表用于描述接口或者类中声明的变量。字段包括类级变量(static修饰符)以及实例级变量,但不包括在方法内部声明的局部变量,字段的信息大概有以下:字段的作用域(public,private,protected修饰符),是实例还是类变量(static修饰符),可变性(final修饰符),并发可见性(volatile修饰符),可否被序列化(transient修饰符),字段数据类型(基本类型,对象,数组),字段名称。这些信息中,各个修饰符都是布尔值,很适合用标志位表示,而字段的名称,类型,这些都无法固定,只能引用常量池中的常量来描述。字段表集合中不会列出从超类或者父类接口中继承而来的字段,但是有可能列出原本java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
方法表集合
class文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。依次包括了访问标志,名称索引,描述符索引,属性表集合等,区别比较大的是方法里面是有代码的,方法里面的代码经过编译器编辑字节码指令后,存放在方法属性表集合中一个名为code的属性里面。属性表作为class文件格式中最具扩展性的一种数据项目。将在后面进行详细解释。与字段表集合相对应的,如果父类方法在子类中没有被重写,方法表集合中就不会出现来自父类方法的信息。
属性表集合
属性表在前面讲解之中已经出现过数次,在class文件,字段表,方法表都可以携带自己的属性表集合,以用于描述某些场景专有信息。对于每个属性,它的名称需要从常量池中引用一个UTF-8编码字符串类型的常量来表示。而属性值的结构则完全是自定义的。值需要通过一个u4的长度属性去说明属性值所占用的位数即可。
1,code属性
java程序方法体中的代码经过javac编译器处理后,最终变为字节码指令存储在code属性内,code属性出现在方法表的属性集合之中。但并非所有的方法表都必须存在这个属性,比如接口或者抽象类中的方法就不存在code属性。code属性包含以下内容。
(1)属性名称
(2)操作数栈深度的最大值,虚拟机运行的时候需要根据这个值来分配栈帧中的操作栈深度。
(3)局部变量表所需的存储空间,单位是slot,每个局部变量占用1个slot,double和long需要2个slot
(4)code_length和code用来存储java源程序编译后生成的字节码指令,code_length代表字节码长度,code是用于存储字节码指令的一系列字节流。每个指令都是一个u1类型的单字节,当虚拟机读取到code中的一个字节码时,就可以根据虚拟机字节码指令表找到对应的指令。并且可以知道这条指令后面是否需要跟随参数,以及参数应当如何理解。
code属性是class文件中最重要的一个属性,如果把一个java程序中的信息分为代码(code,方法体里面的Java代码)和元数据(Metadata,包括类,字段,方法定义以及其他信息)两部分,那么在整个class文件中,code属性用于描述代码,所有其他数据项目都用于描述元数据。
我们举例看<init> 方法的code属性,它的操作数栈的最大深度和本地变量表的容量都为0x0001,字节码区域所占空间的长度为0X0005,虚拟机读取到字节码区域的长度后,按照顺序依次读入紧随的5个字节,并根据字节码指令表翻译出所对应的字节码指令。翻译“2A B7 000AB1”的过程为:
(1)读入2A,查表得0x2A对应的指令为aload_0,这个指令的含义是将第0个slot中为reference类型的本地变量推送到操作数栈顶
(2)读入B7,查表得0xB7对应的指令为invokespecial,这条指令的作用是以栈顶的reference类型的数据所指向的对象作为方法接收者,调用此对象的实例构造器方法,private方法或者它的父类的方法。这个方法有一个u2类型的参数说明具体调用哪一个方法。它指向常量池中的一个该方法的符号引用。
(3)读入00 0A ,这是invokespecial的参数,查常量池得0x000A对应的常量为实例构造器<init>方法的符号引用。
(4)读入B1,查表得0xB1对应的指令为return,含义是返回此方法,并起而返回值为void。这条指令执行后,当前方法结束。
在字节码指令之后的是这个方法的显示异常处理表集合,异常表对于code属性来说并不是必须存在的,异常表实际上是Java代码的一部分,编译器使用异常表而不是简单的跳转命令来实现java异常及finally处理机制。
2,exceptions属性
这里的exceptions属性是在方法表中与code属性平级的一项属性(不要与异常表产生混淆),exceptions属性的作用是列举出方法中可能抛出的受查异常(Checked Exceptions),也就是方法描述时在throws关键字后面列举的异常。
3,LineNumBerTable属性
用于描述java源代码行号与字节码行号之间的对应关系。会默认生成到class文件之中。
4,localVariableTable属性
用于描述栈帧中局部变量表中的变量与java源码中定义的变量之间的关系,默认会生成到class文件之中。
5,sourceFile属性
用于记录生成这个class文件的源码文件名称。
6,ConstantValue属性
用于通知虚拟机自动静态变量赋值。只有被static关键字修饰的变量才可以使用这项属性。对于非static类型的变量的赋值是在实例构造器<init>方法中进行的。对于类变量,则有两种方式可以选择,在类构造器<clint>方法中或者使用ConstantValue属性。
7,InnerClasses属性
用于记录内部类与宿主类之间的关联。如果一个类中定义了内部类,编译器将会为它所包含的内部类生成InnerClasses属性。
8,Deprecated及Synthetic属性
这两个属性都属于标志类型的布尔属性,只存在有何没有的区别。没有属性值的概念。Deprecated属性用于表示某个类,字段或者方法,已经被程序作者定位不再推荐使用,它可以通过在代码中使用@deprecated注释进行设置。
synthetic属性代表此字段或者方法并不是由java源码直接产生的,而是由编译器自行添加的。
9,StackMapTable属性
这是一个复杂的变长属性,位于Code属性的属性表中,这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器使用。目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器。
10,Signature属性
它是一个可选的定长属性,可以出现于类,属性表和方法表结构的属性表中,任何类,接口,初始化方法或者成员的泛型签名如果包含了类型变量或者参数化类型,则Signature属性会为它记录泛型签名信息。之所以要专门使用这样一个属性去记录泛型类型,是因为Java语言的泛型采用的是擦除法实现的伪泛型。
11,BootstrapMesthod属性
它是一个复杂的变长属性,位于类文件的属性表中,这个属性用于保存invokedynamic指令引用的引导方法限定符,
字节码指令简介
java虚拟机的指令由一个字节长度的,代表着某种特定操作含义的数字(称为操作码)以及跟随其后的零至多个代表此操作所需参数(称为操作数)而构成,由于java虚拟机采用面向操作数栈而不是寄存器的架构,所以大多数的指令都不包含操作数,只有一个操作码。
由于限制了java虚拟机操作码的长度为一个字节,又由于class文件格式放弃了编译后代码的操作数长度对齐,这就意味着虚拟机处理那些超过一个字节数据的时候,不得不在运行时从字节中重建出具体数据的结构。这种操作在某种程度上会导致解释执行字节码时损失一些性能,但这样做的优势也非常明显,放弃了操作数长度对齐,就意味着可以省略很多填充和间隔符号,用一个字节来代表操作码,也是为了尽可能获得短小精干的编译代码。传输高效。
字节码与数据类型
在java虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息,例如,iload指令用于加载int类型数据,fload指令用于加载float类型数据。这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但是在class文件中他们必须拥有各自独立的操作码。
对于大部分与数据类型相关的字节码指令,他们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务。
由于java虚拟机的操作码长度只有一个字节,所以包含了数据类型的操作码就为指令集的设计带来了很大的压力。如果每一种与数据类型相关的指令都支持java虚拟机所有运行时数据类型的话,那指令的数量恐怕就会超出一个字节所能表示的数量范围了,因此,java虚拟机的指令集对于特定的操作只提供了有限的类型相关指令去支持它,换句话说,指令集将会故意被设计成非完全独立的。有一些单独的指令可以在必要的时候用来将一些不支持的类型转换为可被支持的类型。
大部分的指令都没有支持整数类型byte,char和short甚至没有任何指令支持boolean类型,编译器会在编译期或运行期将byte和short类型的数据带符号扩展为相应的int类型数据。将boolean和char类型数据零位扩展为相应的int类型数据。数组也一样。因此大多数对于数据的操作,实际上都是使用相应的int类型作为运算类型。
加载和存储指令
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,这类指令包括如下内容
(1)将一个局部变量加载到操作栈
(2)将一个数值从操作数栈存储到局部变量表
(3)将一个常量加载到操作数栈
(4)扩充局部变量表的访问索引的指令
存储数据的操作数栈和局部变量表主要就是由加载和存储指令进行操作,除此之外,还有少量指令,如访问对象的字段或数组元素的指令也会向操作数栈传输数据。
运算指令
运算或算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。包含以下指令
(1)加法 iadd ladd fadd dadd
(2)减法 。。
(3)乘法
(4)除法
(5)求余
(6)取反
(7)位移
(8)或
(9)与
(10)异或
(11)自增
(12)比较
类型转换指令
类型转换指令可以将两种不同的数值类型进行相互转换,这些转换操作一般用于实现用户代码中的显示类型转换操作,或者用来处理指令和数据类型无法一一对应的问题。java虚拟机直接支持一下数值类型的宽化类型转换(即小范围向大范围类型的安全转换)
(1)int到long float double
(2)long到float double
(3)float到double
相对的处理窄化类型转换时,必须显示地使用转换指令来完成。窄化类型转换可能会导致转换结果产生不同的正负号,不同的数量级的情况,转换过程很可能会导致数值的精度丢失。
在将int或long类型窄化转换为整数类型T的时候,转换过程仅仅是简单地丢弃除最低位N个字节以外的内容,N是类型T的数据类型长度。
对象创建与访问指令
虽然实例和数组都是对象,但java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素,这些指令如下:
(1)创建类实例的指令:new
(2)创建数组的指令:newarray
( 3)访问类字段和实例字段
(4)把一个数组元素加载到操作数栈的指令
(5)将一个操作数栈的值存储到数组元素中的指令
(6)取数组长度的指令
(7)检查类实例类型的指令
操作数栈管理指令
(1)将操作数栈顶的一个或者两个元素出栈
(2)复制栈顶一个或者两个数值并将复制值或双份的复制值重新压入栈顶
(3)将栈最顶端的两个数值互换
控制转移指令
控制转移指令可以让Java虚拟机有条件或者无条件的从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,可以认为控制转移指令就是在有条件或者无条件的修改PC寄存器的值。
方法调用和返回指令
(1)invokevirtual 用于调用对象的实例方法,根据对象的实际类型进行分派。
(2)invokeinterface 用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出合适的方法进行调用。
(3)invokespecial 用于调用一些需要特殊处理的实例方法,包括实例初始化方法,私有方法和父类方法。
(4)invokestatic 用于调用类方法(static方法)
(5)invokedynamic 用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法 。
方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的,包括ireturn,Lreturn,dreturn和arerurn,另外还有一条return指令供声明为void的方法,实例初始化方法以及类和接口的类初始化方法使用。
异常处理指令
在java虚拟机中,处理异常不是由字节码指令来实现的,而是采用异常表来完成的。
同步指令
java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。
方法级的同步是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中,虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否声明为同步方法,当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程,如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那么这个同步方法所持有的管程将在异常抛到同步方法之外时自动释放。
同步一段指令集序列通常是由java语言中的synchronized语句块来表示的,java虚拟机的指令集中由monitorenter和monitorexit两条指令来支持synchronized关键字的语义,正确实现synchronized关键字需要javac编译器与java虚拟机两者共同协作支持。编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须执行其对应的monitorexit指令,而无论这个方法是正常结束还是异常结束。编译器会自动生成一个异常处理器,用来执行monitorexit指令。
公有设计和私有实现
Java虚拟机规范描绘了Java虚拟机应有的公共程序存储格式:class文件格式以及字节码指令格式,这些内容与硬件,操作系统及具体的Java虚拟机实现之间是完全独立的。虚拟机实现者可能更意愿把它们看做是程序在各种Java平台实现之间互相安全地交互的手段。
虚拟机的实现方式主要有以下两种:
(1)将输入的Java虚拟机代码在加载或执行时翻译成另外一种虚拟机的指令集
(2)将输入的Java虚拟机代码在加载或执行时翻译成宿主机cpu的本地指令集(jit代码生成技术)。