飞道的博客

深入理解Java虚拟机-虚拟机执行子系统

239人阅读  评论(0)

本博客主要参考周志明老师的《深入理解Java虚拟机》第二版

读书是一种跟大神的交流。阅读《深入理解Java虚拟机》受益匪浅,对Java虚拟机有初步的认识。这里写博客主要出于以下三个目的:一方面是记录,方便日后阅读;一方面是加深对内容的理解;一方面是分享给大家,希望对大家有帮助。

《深入理解Java虚拟机》全书总结如下:

序号 内容 链接地址
1 深入理解Java虚拟机-走近Java https://blog.csdn.net/ThinkWon/article/details/103804387
2 深入理解Java虚拟机-Java内存区域与内存溢出异常 https://blog.csdn.net/ThinkWon/article/details/103827387
3 深入理解Java虚拟机-垃圾回收器与内存分配策略 https://blog.csdn.net/ThinkWon/article/details/103831676
4 深入理解Java虚拟机-虚拟机执行子系统 https://blog.csdn.net/ThinkWon/article/details/103835168
5 深入理解Java虚拟机-程序编译与代码优化 https://blog.csdn.net/ThinkWon/article/details/103835883
6 深入理解Java虚拟机-高效并发 https://blog.csdn.net/ThinkWon/article/details/103836167

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。

类文件结构

概述

我们所编写的每一行代码,要在机器上运行,最终都需要编译成二进制的机器码 CPU 才能识别。但是由于虚拟机的存在,屏蔽了操作系统与 CPU 指令集的差异性,类似于 Java 这种建立在虚拟机之上的编程语言通常会编译成一种中间格式的文件来存储,比如我们今天要聊的字节码(ByteCode)文件。

无关性的基石

Java 虚拟机的设计者在设计之初就考虑并实现了其它语言在 Java 虚拟机上运行的可能性。所以并不是只有 Java 语言能够跑在 Java 虚拟机上,时至今日诸如 Kotlin、Groovy、Jython、JRuby 等一大批 JVM 语言都能够在 Java 虚拟机上运行。它们和 Java 语言一样都会被编译器编译成字节码文件,然后由虚拟机来执行。所以说类文件(字节码文件)具有语言无关性。

Class类文件的结构

Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据严格按照顺序紧凑的排列在 Class 文件中,中间无任何分隔符,这使得整个 Class 文件中存储的内容几乎全部都是程序运行的必要数据,没有空隙存在。当遇到需要占用 8 位字节以上空间的数据项时,会按照高位在前的方式分割成若干个 8 位字节进行存储。

Java 虚拟机规范规定 Class 文件格式采用一种类似与 C 语言结构体的伪结构体来存储数据,这种伪结构体中只有两种数据类型:无符号数和表。

  • 无符号数属于基本的数据类型,以 u1、u2、u4、u8来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码结构构成的字符串值。
  • 是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以「_info」结尾。表用于描述有层次关系的复合结构的数据,整个 Class 文件就是一张表,它由下表中所示的数据项构成。
类型 名称 数量
u4 magic 1
u2 minor_version 1
u2 major_version 1
u2 constant_pool_count 1
cp_info constant_pool constant_pool_count-1
u2 access_flags 1
u2 this_class 1
u2 super_class 1
u2 interfaces_count 1
u2 interfaces interfaces_count
u2 fields_count 1
field_info fields fields_count
u2 methods_count 1
method_info methods methods_count
u2 attributes_count 1
attribute_info attributes attributes_count

Class 文件中存储的字节严格按照上表中的顺序紧凑的排列在一起。哪个字节代表什么含义,长度是多少,先后顺序如何都是被严格限制的,不允许有任何改变。

魔数与 Class 文件的版本

每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 Calss 文件。之所以使用魔数而不是文件后缀名来进行识别主要是基于安全性的考虑,因为文件后缀名是可以随意更改的。Class 文件的魔数值为「0xCAFEBABE」。

