《深入理解Java虚拟机:JVM高级特性与最佳实践》- 虚拟机执行子系统

类文件结构

Class 类文件结构

Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项目之间严格按照顺序紧凑地排列在 Class 文件之中,中间没有添加任何分隔符,这使得整个 Class 文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。任何一个 Class 文件都对应着唯一一个类或者接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(比如类或接口也可以通过类加载器直接生成)。

魔数与 Class 文件的版本

每个 Class 文件的头 4 个字节称为魔数(Magic Number),唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件;Class 文件的魔数值为 0xCAFFBAB。紧接着魔数后面的四个字节是次版本号(Minor Version,第5、6 个字节)和主版本号(Major Version,第7、8个字节)。高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件(即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的 Class 文件)。

主流 JDK 版本编译器输出的默认和可支持的 Class 文件版本号如下图:

jdk_support_class_file_version.jpeg

常量池

常量池入口紧接着主次版本号之后。它是 Class 文件结构中与其他项目关联最多的数据类型,也是占用 Class 文件空间最大的数据项目之一,同时还是在 Class 文件中第一个出现的表类型数据项目。常量池主要存放两大类常量:

  • 字面常量(Literal):如文本字符串、声明为 final 的常量值等。
  • 符号引用(Symbolic References):包括类和接口的权限定名(Fully Qualified Name)、字段的名称和描述符(Descriptor)、方法的名称和描述符。

常量池中的 14 中常量项的结构表如下图:

java_const_pool_const_structer.jpeg

访问标志

紧跟着常量池后面的两个字节是访问标志(access_flag),用于识别一些类或接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型;如果是类是否被声明为 final 等。具体含义如下图:

java_class_access_flag.jpeg

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

类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,接口索引集合就用来描述这个类实现了哪些接口。Class 文件由这三项确定这个类的继承关系。

字段表集合

字段表(field_info)用于描述接口或者类中声明的变量。依次包含访问标识(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)。

三个概念:

  1. 全限定名:类全名中的.替换成/并以;结束表示全限定名结束(如’co/lujun/test/TestName;’)。
  2. 简单名称是指没有类型和参数修饰的方法或者字段名称(如inc()方法和m字段的简单名称分别是incm)。
  3. 描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。

关于描述符标识自负含义如下图:

java_descriptor_index.jpeg

方法int indexOf(char[] chars, int i, int j)的描述符为([CII)I

字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原来 Java 代码中不存在的字段(例如内部类为了保持外部类的访问性,会自动添加指向外部类实例的字段)。

方法表集合

结构和字段表一样,依次包含访问标识(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)。

属性表集合

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

虚拟机规范中预定义的属性:

java_set_code_attributes.jpeg

字节码指令

Java 虚拟机指令由一个字节长度的、代表着某种特定操作含义的数字(操作码,Opcode)以及跟随其后的零至多个代表此操作所需的参数(操作数,Operands)构成。由于 JVM 采用面向操作数而非寄存器的架构,所以大多数的指令都不包含操作数,而只包含一个操作码。

  • 加载和存储指令:将数据在栈帧的局部变量表和操作数栈之间来回传输(如iload将局部整型变量加载到操作栈)。
  • 运算指令:对两个操作栈上的值进行某种特定运算,并把结果重新存入到操作栈顶(如iadd加法指令)。
  • 类型转换指令:将两种不同数值类型进行互相转换,这些转换操作一般用于实现用户代码中显示类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
  • 对象创建于访问指令:如 ‘new’ 创建类实例, ‘newarray’ 创建数组,’getfield’ 访问实例字段,’getstatic’ 访问类字段等。
  • 操作数栈管理指令:如 ‘pop’ 操作数栈栈顶元素出栈,’swap’ 将栈顶端两个元素互换等。
  • 控制转移指令:控制转移指令可以让 Java 虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一跳指令继续执行程序(比如 ‘ifeq’ 条件分之指令)。
  • 方法调用和返回指令:如 ‘invokevirtual’ 指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派)。方法返回指令是根据返回值的类型区分的(比如 ‘ireturn’ 表示返回值是booleanbytecharshortint类型是使用)。
  • 异常处理指令:Java 程序中显示抛出异常的操作(throw 语句)都有 ‘athrow’ 指令实现,许多运行时异常会在其他 Java 虚拟机指令检测到异常状况时自动抛出。
  • 同步指令:Java 虚拟机支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是用管程(Monitor)来支持的。

