JVM 基础 (9) -- 虚拟机字节码执行引擎

1. 简介

执行引擎是 Java 虚拟机最核心的组成部件之一。虚拟机的执行引擎由自己实现,所以可以自行定制指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

所有 Java 虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。本节将主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。

2. 运行时栈帧结构

栈帧 (Stack Frame) 是用于支持虚拟机方法调用和方法执行的数据结构,它是虚拟机运行时数据区中虚拟机栈 (Virtual Machine Stack) 的栈元素

栈帧存储了方法的局部变量表 、操作数栈 、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

栈帧概念结构如下图所示:
在这里插入图片描述

1. 局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。 局部变量表的容量以变量 (Variable Slot) 为最小单位。 一个 Slot 可以存放一个 32 位以内 (boolean、byte、char、short、int、float、reference 和 returnAddress) 的数据类型,reference 类型表示一个对象实例的引用,returnAddress 已经很少见了,它指向了一条字节码指令的地址。
对于 64 位的数据类型 (Java 语言中明确的 64 位数据类型只有 long 和 double),虚拟机会以高位对齐的方式为其分配两个连续的 Slot 空间。

注意:

  1. 虚拟机通过索引定位的方式使用局部变量表,索引值的范围从 0 开始至局部变量表最大的 Slot 数量。访问的是 32 位数据类型的变量,索引 n 就代表了使用第 n 个 Slot,如果是 64 位数据类型,就代表会同时使用第 n 和 n+1 这两个 Slot。
  2. 为了节省栈帧空间,局部变量 Slot 可以重用,方法体中定义的变量,其作用域并不一定会覆盖整个方法体。如果当前字节码 PC (程序计数器) 的值超出了某个变量的作用域,那么这个变量的 Slot 就可以交给其他变量使用。这样的设计会带来一些额外的副作用,比如:在某些情况下,Slot 的复用会直接影响到系统的收集行为。
  3. 局部变量表中第 0 位索引的 Slot 默认 this 关键字的引用。

2. 操作数栈

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

在概念模型中,一个活动线程中两个栈帧是相互独立的。但大多数虚拟机实现都会做一些优化处理:让下一个栈帧的部分操作数栈与上一个栈帧的部分局部变量表重叠在一起,这样的好处是方法调用时可以共享一部分数据,而无须进行额外的参数复制传递。
在这里插入图片描述

3. 动态连接

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

字节码中方法调用指令是以常量池中的指向方法的符号引用为参数的,有一部分符号引用会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为 静态解析,另外一部分在每次的运行期间转化为直接引用,这部分称为动态连接

4. 方法返回地址

当一个方法被执行后,有两种方式退出这个方法:

  1. 第一种是执行引擎遇到任意一个方法返回的字节码指令,这种退出方法的方式称为正常完成出口 (Normal Method Invocation Completion)。
  2. 另外一种是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理 (即本方法异常处理表中没有匹配的异常处理器),就会导致方法退出,这种退出方式称为异常完成出口 (Abrupt Method Invocation Completion)。 注意:这种退出方式不会给上层调用者产生任何返回值。

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

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

虚拟机规范还允许虚拟机实现向栈帧中添加一些自定义的附加信息,例如与调试相关的信息等

3. 方法调用

方法调用阶段的目的:确定被调用方法的版本 (哪一个方法),不涉及方法内部的具体运行过程,在程序运行时,进行方法调用是最普遍 、最频繁的操作。

一切方法调用在 .class 文件里存储的都只是符号引用,这是需要在类加载期间或者是运行期间,才能确定为方法在实际 运行时内存布局中的入口地址 (相当于之前说的直接引用)。

1. 解析

“编译期可知,运行期不可变” 的方法 (静态方法和私有方法),在类加载的解析阶段,会将其符号引用转化为直接引用 (入口地址)。这类方法的调用称为解析 (Resolution)。

在 Java 虚拟机中提供了 5 条方法调用字节码指令:

  1. invokestatic:调用静态方法
  2. invokespecial:调用实例构造器方法 、私有方法 、父类方法
  3. invokevirtual:调用所有的虚方法
  4. invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象
  5. invokedynamic:先在运行时动态解析出点限定符所引用的方法,然后再执行该方法,在此之前的 4 条调用命令的分派逻辑都是固化在 Java 虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

2. 分派

分派调用过程揭示了多态特性的一些最基本的体现,如:重载和重写在 Java 虚拟中是如何实现的。

1. 静态分派

所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派发生在编译阶段。

静态分派最典型的应用就是方法重载:

public class StaticDispatch {
    static abstract class Human {
    }

    static class Man extends Human {
    }

    static class Woman extends Human {
    }

    public void sayhello(Human guy) {
        System.out.println("Human guy");
    }

    public void sayhello(Man guy) {
        System.out.println("Man guy");
    }

