暂时跳过实战和调优部分
概述
把程序编译成二进制本地机器码(Native Code)已不再是唯一的选择
越来越多的程序语言选择了与操作系统和机器指令集无关的、平台中立的格式作为程序编译后的存储格式。
平台无关性:各种不同平台的 Java 虚拟机,以及所有平台都统一支持的程序存储格式——字节码(Byte Code)
语言无关性:基础是虚拟机和字节码存储格式。Java 虚拟机不与包括 Java 语言在内的任何程序语言绑定,它只与“Class 文件”这种特定的二进制文件格式所关联。即虚拟机丝毫不关心 Class 的来源是什么语言
Class 类文件的结构
1. 魔数与Class文件的版本
每个 Class 文件的头 4 个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。
Class 文件的魔数取得很有“浪漫气息”,值为 0xCAFEBABE(咖啡宝贝?)。
紧接着魔数的 4 个字节存储的是 Class 文件的版本号:第 5 和第 6 个字节是次版本号(Minor Version),第 7 和第 8 个字节是主版本号(Major Version)。
2. 常量池
紧接着主、次版本号之后的是常量池入口
常量池可以比喻为 Class 文件里的资源仓库,它是 Class 文件结构中与其他项目关联最多的数据,通常也是占用 Class 文件空间最大的数据项目之一。另外,它还是在 Class 文件中第一个出现的表类型数据项目。
由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项 u2 类型(两字节)的数据,代表常量池容量计数值。
常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)
-
字面量 接近于 Java 语言层面的常量概念,如文本字符串、被声明为 final 的常量值等
-
符号引用 属于编译原理方面的概念,主要包括下面几类常量:
- 被模块导出或者开放的包(Package)
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
- 方法的名称和描述符
- 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
- 动态调用点和动态常量(Dynamically-Computed Call Site、 Dynamically-Computed Constant)
当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
常量池中每一项常量都是一个表,常量表中有结构各不相同的表数据结构
在 JDK 的 bin 目录中,Oracle 公司已经为我们准备好一个专门用于分析 Class 文件字节码的工具:javap。
3. 访问标志
在常量池结束之后,紧接着的 2 个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:
- 这个 Class 是类还是接口;
- 是否定义为 public 类型;
- 是否定义为 abstract 类型;
- 如果是类的话,是否被声明为 final
- ......
4. 类索引、父类索引与接口索引集合
类索引(this_class):
- 用于确定这个类的全限定名
- u2 类型的数据
父类索引(super_class):
- 用于确定这个类的父类的全限定名
- 由于 Java 语言不允许多重继承,所以父类索引只有一个
- 除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。
- u2 类型的数据
接口索引集合(interfaces):
- 用来描述这个类实现了哪些接口
- 被实现的接口将按 implements 关键字后的接口顺序,从左到右排列在接口索引集合中。
- 一组 u2 类型的数据的集合
Class 文件中由这三项数据来确定该类型的继承关系。
5. 字段表集合
用于描述接口或者类中声明的变量。
Java 语言中的“字段”(Field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。
字段可以包括的修饰符有字段的作用域(public、private、 protected 修饰符)、是实例变量还是类变量(static 修饰符)、可变性(final)、并发可见性(volatile 修饰符,是否强制从主内存读写)、可否被序列化(transient 修饰符)、字段数据类型(基本类型、对象、数组)、字段名称。
上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。
而字段叫做什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。
方法和字段的描述符就要复杂一些。描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。
-
基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的 void 类型都用一个大写字符来表示
-
对象类型则用字符 L 加对象的全限定名来表示
-
数组类型,每一维度将使用一个前置的
[字符来描述 如一个定义为“java.lang.String[][]”类型的二维数组将被记录成[[Ljava/lang/String;,一个整型数组“int[]”将被记录成[I。 -
描述符来描述方法时,按照先参数列表、后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。 如方法
void inc()的描述符为()V方法
java.lang.String toString()的描述符为()Ljava/lang/String;方法
int indexOf(char[]source,int sourceOffset,int sourceCount,char[]target, int targetOffset,int targetCount,int fromIndex)的描述符为([CII[CIII)I
字段表集合中不会列出从父类或者父接口中继承而来的字段,但有可能出现原本 Java 代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,编译器就会自动添加指向外部类实例的字段。
6. 方法表集合
Class 文件存储格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,方法表的结构如同字段表一样,依次包括**访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)**几项
方法里的 Java 代码,经过 Javac 编译器编译成字节码指令之后,存放在方法属性表集合中一个名为“Code”的属性里面,属性表作为 Class 文件格式中最具扩展性的一种数据项目
与字段表集合相对应地,如果父类方法在子类中没有被重写,方法表集合中就不会出现来自父类的方法信息。
在 Java 语言中,要重载一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名。
特征签名:指一个方法中各个参数在常量池中的字段符号引用的集合,也正是因为返回值不会包含在特征签名之中,所以 Java 语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。
7. 属性表集合
Class 文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有的信息。
Code 属性
Java 程序方法体里面的代码经过 Javac 编译器处理之后,最终变为字节码指令存储在 Code 属性内。
-
max_stack 代表了操作数栈(Operand Stack)深度的最大值。
-
max_locals 代表了局部变量表所需的存储空间。
max_locals 的单位是变量槽(Slot),变量槽是虚拟机为局部变量分配内存所使用的最小单位。
对于 byte、char、float、int、short、boolean 和 returnAddress 等长度不超过 32 位的数据类型,每个局部变量占用一个变量槽
而 double 和 long 这两种 64 位的数据类型则需要两个变量槽来存放。
-
code_length 和 code 用来存储 Java 源程序编译后生成的字节码指令。 code_length 代表字节码长度, code 是用于存储字节码指令的一系列字节流。
-
Code 属性是 Class 文件中最重要的一个属性: 如果把一个 Java 程序中的信息分为代码(Code,方法体里面的 Java 代码)和元数据(Metadata,包括类、字段、方法定义及其他信息)两部分,那么在整个 Class 文件里,Code 属性用于描述代码,所有的其他数据项目都用于描述元数据。
字节码指令简介
Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operand)构成。
虚拟机在处理那些超过一个字节的数据时,在运行时从字节中重建出具体数据的结构
这种操作在某种程度上会导致解释执行字节码时将损失一些性能,但这样做的优势也同样明显:
- 放弃了操作数长度对齐,就意味着可以省略掉大量的填充和间隔符号;
- 用一个字节来代表操作码,也是为了尽可能获得短小精干的编译代码。
1. 字节码与数据类型
大多数指令都包含其操作所对应的数据类型信息
举个例子,iload 指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据。
这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但在 Class 文件中它们必须拥有各自独立的操作码。
2. 加载和存储指令
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,这类指令包括
存储数据的操作数栈和局部变量表主要由加载和存储指令进行操作,除此之外,还有少量指令,如访问对象的字段或数组元素的指令也会向操作数栈传输数据。
3. 运算指令
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。
大体上运算指令可以分为两种:对整型数据进行运算的指令与对浮点型数据进行运算的指令。
在处理浮点数时,必须严格遵循 IEEE 754 规范中所规定行为和限制
4. 类型转换指令
类型转换指令可以将两种不同的数值类型相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理字节码指令集中,数据类型相关指令无法与数据类型一一对应的问题。
5. 对象创建与访问指令
虽然类实例和数组都是对象,但 Java 虚拟机对类实例和数组的创建与操作使用了不同的字节码指令
对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素。
6. 操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,Java 虚拟机提供了一些用于直接操作操作数栈的指令,包括:
- 将操作数栈的栈顶一个或两个元素出栈:pop、pop2
- 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、 dup2_x1、dup_x2、dup2_x2
- 将栈最顶端的两个数值互换:swap
7. 控制转移指令
控制转移指令可以让 Java 虚拟机有条件或无条件地从指定位置指令(而不是控制转移指令)的下一条指令继续执行程序,从概念模型上理解,可以认为控制指令就是在有条件或无条件地修改 PC 寄存器的值。
控制转移指令包括:
- 条件分支: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
8. 方法调用和返回指令
方法调用(分派、执行过程)
9. 异常处理指令
在 Java 程序中显式抛出异常的操作(throw 语句)都由 athrow 指令来实现,除了用 throw 语句显式抛出异常的情况之外,《Java 虚拟机规范》还规定了许多运行时异常会在其他 Java 虚拟机指令检测到异常状况时自动抛出。
而在 Java 虚拟机中,处理异常(catch 语句)不是由字节码指令来实现的(很久之前曾经使用 jsr 和 ret 指令来实现,现在已经不用了),而是采用异常表来完成。
10. 同步指令
Java 虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor,更常见的是直接将它称为“锁”)来实现的。
方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。
虚拟机可以从方法常量池中的方法表结构中的 ACC_SYNCHRONIZED 访问标志得知一个方法是否被声明为同步方法。
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。
在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。
如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。
小结
Class 文件是 Java 虚拟机执行引擎的数据入口,也是 Java 技术体系的基础支柱之一。了解 Class 文件的结构对后面进一步了解虚拟机执行引擎有很重要的意义。