紧接着魔数的 4 个字节存储的是 Class 文件的版本号:第 5 和第 6 两个字节是次版本号(Minor Version),第 7 和第 8 个字节是主版本号(Major Version)。高版本的 JDK 能够向下兼容低版本的 Class 文件,虚拟机会拒绝执行超过其版本号的 Class 文件。

常量池

主版本号之后是常量池入口,常量池可以理解为 Class 文件之中的资源仓库,它是 Class 文件结构中与其他项目关联最多的数据类型,也是占用 Class 文件空间最大的数据项目之一,同是它还是 Class 文件中第一个出现的表类型数据项目。

因为常量池中常量的数量是不固定的,所以在常量池入口需要放置一个 u2 类型的数据来表示常量池的容量「constant_pool_count」,和计算机科学中计数的方法不一样,这个容量是从 1 开始而不是从 0 开始计数。之所以将第 0 项常量空出来是为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达「不引用任何一个常量池项目」的含义,这种情况可以把索引值置为 0 来表示。

Class 文件结构中只有常量池的容量计数是从 1 开始的,其它集合类型,包括接口索引集合、字段表集合、方法表集合等容量计数都是从 0 开始。

常量池中主要存放两大类常量:字面量符号引用

  • 字面量比较接近 Java 语言层面的常量概念,如字符串、声明为 final 的常量值等。
  • 符号引用属于编译原理方面的概念,包括了以下三类常量:
    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符

访问标志

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

标志名称 标志值 含义
ACC_PUBLIC 0x0001 是否为 public 类型
ACC_FINAL 0x0010 是否被声明为 final,只有类可设置
ACC_SUPER 0x0020 是否允许使用 invokespecial 字节码指令的新语意,invokespecial 指令的语意在 JKD 1.0.2 中发生过改变,微聊区别这条指令使用哪种语意,JDK 1.0.2 编译出来的类的这个标志都必须为真
ACC_INTERFACE 0x0200 标识这是一个接口
ACC_ABSTRACT 0x0400 是否为 abstract 类型,对于接口或者抽象类来说,此标志值为真,其它类值为假
ACC_SYNTHETIC 0x1000 标识这个类并非由用户代码产生
ACC_ANNOTATION 0x2000 标识这是一个注解
ACC_ENUM 0x4000 标识这是一个枚举

access_flags 中一共有 16 个标志位可以使用,当前只定义了其中的 8 个,没有使用到的标志位要求一律为 0。

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

类索引(this_class)和父类索引(super_class)都是一个 u2 类型的数据,而接口索引集合(interfaces)是一组 u2 类型的数据集合,Class 文件中由这三项数据来确定这个类的继承关系。

  • 类索引用于确定这个类的全限定名
  • 父类索引用于确定这个类的父类的全限定名
  • 接口索引集合用于描述这个类实现了哪些接口

字段表集合

字段表集合(field_info)用于描述接口或者类中声明的变量。字段(field)包括类变量和实例变量,但不包括方法内部声明的局部变量。下面我们看看字段表的结构:

类型 名称 数量
u2 access_flag 1
u2 name_index 1
u2 descriptor_index 1
u2 attributes_count 1
attribute_info attributes attributes_count

字段修饰符放在 access_flags 中,它与类中的 access_flag 非常相似,都是一个 u2 的数据类型。

标志名称 标志值 含义
ACC_PUBLIC 0x0001 字段是否为 public
ACC_PRIVATE 0x0002 字段是否为 private
ACC_PROTECTED 0x0004 字段是否为 protected
ACC_STATIC 0x0008 字段是否为 static
ACC_FINAL 0x0010 字段是否为 final
ACC_VOLATILE 0x0040 字段是否为 volatile
ACC_TRANSIENT 0x0080 字段是否为 transient
ACC_SYNTHETIC 0x1000 字段是否由编译器自动生成
ACC_ENUM 0x4000 字段是否为 enum

方法表集合

Class 文件中对方法的描述和对字段的描述是完全一致的,方法表中的结构和字段表的结构一样。