    public void sayhello(Woman guy) {
        System.out.println("Woman guy");
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch staticDispatch = new StaticDispatch();
        staticDispatch.sayhello(man);// Human guy
        staticDispatch.sayhello(woman);// Human guy
    }
}

//输出结果
Human guy
Human guy

为什么会出现这样的结果呢?
Human man = new Man();其中的 Human 称为变量的静态类型 (Static Type),Man 称为变量的实际类型 (Actual Type)。

两者的区别是:静态类型在编译器可知,而实际类型到运行期才确定下来。 在重载时是通过参数的静态类型来作为判定依据而不是实际类型,因此,在编译阶段,Javac 编译器会根据参数的静态类型决定使用哪个重载版本。所以选择了 。sayhello(Human human) 作为调用目标,并把这个方法的符号引用写到 main() 方法里的两条 invokevirtual 指令的参数中。

2. 动态分派

在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

最典型的应用就是方法重写:

public class DynamicDisptch {

    static abstract class Human {
        abstract void sayhello();
    }

    static class Man extends Human {
        @Override
        void sayhello() {
            System.out.println("man");
        }
    }

    static class Woman extends Human {
        @Override
        void sayhello() {
            System.out.println("woman");
        }
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayhello();
        woman.sayhello();
        man = new Woman(); //man 是 Human 类型,这句代码相当于 Human man = new Woman();
        man.sayhello();
    }
}

//执行结果
man
woman
woman
3. 单分派和多分派

方法的接收者 、方法的参数都可以称为方法的宗量。根据分批基于多少种宗量,可以将分派划分为单分派和多分派。单分派是根据一个宗量对目标方法进行选择的,多分派是根据多于一个的宗量对目标方法进行选择的。

Java 在进行静态分派时,选择目标方法要依据两点:一是变量的静态类型是哪个类型,二是方法参数是什么类型。因为要根据两个宗量进行选择,所以 Java 语言的静态分派属于多分派类型。

运行时阶段的动态分派过程,由于编译器已经确定了目标方法的签名 (包括方法参数),运行时虚拟机只需要确定方法的接收者的实际类型,就可以分派。因为是根据一个宗量作为选择依据,所以 Java 语言的动态分派属于单分派类型。

注:到 JDK1.7 时,Java 语言还是静态多分派 、动态单分派的语言,未来有可能支持动态多分派

4. 虚拟机动态分派的实现

由于动态分派是非常频繁的动作,而动态分派在方法版本选择过程中又需要在方法元数据中搜索合适的目标方法,虚拟机实现出于性能的考虑,通常不直接进行如此频繁的搜索,而是采用优化方法。

其中一种稳定优化手段就是:在类的方法区中建立一个 虚方法表 (Virtual Method Table, 也称 vtable, 与此对应,也存在接口方法表——Interface Method Table,也称 itable)。使用虚方法表索引来代替元数据查找以提高性能。其原理与 C++ 的虚函数表类似。

虚方法表中存放的是各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类中该方法相同,都指向父类的实现入口。虚方法表一般在类加载的连接阶段进行初始化。

3. 动态类型的语言支持

JDK 新增加了invokedynamic指令来实现动态类型语言

静态语言和动态语言的区别:

  1. 静态语言 (强类型语言): 静态语言是在编译时变量的数据类型即可确定的语言,多数静态类型语言要求在使用变量之前必须声明数据类型。 例如:C++、Java、Delphi、C# 等。
  2. 动态语言 (弱类型语言) : 动态语言是在运行时确定数据类型的语言。变量使用之前不需要类型声明,通常变量的类型是被赋值的那个值的类型。 例如 PHP/ASP/Ruby/Python/Perl/ABAP/SQL/JavaScript/Unix Shell 等等。
  1. 强类型定义语言: 强制数据类型定义的语言。也就是说,一旦一个变量被指定了某个数据类型,如果不经过强制转换,那么它就永远是这个数据类型了。举个例子:如果你定义了一个整型变量 a,那么程序根本不可能将 a 当作字符串类型处理。强类型定义语言是类型安全的语言。
  2. 弱类型定义语言: 数据类型可以被忽略的语言。它与强类型定义语言相反, 一个变量可以赋不同数据类型的值。强类型定义语言在速度上可能略逊色于弱类型定义语言,但是强类型定义语言带来的严谨性能够有效的避免许多错误。

4. 基于栈的字节码解析执行引擎

虚拟机是如何执行方法中的字节码指令的呢?

1. 解释执行