公有设计和私有实现

Java 虚拟机应有共同的程序存储格式:Class 文件格式以及字节码指令集,这些内容与硬件、操作系统以及具体的 Java 虚拟机实现之间是完全独立的。

虚拟机类加载机制

虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型(与那些在编译时需要进行连接工作的语言不同,Java 中类型的加载、连接和初始化过程都是在程序运行期间完成的)。

类加载的时机

类从被加载到内存到卸载出内存,经过的生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialza)、使用(Using)和卸载(Unloading)。其中验证、准备和解析 3 个部分统称为连接(Linking)。

java_class_load_process.jpeg

加载、验证、准备、初始化、卸载这 5 个阶段是确定的,类的加载过程必须按照这种顺序按部就班的开始,而解析阶段则不一定,在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 语言的运行时绑定。

以下情况下类必须初始化:

  • 遇到newgetstaticputstaticinvokestatic这 4 条字节码指令时,如果类没有进行过初始化,则先触发其初始化。生成这 4 条指令最常见的 Java 场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被 final 修饰、已在编译期间把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化则触发初始化。
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发父类的初始化。
  • 当虚拟机启动的时候,用户需要指定一个需要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
  • 当使用 JDK 1.7 的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getStaticREF_putStaticREF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先触发其初始化。

这 5 种场景中的行为称为对一个类进行主动引用,除此之外,所有引用类的方式都不会触发初始化,称为被动引用。

接口也有初始化过程,这点与类一致。接口不能使用static{}语句块,但编译器仍然会为接口生成clinit()类构造器,用于初始化接口中定义的成员变量。初始化场景中接口与类不同之处在于:类初始化时,要求其父类全部已经初始化过,而接口初始化时并不要求其父接口全部都初始化,只有在真正使用到父接口的时候(比如引用接口定义的常量)才会初始化。

类加载的过程

加载

在加载阶段,虚拟机需要完成三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

验证

验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致上会完成以下 4 个阶段的校验动作:

  1. 文件格式校验:该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区中,格式上符合描述一个 Java 类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的三个验证都是基于方法区的存储结构进行的,不会再直接操作字节流。
  2. 元数据校验:对字节码描述的信息进行语义分析,保证其描述的信息符合 Java 语言规范的要求。
  3. 字节码校验:通过数据流和控制流分析确定程序语义是合法的、符合逻辑的。这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。
  4. 符号引用验证:发生在虚拟机将符号引用转换为直接引用的时候,这个转化动作将在连接的第三阶段 —— 解析阶段中发生。符号医用验证可以看做是对类自身以外(常量池中的各种符号引用除外)的信息进行匹配性校验。目的是确保解析动作能正常执行。

准备

准备阶段是正式为类变量(被 static 修饰)分配内存并设置类变量初始值(零值)的阶段,这些变量所使用的内存都在方法区中进行分配。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接以用的过程。解析阶段主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。

初始化

类加载过程的最后一步,真正执行类中定义的 Java 程序代码(字节码)。

初始化阶段是执行类构造器<clinit>()方法的过程。关于<clinit>()方法:

  • <clinit>()方法是有编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{})中的语句合并产生的。
  • <clinit>()方法与类的构造函数不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。
  • 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
  • <clinit>()方法对类或接口来说并不是必须的(如果类中没有静态语句块或者没有对类变量的复制操作,编译器可以不为这个类生成<clinit>()方法)。
  • 接口中没有静态语句块,但是为接口中的静态变量初始化赋值操作时仍然会有<clinit>()方法生成。与类不同的是,接口中的<clinit>()方法执行不需要先执行父接口的<clinit>()方法。另外接口的实现类在初始化时也不会执行接口的<clinit>()方法。
  • 虚拟机保证一个类的<clinit>()方法在多线程环境中能被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程执行这个类的<clinit>()方法,其它线程需要阻塞等待,直到活动线程执行<clinit>()方法完毕,所以如果在一个类的<clinit>()方法耗时过长,有可能造成多个进程阻塞。