因为 volatile 关键字和 transient 关键字不能修饰方法,所以方法表的访问标志中没有 ACC_VOLATILE 和 ACC_TRANSIENT。与之相对的,synchronizes、native、strictfp 和 abstract 关键字可以修饰方法,所以方法表的访问标志中增加了 ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP 和 ACC_ABSTRACT 标志。

对于方法里的代码,经过编译器编译成字节码指令后,存放在方法属性表中一个名为「Code」的属性里面。

属性表集合

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

属性表集合不像 Class 文件中的其它数据项要求这么严格,不强制要求各属性表的顺序,并且只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java 虚拟机在运行时会略掉它不认识的属性。

字节码指令简介

感兴趣的小伙伴可以自行阅读《深入理解Java虚拟机》

公有设计和私有实现

感兴趣的小伙伴可以自行阅读《深入理解Java虚拟机》

Class文件结构的发展

感兴趣的小伙伴可以自行阅读《深入理解Java虚拟机》

虚拟机类加载机制

概述

我们的源代码经过编译器编译成字节码之后,最终都需要加载到虚拟机之后才能运行。虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制

与编译时需要进行连接工作的语言不同,Java 语言中类的加载、连接和初始化都是在程序运行期间完成的,这种策略虽然会让类加载时增加一些性能开销,但是会为 Java 应用程序提供高度的灵活性,Java 里天生可动态扩展的语言特性就是依赖运行期间动态加载和动态连接的特点实现的。

例如,一个面向接口的应用程序,可以等到运行时再指定实际的实现类;用户可以通过 Java 预定义的和自定义的类加载器,让一个本地的应用程序运行从网络上或其它地方加载一个二进制流作为程序代码的一部分。

类加载时机

类从被虚拟机从加载到卸载,整个生命周期包含:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)7 个阶段。其中验证、准备、解析 3 个部分统称为连接(Linking)。这 7 个阶段的发生顺序如下图:

上图中加载、验证、准备、初始化和卸载 5 个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班的开始「注意,这里说的是按部就班的开始,并不要求前一阶段执行完才能进入下一阶段」,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定。

虚拟机规范中对于什么时候开始类加载过程的第一节点「加载」并没有强制约束。但是对于「初始化」阶段,虚拟机则是严格规定了有且只有以下 5 种情况,如果类没有进行初始化,则必须立即对类进行「初始化」(加载、验证、准备自然需要在此之前开始):

  1. 遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令;
  2. 使用 java.lang.reflect 包的方法对类进行反射调用的时候;
  3. 当初始化一个类的时候,发现其父类还没有进行初始化的时候,需要先触发其父类的初始化;
  4. 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个类;
  5. 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有初始化。

「有且只有」以上 5 种场景会触发类的初始化,这 5 种场景中的行为称为对一个类的主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。比如如下几种场景就是被动引用:

  1. 通过子类引用父类的静态字段,不会导致子类的初始化;
  2. 通过数组定义来引用类,不会触发此类的初始化;
  3. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化;

类加载过程

加载

这里的「加载」是指「类加载」过程的一个阶段。在加载阶段,虚拟机需要完成以下 3 件事:

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

验证

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

  1. 文件格式验证:第一阶段要验证字节流是否符合 Class 文件格式的规范,并且能够被当前版本的虚拟机处理。验证点主要包括:是否以魔数 0xCAFEBABE 开头;主、次版本号是否在当前虚拟机处理范围之内;常量池的常量中是否有不被支持的常量类型;Class 文件中各个部分及文件本身是否有被删除的或者附加的其它信息等等。
  2. 元数据验证:第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求,这个阶段的验证点包括:这个类是否有父类;这个类的父类是否继承了不允许被继承的类;如果这个类不是抽象类,是否实现了其父类或者接口之中要求实现的所有方法;类中的字段、方法是否与父类产生矛盾等等。
  3. 字节码验证:第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
  4. 符号引用验证:最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段–解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的形象进行匹配性校验。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区进行分配。这个阶段中有两个容易产生混淆的概念需要强调下:

  • 首先,这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中;
  • 其次这里所说的初始值「通常情况」下是数据类型的零值。假设一个类变量的定义为public static int value = 123; 那么变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这个时候尚未执行任何 Java 方法,而把 value 赋值为 123 的 putstatic 指令是程序被编译之后,存放于类构造器 () 方法之中,所以把 value 赋值为 123 的动作将在初始化阶段才会执行。