Java 语言经常被人们定位为 解释执行 语言,在 Java 初生的 JDK1.0 时代,这种定义还比较准确的,但当主流的虚拟机中都包含了即时编译后,.class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。再后来,Java 也发展出来了直接生成本地代码的编译器 [如何 GCJ (GNU Compiler for the Java) ],而 C/C++ 也出现了通过解释器执行的版本 [如CINT],这时候再笼统的说 解释执行,对于整个 Java 语言来说就成了几乎没有任何意义的概念,只有确定了谈论对象是某种具体的 Java 实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切
在这里插入图片描述
Java 语言中,javac 编译器完成了程序代码经过词法分析 、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程,因为这一部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机内部,所以 Java 程序的编译是半独立实现的。

1. 谈谈解释执行和编译执行

解释执行:将编译好的字节码一行一行地翻译为机器码执行。
编译执行:以方法为单位,将字节码一次性翻译为机器码后执行。

在编译示时期,我们通过将源代码编译成 .class,配合 JVM 这种跨平台的特点,屏蔽了底层计算机操作系统和硬件的区别,实现了 一次编译,到处运行。 而在运行时期,目前主流的 JVM 都是混合模式(-Xmixed),即 解释运行 和 编译运行 配合使用。

以 Oracle JDK 提供的 HotSpot 虚拟机为例,在 HotSpot 虚拟机中,提供了两种编译模式:解释执行 和 即时编译 (JIT,Just-In-Time)。解释执行即逐条翻译字节码为可运行的机器码,而即时编译则以方法为单位将字节码翻译成机器码再执行。前者的优势在于不用等待,后者则在实际运行当中效率更高。

即时编译存在的意义在于它是提高程序性能的重要手段之一。根据 “二八定律” (即:百分之二十的代码占据百分之八十的系统资源),对于大部分不常用的代码,我们没有必要耗时间将之编译为机器码,而是采用解释执行的方式,用到就去逐条解释运行;对于一些仅占据小部分的热点代码 (可认为是反复执行的重要代码),则可将之翻译为符合机器的机器码高效执行,提高程序的效率,此为运行时的即时编译。

为了满足不同的场景,HotSpot 虚拟机内置了多个即时编译器:C1、C2 与 Graal。Graal 是 Java10 正式引入的实验性即时编译器。看一下 C1、C2 和 Graal:

  1. C1:即 Client 编译器,面向对启动性能有要求的客户端 GUI 程序,采用的优化手段比较简单,因此编译的时间较短。
  2. C2:即 Server 编译器,面向对性能峰值有要求的服务端程序,采用的优化手段复杂,因此编译时间长,但是在运行过程中性能更好。
  3. Graal: 动态的实时 (JIT) 编译器,通过独特的代码分析和优化方法,能够提升应用程序的效率和速度。

从 Java 7 开始,HotSpot 虚拟机默认采用分层编译的方式:热点方法首先被 C1 编译器编译,而后热点方法中的热点再进一步被 C2 编译 (理解为二次编译,根据前面的运行,计算出更优的编译优化)。为了不干扰程序的正常运行,JIT 编译时放在额外的线程中执行的,HotSpot 根据实际 CPU 的资源,以 1:2 的比例分配给 C1 和 C2 线程数。在计算机资源充足的情况,字节码的解释运行和编译运行时可以同时进行,编译执行完后的机器码会在下次调用该方法时启动,以替换原本的解释执行 (意思就是已经翻译出效率更高的机器码,自然替换原来的相对低效率执行的方法)。

以上,可以看出在 Java 中不单单是解释执行,即时编译 (编译执行) 在 Java 性能优化中彰显重要的作用,所以现在应该说:Java 是解释执行和编译执行共同存在的,至少大部分是这样。

2. 基于栈的指令集和基于寄存器的指令集

Java 编译器输出的指令流,基本上是一种 基于栈的指令集架构 (Instruction Set Architecture,ISA),依赖操作数栈进行工作。与之相对应的另一套常用的指令集架构是 基于寄存器的指令集, 依赖寄存器进行工作。

那么,基于栈的指令集和基于寄存器的指令集这两者有什么不同呢?
举个简单例子,分别使用这两种指令计算1+1的结果:

  1. 基于栈的指令集

    iconst_1
    iconst_1
    iadd
    istore_0
    

    两条iconst_1指令连续把两个常量 1 压入栈后,iadd指令把栈顶的两个值出栈 、相加,然后将结果放回栈顶,最后istore_0把栈顶的值放到局部变量表中的第 0 个 Slot 中。

  2. 基于寄存器的指令集

    mov eax, 1
    add eax, 1
    

    mov 指令把 eax 寄存器的值设置为 1,然后 add 指令再把这个值加 1,然后将结果保存在 eax 寄存器里面。

基于栈的指令集主要的优点就是可移植,寄存器是由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。
栈架构的指令集还有一些其他的优点,如代码相对更加紧凑,编译器实现更加简单等。 栈架构指令集的主要缺点是执行速度相对来说会稍微慢一些。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值