深入理解Java虚拟机::虚拟机字节码执行引擎

虚拟机字节码执行引擎

  • 概述
    • 执行引擎是 Java 虚拟机核心的组成部分之一
    • 虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
  • 运行时栈帧结构
    • Java 虚拟机以方法作为最基本的执行单元
    • 栈帧是用于支持虚拟机进行方法调用和方法执行背后的数据结构
    • 虚拟机运行时数据区中的虚拟机栈的栈元素
    • 栈帧
      • 局部变量表、操作数栈、动态连接和方法返回地址等信息
    • 每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程
    • 一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式
    • 局部变量表
      • 局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量
      • 局部变量表的容量以变量槽为最小单位
        • 64 位或者 32 位
      • 每个变量槽都应该能存放一个 boolean、 byte、char、short、int、float、reference 或 returnAddress 类型的数据
      • 局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。
      • 当一个方法被调用时,Java 虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递, 实例方法, 那局部变量表中第 0 位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从 1 开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。
      • 变量槽可以复用,但是会影响垃圾回收
        • 把占用了大量内存但是已经用不到的变量设为 null 有助于 gc 的回收,因为在局部变量表中不再有引用
    • 操作数栈
      • 后入先出栈
    • 动态连接
      • 支持方法调用过程中的动态连接
      • Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数
      • 这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。
      • 另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接
    • 方法返回地址
      • 当一个方法开始执行后,只有两种方式退出这个方法
        • 遇到任意一个方法返回的字节码指令
        • 代码中使用 athrow 字节码指令产生的异常
        • 方法退出的过程实际上等同于把当前栈帧出栈
          • 恢复上层方法的局部变量表和操作数栈
          • 把返回值压入调用者栈帧的操作数栈中
          • 调整 PC 计数器的值以指向方法调用指令后面的一条指令等
  • 方法调用
    • 方法调用阶段唯一的任务就是确定被调用方法的版本,暂时还未涉及方法内部的具体运行过程
    • 某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
    • 解析
      • 方法调用的目标方法在 Class 文件里面都是一个常量池中的符号引用
      • 方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的
      • 在 Java 虚拟机支持以下 5 条方法调用字节码指令,分别是:
        • invokestatic。用于调用静态方法。
        • invokespecial。用于调用实例构造器()方法、私有方法和父类中的方法。
        • invokevirtual。用于调用所有的虚方法。
        • invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。
        • invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面 4 条调用指令,分派逻辑都固化在 Java 虚拟机内部,而 invokedynamic 指令的分派逻辑是由用户设定的引导方法来决定的。
      • final 方法无法被覆盖
    • 分派
      • 静态分派
        • 编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的
        • 静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,这点也是为何一些资料选择把它归入“解析”而不是“分派”的原因。
        • 自动转型按照 char > int > long > float > double 的顺序转型进行匹配
      • 动态分配
        • 重写
        • invokevirtual 指令的运行时解析过程
          • 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作 C。
          • 如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回 java.lang.IllegalAccessError 异常。
          • 否则,按照继承关系从下往上依次对 C 的各个父类进行第二步的搜索和验证过程。
          • 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。
      • 单分派与多分派
        • 如今的 Java 语言是一门静态多分派、动态单分派的语言
      • 虚拟机动态分派的实现
        • 虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。
  • 动态类型语言支持
    • invokedynamic 实现动态类型语言
    • 动态类型语言
      • 动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的
      • 静态类型语言能够在编译期确定变量类型,最显著的好处是编译器可以提供全面严谨的类型检查,这样与数据类型相关的潜在问题就能在编码时被及时发现,利于稳定性及让项目容易达到更大的规模。
      • 动态类型语言在运行期才确定类型,这可以为开发人员提供极大的灵活性,某些在静态类型语言中要花大量臃肿代码来实现的功能,由动态类型语言去做可能会很清晰简洁,清晰简洁通常也就意味着开发效率的提升。
    • Java 与动态类型
      • invokedynamic 指令以及 java.lang.invoke 包提供了 JVM 底层对于动态语言的实现的支持
    • java.lang.invoke 包
      • 方法句柄
      • Java 语言也可以拥有类似于函数指针或者委托的方法别名这样的工具
      • Reflection 和 MethodHandle 机制本质上都是在模拟方法调用,但是 Reflection 是在模拟 Java 代码层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用
    • invokedynamic 指令
      • 支持动态调用方法
  • 基于栈的字节码解释执行引擎
    • 解释执行
      • 在 Java 语言中,Javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以 Java 程序的编译就是半独立的实现。
    • 基于栈的指令集与基于寄存器的指令集
      • Javac 编译器输出的字节码指令流
      • 一种基于栈的指令集架构
      • 基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供
      • 代码相对更加紧凑
      • 编译器实现更加简单
      • 栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢一些
      • 完成相同功能所需的指令数量一般会比寄存器架构来得更多,因为出栈、入栈操作本身就产生了相当大量的指令