这里提到,在「通常情况」下初始值是零值,那相对的会有一些「特殊情况」:如果类字段的字段属性表中存在 ConstantsValue 属性,那在准备阶段变量 value 就会被初始化为 ConstantValue 属性所指的值。假设上面的类变量 value 的定义变为 public static final int value = 123;,编译时 JavaC 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 123。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。前面提到过很多次符号引用和直接引用,那么到底什么是符号引用和直接引用呢?

  • 符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号可以上任何形式的字面量,只要使用时能无歧义地定位到目标即可。
  • 直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

初始化

类初始化阶段是类加载过程中的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全是由虚拟机主导和控制的。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。初始阶段是执行类构造器 () 方法的过程。

类加载器

虚拟机设计团队把类加载阶段中的「通过一个类的全限定名来获取描述此类的二进制字节流」这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为「类加载器」。

类加载器:类加载器负责加载程序中的类型(类和接口),并赋予唯一的名字予以标识。

类与类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机的唯一性,每个类加载器都拥有一个独立的类名称空间。也就是说:比较两个类是否「相等」,只要在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

双亲委派模型

从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 来实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由 Java 来实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader

从 Java 开发者的角度来看,类加载器可以划分为:

  • 启动类加载器(Bootstrap ClassLoader):这个类加载器负责将存放在 <JAVA_HOME>\lib 目录中的类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,纳智捷使用 null 代替即可;
  • 扩展类加载器(Extension ClassLoader):这个类加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载 <JAVA_HOME>\lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器;
  • 应用程序类加载器(Application ClassLoader):这个类加载器由 sun.misc.Launcher$App-ClassLoader 实现。getSystemClassLoader() 方法返回的就是这个类加载器,因此也被称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库。开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

我们的应用程序都是由这 3 种类加载器互相配合进行加载的,在必要时还可以自己定义类加载器。它们的关系如下图所示:

上图中所呈现出的这种层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。双亲委派模型要求除了顶层的启动类加载器以外,其余的类加载器都应当有自己的父类加载器。

类加载器的关系
  1. Bootstrap Classloader 是在Java虚拟机启动后初始化的。
  2. Bootstrap Classloader 负责加载 ExtClassLoader,并且将 ExtClassLoader的父加载器设置为 Bootstrap Classloader
  3. Bootstrap Classloader 加载完 ExtClassLoader 后,就会加载 AppClassLoader,并且将 AppClassLoader 的父加载器指定为 ExtClassLoader
类加载器的作用
Class Loader 实现方式 具体实现类 负责加载的目标
Bootstrap Loader C++ 由C++实现 %JAVA_HOME%/jre/lib/rt.jar以及-Xbootclasspath参数指定的路径以及中的类库
Extension ClassLoader Java sun.misc.Launcher$ExtClassLoader %JAVA_HOME%/jre/lib/ext路径下以及java.ext.dirs系统变量指定的路径中类库
Application ClassLoader Java sun.misc.Launcher$AppClassLoader Classpath以及-classpath-cp指定目录所指定的位置的类或者是jar文档,它也是Java程序默认的类加载器
类加载器的特点
  • 层级结构:Java里的类装载器被组织成了有父子关系的层级结构。Bootstrap类装载器是所有装载器的父亲。
  • 代理模式: 基于层级结构,类的代理可以在装载器之间进行代理。当装载器装载一个类时,首先会检查它在父装载器中是否进行了装载。如果上层装载器已经装载了这个类,这个类会被直接使用。反之,类装载器会请求装载这个类
  • 可见性限制:一个子装载器可以查找父装载器中的类,但是一个父装载器不能查找子装载器里的类。
  • 不允许卸载:类装载器可以装载一个类但是不可以卸载它,不过可以删除当前的类装载器,然后创建一个新的类装载器装载。
类加载器的隔离问题