类加载器

类与类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间(比较两个类是否“相等”[包括代表类的 Class 对象的 equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceOf关键字做对象所属关系判定等情况],只有这两个类是由同一个类加载器加载的前提下才有意义)。

双亲委派模型

系统提供的 3 种类加载器:

  1. 启动类加载器(Bootstrap ClassLoader):负责将存放在<JAVA_HOME>\lib目录中,或者被Xbootclasspath参数所指定的路径中的,并且被虚拟机识别的(仅按照文件名识别)类库加载到虚拟机内存中。
  2. 扩展类加载器(Extension ClassLoader):负责加载<JAVA_HOME>\lib\ext目录下或者被java.ext.dirs系统变量所指定的路径中的类库。
  3. 应用程序类加载器(Application ClassLoader):系统类加载器。负责加载用户类路径(ClassPath)上所指定的类库,如果应用程序没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

类加载器双亲委派模型(Parents Delegation Model):

java_parents_delegation_model.jpeg

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来服用父加载器的代码。

双亲委派模型工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的类加载器中,只有当父加载器反馈自己无法完成这个加载请求(搜索范围没有找到所需的类)时,子加载器才会尝试自己去加载。

虚拟机字节码执行引擎

运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧中存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每个方法从调用开始到执行完成的过程,都对应这一个栈帧在虚拟机栈里面从入栈到出栈的过程。

局部变量表

局部变量表(Local Variable Table)是一组变量存储空间,用于存放方法参数和方法内部定义的局部变量。

操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个后入先进栈。当一个方法开始执行的时候,这个方法的操作数栈时空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈过程。

动态连接

每个栈帧都包含一个指向运行时常量池中该帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。

方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法),这种退出称为正常完成出口(Normal Method Invocation Completion);另一种退出是遇到了异常,并且异常没有在方法体内得到处理,无论是 Java 虚拟机内部产生的异常还是代码中的athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理,就会导致方法退出,这种退出方法的方式成为异常完成出口(Abrupt Method Invocation Completion),异常退出不会给上层调用者任何返回值。

附加信息

方法调用

方法调用并不等于方法执行,方法调用阶段唯一任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体过程。

解析

所有方法调用中的目标方法在 Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,这种解析能成立的前提是:调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析。

分派

  • 静态分派:[重载 Overload]所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
  • 动态分派:[重写 Override]运行期根据实际类型确定方法执行版本的分派过程。
  • 单分派与多分派:方法的接受者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将方法划分为为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多余一个宗量对目标方法进行选择。静态分派属于多分派类型,动态分派属于单分派类型。

动态类型语言支持

基于栈的字节码解释执行引擎

解释执行

程序代码到物理机的目标代码或虚拟机能执行的指令集之前的编译过程:

java_code_compile_status.jpeg

如上图:中间分支就是解释执行过程,下面的分支就是传统程序代码到目标代码得生产过程。

基于栈的指令集与基于寄存器的指令集

Java 编译器输出的指令流,基本上是一种基于栈的指令集架构(Instrction Set Architecture, ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数帧进行工作。

栈结构指令集优缺点:

  1. 基于栈的指令集主要优点是可移植,寄存器由硬件直接提供(注:Android 平台上的 Dalvik VM 是基于寄存器的虚拟机),程序直接依赖这些集训器则不可避免地要受到硬件的约束。
  2. 代码相对紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)。
  3. 编译器实现更加简单(不需要考虑空间分配问题,所需空间都在栈上)。
  4. 主要缺点是执行速度相对来说会慢。
  5. 虽然栈结构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就会产生较多指令。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对处理器来说,内存始终是执行速度的瓶颈。