每个类装载器都有一个自己的命名空间用来保存已装载的类。当一个类装载器装载一个类时,它会通过保存在命名空间里的类全局限定名(Fully Qualified Class Name) 进行搜索来检测这个类是否已经被加载了。

JVMDalvik 对类唯一的识别是 ClassLoader id + PackageName + ClassName,所以一个运行程序中是有可能存在两个包名类名完全一致的类的。并且如果这两个不是由一个 ClassLoader 加载,是无法将一个类的实例强转为另外一个类的,这就是 ClassLoader 隔离性。

为了解决类加载器的隔离问题JVM引入了双亲委托机制

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

这样做的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如 java.lang.Object,它放在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型顶端的启动类加载器来加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,Java 类型体系中最基本的行为也就无法保证了。

双亲委派模型对于保证 Java 程序运行的稳定性很重要,但它的实现很简单,实现双亲委派模型的代码都集中在 java.lang.ClassLoader 的 loadClass() 方法中,逻辑很清晰:先检查是否已经被加载过,若没有则调用父类加载器的 loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出 ClassNotFoundException 异常后,再调用自己的 findClass() 方法进行加载。

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException {
    // 首先,检查请求的类是不是已经被加载过
    Class<?> c = findLoadedClass(name);
    if (c == null) {
        try {
            if (parent != null) {
                c = parent.loadClass(name, false);
            } else {
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) {
            // 如果父类抛出 ClassNotFoundException 说明父类加载器无法完成加载
        }

        if (c == null) {
            // 如果父类加载器无法加载,则调用自己的 findClass 方法来进行类加载
            c = findClass(name);
        }
    }
    if (resolve) {
        resolveClass(c);
    }
    return c;
}

关于类文件结构和类加载就通过连续的两篇文章介绍到这里了,下一篇我们来聊聊「虚拟机的字节码执行引擎」。

破坏双亲委派模型

感兴趣的小伙伴可以自行阅读《深入理解Java虚拟机》

字节码执行引擎

概述

执行引擎是 Java 虚拟机最核心的组成部分之一。「虚拟机」是相对于「物理机」的概念,这两种机器都有代码执行的能力,区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机执行引擎是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

在 Java 虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观(Facade)。在不同的虚拟机实现里,执行引擎在执行 Java 代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种方式,也可能两者都有,甚至还可能会包含几个不同级别的编译器执行引擎。但从外观上来看,所有 Java 虚拟机的执行引擎是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

运行时栈帧结构

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

每一个栈帧都包括了局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。在编译程序代码时,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的 Code 属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

一个线程中的方法调用链可能会很长,很多方法都处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法成为当前方法。执行引擎运行的所有字节码指令对当前栈帧进行操作,在概念模型上,典型的栈帧结构如下图:

局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在 Java 程序中编译为 Class 文件时,就在方法的 Code 属性的 max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量。

操作数栈

操作数栈(Operand Stack)是一个后进先出栈。同局部变量表一样,操作数栈的最大深度也在编译阶段写入到 Code 属性的 max_stacks 数据项中。操作数栈的每一个元素可以是任意的 Java 数据类型,包括 long 和 double。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。在方法执行的任何时候,操作数栈的深度都不会超过 max_stacks 数据项中设定的最大值。

一个方法刚开始执行的时候,该方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈和出栈操作。

动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。Class 文件的常量池中存在大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数,这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化成为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法。

一种是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层方法的调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口

另一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是 Java 虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。这种称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给上层调用者产生任何返回值的。

无论采用何种退出方式,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上次方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。

附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,成为栈帧信息。

方法调用

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

在程序运行时,进行方法调用是最为普遍、频繁的操作。前面说过 Class 文件的编译过程是不包含传统编译中的连接步骤的,一切方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在运行时内存布局中的入口地址(相当于之前说的直接引用)。这个特性给 Java 带来了更强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

解析

所有方法调用中的目标方法在 Class 文件里都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,这种解析能成立的前提是方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。话句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

Java 语言中符合「编译器可知,运行期不可变」这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或者别的方式重写其它版本,因此它们都适合在类加载阶段解析。

与之相应的是,在 Java 虚拟机里提供了 5 条方法调用字节码指令,分别是:

  • invokestatic:调用静态方法;
  • invokespecial:调用实例构造器 方法、私有方法和父类方法;
  • invokevirtual:调用所有虚方法;
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象;
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。

只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法 4 类,它们在加载的时候就会把符号引用解析为直接引用。这些方法可以称为非虚方法,与之相反,其它方法称为虚方法(final 方法除外)。

Java 中的非虚方法除了使用 invokestatic、invokespecial 调用的方法之外还有一种,就是被 final 修饰的方法。虽然 final 方法是使用 invokevirtual 指令来调用的,但是由于它无法被覆盖,没有其它版本,所以也无需对方法接受者进行多态选择,又或者说多态选择的结果肯定是唯一的。在 Java 语言规范中明确说明了 final 方法是一种非虚方法。

解析调用一定是个静态过程,在编译期间就能完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派 4 种分派组合情况,下面我们再看看虚拟机中的方法分派是如何进行的。

分派

面向对象有三个基本特征,封装、继承和多态。这里要说的分派将会揭示多态特征的一些最基本的体现,如「重载」和「重写」在 Java 虚拟机中是如何实现的?虚拟机是如何确定正确目标方法的?

静态分派

在开始介绍静态分派前我们先看一段代码。

/**
 * 方法静态分派演示
 */
public class StaticDispatch {

    private static abstract class Human { }

    private static class Man extends Human { }

    private static class Woman extends Human { }

    private void sayHello(Human guy) {
        System.out.println("Hello, guy!");
    }

    private void sayHello(Man man) {
        System.out.println("Hello, man!");
    }

    private void sayHello(Woman woman) {
        System.out.println("Hello, woman!");
    }

    public static void main(String[] args) {

        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch dispatch = new StaticDispatch();
        dispatch.sayHello(man);
        dispatch.sayHello(woman);
    }
}

运行后这段程序的输出结果如下:

Hello, guy!
Hello, guy!

稍有经验的 Java 程序员都能得出上述结论,但为什么我们传递给 sayHello() 方法的实际参数类型是 Man 和 Woman,虚拟机在执行程序时选择的却是 Human 的重载呢?要理解这个问题,我们先弄清两个概念。

Human man = new Man();

上面这段代码中的「Human」称为变量的静态类型(Static Type),或者叫做外观类型(Apparent Type),后面的「Man」称为变量为实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅发生在使用时,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

弄清了这两个概念,再来看 StaticDispatch 类中 main() 方法里的两次 sayHello() 调用,在方法接受者已经确定是对象「dispatch」的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中定义了两个静态类型相同但是实际类型不同的变量,但是虚拟机(准确的说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此在编译阶段, Javac 编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello(Human) 作为调用目标,并把这个方法的符号引用写到 man() 方法里的两条 invokevirtual 指令的参数中。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

另外,编译器虽然能确定方法的重载版本,但是很多情况下这个重载版本并不是「唯一」的,因此往往只能确定一个「更加合适」的版本。产生这种情况的主要原因是字面量不需要定义,所以字面量没有显示的静态类型,它的静态类型只能通过语言上的规则去理解和推断。下面的代码展示了什么叫「更加合适」的版本。

public class Overlaod {

    static void sayHello(Object arg) {
        System.out.println("Hello, Object!");
    }

    static void sayHello(int arg) {
        System.out.println("Hello, int!");
    }

    static void sayHello(long arg) {
        System.out.println("Hello, long!");
    }

    static void sayHello(Character arg) {
        System.out.println("Hello, Character!");
    }

    static void sayHello(char arg) {
        System.out.println("Hello, char!");
    }

    static void sayHello(char... arg) {
        System.out.println("Hello, char...!");
    }

    static void sayHello(Serializable arg) {
        System.out.println("Hello, Serializable!");
    }

    public static void main(String[] args) {
        sayHello('a');
    }
}

上面代码的运行结果为:

Hello, char!

这很好理解,‘a’ 是一个 char 类型的数据,自然会寻找参数类型为 char 的重载方法,如果注释掉 sayHello(chat arg) 方法,那么输出结果将会变为:

Hello, int!

这时发生了一次类型转换, ‘a’ 除了可以代表一个字符,还可以代表数字 97,因为字符 ‘a’ 的 Unicode 数值为十进制数字 97,因此参数类型为 int 的重载方法也是合适的。我们继续注释掉 sayHello(int arg) 方法,输出变为:

Hello, long!

这时发生了两次类型转换,‘a’ 转型为整数 97 之后,进一步转型为长整型 97L,匹配了参数类型为 long 的重载方法。我们继续注释掉 sayHello(long arg) 方法,输出变为:

Hello, Character!

这时发生了一次自动装箱, ‘a’ 被包装为它的封装类型 java.lang.Character,所以匹配到了类型为 Character 的重载方法,继续注释掉 sayHello(Character arg) 方法,输出变为:

Hello, Serializable!

这里输出之所以为「Hello, Serializable!」,是因为 java.lang.Serializable 是 java.lang.Character 类实现的一个接口,当自动装箱后发现还是找不到装箱类,但是找到了装箱类实现了的接口类型,所以紧接着又发生了一次自动转换。char 可以转型为 int,但是 Character 是绝对不会转型为 Integer 的,他只能安全的转型为它实现的接口或父类。Character 还实现了另外一个接口 java.lang.Comparable,如果同时出现两个参数分别为 Serializable 和 Comparable 的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。程序必须在调用时显示的指定字面量的静态类型,如:sayHello((Comparable) ‘a’),才能编译通过。继续注释掉 sayHello(Serializable arg) 方法,输出变为:

Hello, Object!

这时是 char 装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低。即使方法调用的入参值为 null,这个规则依然适用。继续注释掉 sayHello(Serializable arg) 方法,输出变为:

Hello, char...!

7 个重载方法以及被注释得只剩一个了,可见变长参数的重载优先级是最低的,这时字符 ‘a’ 被当成了一个数组元素。

前面介绍的这一系列过程演示了编译期间选择静态分派目标的过程,这个过程也是 Java 语言实现方法重载的本质。

动态分派

动态分派和多态性的另一个重要体现「重写(Override)」有着密切的关联,我们依旧通过代码来理解什么是动态分派。

/**
 * 方法动态分派演示
 */
public class DynamicDispatch {

    static abstract class Human {

        abstract void sayHello();
    }

    static class Man extends Human {

        @Override
        void sayHello() {
            System.out.println("Man say hello!");
        }
    }

    static class Woman extends Human {
        @Override
        void sayHello() {
            System.out.println("Woman say hello!");
        }
    }

    public static void main(String[] args){

        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();

        man = new Woman();
        man.sayHello();
    }
}

代码执行结果:

Man say hello!
Woman say hello!
Woman say hello!

对于上面的代码,虚拟机是如何确定要调用哪个方法的呢?显然这里不再通过静态类型来决定了,因为静态类型同样都是 Human 的两个变量 man 和 woman 在调用 sayHello() 方法时执行了不同的行为,并且变量 man 在两次调用中执行了不同的方法。导致这个结果的原因是因为它们的实际类型不同。对于虚拟机是如何通过实际类型来分派方法执行版本的,这里我们就不做介绍了,有兴趣的可以去看看原著。

我们把这种在运行期根据实际类型来确定方法执行版本的分派称为动态分派

单分派和多分派

方法的接收者和方法的参数统称为方法的宗量,这个定义最早来源于《Java 与模式》一书。根据分派基于多少宗量,可将分派划分为单分派多分派

单分派是根据一个宗量来确定方法的执行版本;多分派则是根据多余一个宗量来确定方法的执行版本。

我们依旧通过代码来理解(代码以著名的 3Q 大战作为背景):

/**
 * 单分派、多分派演示
 */
public class Dispatch {

    static class QQ { }

    static class QiHu360 { }

    static class Father {

        public void hardChoice(QQ qq) {
            System.out.println("Father choice QQ!");
        }

        public void hardChoice(QiHu360 qiHu360) {
            System.out.println("Father choice 360!");
        }
    }

    static class Son extends Father {

        @Override
        public void hardChoice(QQ qq) {
            System.out.println("Son choice QQ!");
        }

        @Override
        public void hardChoice(QiHu360 qiHu360) {
            System.out.println("Son choice 360!");
        }
    }

    public static void main(String[] args) {

        Father father = new Father();
        Father son = new Son();

        father.hardChoice(new QQ());
        son.hardChoice(new QiHu360());
    }
}

代码输出结果:

Father choice QQ!
Son choice 360!

我们先来看看编译阶段编译器的选择过程,也就是静态分派过程。这个时候选择目标方法的依据有两点:一是静态类型是 Father 还是 Son;二是方法入参是 QQ 还是 QiHu360。因为是根据两个宗量进行选择的,所以 Java 语言的静态分派属于多分派

再看看运行阶段虚拟机的选择过程,也就是动态分派的过程。在执行 son.hardChoice(new QiHu360()) 时,由于编译期已经确定目标方法的签名必须为 hardChoice(QiHu360),这时参数的静态类型、实际类型都不会对方法的选择造成任何影响,唯一可以影响虚拟机选择的因数只有此方法的接收者的实际类型是 Father 还是 Son。因为只有一个宗量作为选择依据,所以 Java 语言的动态分派属于单分派。

综上所述,Java 语言是一门静态多分派、动态单分派的语言。

动态类型语言支持

感兴趣的小伙伴可以自行阅读《深入理解Java虚拟机》

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

虚拟机如何调用方法已经介绍完了,下面我们来看看虚拟机是如何执行方法中的字节码指令的。

解释执行

Java 语言常被人们定义成「解释执行」的语言,但随着 JIT 以及可直接将 Java 代码编译成本地代码的编译器的出现,这种说法就不对了。只有确定了谈论对象是某种具体的 Java 实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。

无论是解释执行还是编译执行,无论是物理机还是虚拟机,对于应用程序,机器都不可能像人一样阅读、理解,然后获得执行能力。大部分的程序代码到物理机的目标代码或者虚拟机执行的指令之前,都需要经过下图中的各个步骤。下图中最下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程;中间那条分支,则是解释执行的过程。

如今,基于物理机、Java 虚拟机或者非 Java 的其它高级语言虚拟机的语言,大多都会遵循这种基于现代编译原理的思路,在执行前先对程序源代码进行词法分析和语法分析处理,把源代码转化为抽象语法树。对于一门具体语言的实现来说,词法分析、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是 C/C++。也可以为一个半独立的编译器,这类代表是 Java。又或者把这些步骤和执行全部封装在一个封闭的黑匣子中,如大多数的 JavaScript 执行器。

Java 语言中,Javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树、再遍历语法树生成字节码指令流的过程。因为这一部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以 Java 程序的编译就是半独立的实现。

许多 Java 虚拟机的执行引擎在执行 Java 代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。而对于最新的 Android 版本的执行模式则是 AOT + JIT + 解释执行,关于这方面我们后面有机会再聊。

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

Java 编译器输出的指令流,基本上是一种基于栈的指令集架构。基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免的要受到硬件约束。栈架构的指令集还有一些其他优点,比如相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译实现更加简单(不需要考虑空间分配的问题,所有空间都是在栈上操作)等。

栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。

虽然栈架构指令集的代码非常紧凑,但是完成相同功能需要的指令集数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存中,频繁的栈访问也意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。

正是基于上述原因,Android 虚拟机中采用了基于寄存器的指令集架构。不过有一点不同的是,前面说的是物理机上的寄存器,而 Android 上指的是虚拟机上的寄存器。

基于栈的解释器执行过程

感兴趣的小伙伴可以自行阅读《深入理解Java虚拟机》

类加载及执行子系统的案例与实战

感兴趣的小伙伴可以自行阅读《深入理解Java虚拟机》


转载:https://blog.csdn.net/ThinkWon/article/details/103835168
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场