深入解析“Java 字节码 ” 之 「虚拟机机字节码执行引擎」

Posted by tomas家的小拨浪鼓 on November 3, 2019

深入解析“Java 字节码 ” 之 「虚拟机字节码执行引擎」

本文主要是《深入探索 JVM》系列中『字节码篇』文章,主要系统的介绍了「虚拟机字节码执行引擎」。系列文章目录见:《 深入探索 JVM 》文集


『字节码』篇文章推荐:
深入解析“Java 字节码 ” 之 「类文件结构」
深入解析“Java 字节码 ” 之 「从案例深度解读 Java 字节码」
深入解析“Java 字节码 ” 之 「进一步探究 Java 方法的字节码实现」
深入解析“Java 字节码 ” 之 「从案例解读虚拟机属性表」
深入解析“Java 字节码 ” 之 「虚拟机字节码执行引擎」
深入解析“Java 字节码 ” 之 「动态代理的实现」


一,前言

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

在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有
① 解释执行(通过解释器执行)和
② 编译执行(通过即时编译器产生本地代码执行)
两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。但从外观上看起来,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。


二,运行时栈帧结构

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

栈帧存储了:
a)方法的局部变量表、
b)操作数栈、
c)动态连接、
d)方法返回地址、
e)一些额外的附加信息
f)等信息。

每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。当一个方法调用了另外一方法的时候,实际上就是往栈中压入了一个新的栈帧。

在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

栈帧归属于一个一个的线程的,每一个线程只会拥有自己独有的一份栈帧结构。因此对于栈帧来说,是不存在所谓的并发调用的情况的。它在某一时刻只会归属于特定的一个线程。因此,不存在并发和同步的概念。

2.1 局部变量表

关于「局部变量表」前文已经详细的介绍过,这里就不赘述了。


2.2 操作数栈

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

在调用其他方法的时候是通过操作数栈来进行参数传递的。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。

Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。


2.3 动态连接

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

  • 符号引用 与 直接引用
    “符号引用” vs “直接引用”
    符号引用(Symbolic References)
            符号引用则属于编译原理方面的概念,包括了下面三类常量:
            a)类和接口的全限定名(Fully Qualified Name)
            b)字段的名称和描述符(Descriptor)
            c)方法的名称和描述符

Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

“符号引用”存放在类中的常量池中。方法调用其实就是通过常量池来指向方法的符号引用,将其作为参数。

  • “符号引用”转换成“直接引用”的时机: ① 静态的解析:类加载的阶段或第一次使用的时候,将“符号引用”转换为“直接引用”
    ② 动态的链接:每次运行的时候,将“符号引用”转换为“直接引用”。这体现为 Java 的多态性。
Animal a = new Cat();
a.sleep();
a = new Dog();
a.sleep();
a = new Tiger();
a.sleep();

👆这就是“动态的链接”。
执行到这些操作的时候,才能动态的识别出 a 是哪个对象,应该调用哪个对象的 sleep 方法。
而在程序编译的时候,从字节码的角度来看,看到的 a 都是调用的 Animal 的 sleep,而不是调用 Cat 的sleep,也不是调用 Dog、Tiger 的 sleep。
这里“看到”的意思是,字节码直接’翻译’的对象。而实际上,字节码会调用「invokevirtual」指令来实现方法的调用。
「invokevirtual」本身是一个动态派发的指令,虽然看到调用的是一个 Animal 的 sleep 方法,但是对于 invokevirtual 来说它会检测 a 在此刻 真正指向的那个对象是什么。从而由将对 Animal 的 sleep 的调用转换成对真正指向的那个对象特定的 sleep 的调用。但是从程序静态的概念,或者是角度来看,我们看到的字节码所写的信息就 👆3 个 sleep 的调用都是调用的 Animal 的 sleep 方法。


2.4 方法返回地址

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

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

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

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


2.5 附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。

在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。


三,方法调用

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

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

  • 在Java虚拟机里面提供了5条方法调用字节码指令,分别如下
    • invokestatic:调用静态方法;
    • invokespecial:调用实例构造器<init>方法、私有方法和父类方法(可以是父类的实例方法、也可以是父类的构造方法)。
    • invokevirtual:调用虚方法。
    • invokeinterface:(调用接口方法。从 JDK 1.8 开始,接口里可以有默认方法了。)调用接口中的方法,实际上就是在运行期决定的,决定到底调用实现该接口的哪个对象的特定方法。(因此这是 invokeinterface 是一个动态的过程,因为它是在运行期决定的。)
    • invokedynamic:动态调用方法(从 JDK 1.7 开始引入的)。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。


3.1 静态解析

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用。
这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。

  • 非虚方法
    在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法

非虚方法包括:
① invokestatic 调用的所有方法 ———— 静态方法
② invokespecial 调用的所有方法 ———— 私有方法、实例构造器、父类方法
③ invokevirtual 调用的 final 方法
虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。在Java语言规范中明确说明了final方法是一种非虚方法。


3.2 分派

public class MyTest5 {
    // 对于 JVM 来说,方法重载,是一种静态的行为,编译期就可以完全确定。这种所谓的静态行为就是说,在执行被重载的方法的时候,JVM 判断的依据是方法本身接受的参数的静态类型来去决定调用哪个版本的方法。
    public void test(Grandpa grandpa) {
        System.out.println("grandpa");
    }
    public void test(Father father) {
        System.out.println("father");
    }
    public void test(Son son) {
        System.out.println("son");
    }
    public static void main(String[] args) {
        Grandpa g1 = new Father();
        Grandpa g2 = new Son();
        MyTest5 myTest5 = new MyTest5();
        myTest5.test(g1);
        myTest5.test(g2);
    }

}
class Grandpa {}
class Father extends Grandpa {}
class Son extends Father {}

「Grandpa g1 = new Father();」:【g1 的声明类型为 Grandpa,声明类型也就是静态类型。因此,g1 的静态类型是 Grandpa;而 g1 实际指向的类型是 Father对象,因此 Father 就是 g1 所指向的实际类型。】
以上代码,g1 的静态类型是 Grandpa,而 g1 的实际类型(真正指向的类型)是 Father。

变量本身的静态类型,是不会被改变的(即便你对其做了强制的向下类型转换,g1 本身的静态类型依旧也是不会改变的)。而 g1 的实际类型是有可能随着代码的编写而发送变化的。比如:
Grandpa g1 = new Father();
g1 = new Son();
👆 g1 的动态类型就由 Father 变为了 Son 类型。

我们可以得出这样的一个结论:变量的静态类型是不会发生变化的,而变量的实际类型则是可以发生变化的(多态的一种体现),实际类型是在运行期方可确定。


1, 静态分派

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

虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。


👆两个 myTest5.test(…) 对应的字节码是「invokevirtual」,是因为,test 是 MyTest5 的共有方法,正如上面所说的,只有私有方法能是“静态解析”,因为共有方法可能存在被重写的可能,那么也就可能存在多态的可能。

我们可以将 test 方法改为 private 的来证实这一点:

“方法重载”本身是一种静态的概念,而“方法重写”是一种动态的概念。

  • Q:“静态解析”与“静态分派”之间有什么关系吗???
    A:“静态解析”与“静态分派”之前是没有关系的,不要因为名字的相似而混淆了!!!
    “静态分派”是编译期的行为,它决定了「方法调用字节码指令」的方法描述符是哪个,也就是当有多个方法重载的时候,具体调用的是哪个方法;
    而“静态解析”是类加载的期间的行为(即,虚拟机行为),它是在需要调用的方法描述符已经确定的前提下,在类加载的阶段或第一次使用的时候,将“符号引用”转换为“直接引用”,即确定了调用该方法的对象。


2, 动态分派

动态分派的过程,它和多态性的另外一个重要体现 ———— 重写(Override)有着很密切的关联。

「invokevirtual 」字节码指令的多态查找流程:
① (在运行期间)首先到操作数的栈顶,去寻找栈顶元素所指向的这个对象的实际类型。
② 如果在这个实际类型当中,它寻找到了与常量池当中描述符和名称都相同的方法(即,实际类型中也实现了目标方法),并且也具备相应的访问权限。那么它就直接的返回目标方法的”直接引用”。如果存在描述符和名称都相同的方法,但是不具备访问权限,则返回java.lang.IllegalAccessError异常。(从 Java 继承角度来说,不可能出现「描述符和名称都相同的方法,但是不具备访问权限」的情况,因为子类的同一方法的访问权限不得小于父类同一方法的访问权限,因此如果父类识别能访问的方法,在经过子类重写后也一定有访问权限的)
③ 如果在第 ② 步中没有找到这样的方法,那么就按照继承的这种层次关系,从子类往父类依次的重复这个查询流程,直到能找到符合要求的(即,与常量池中描述符和名称都相同的方法,且具备相应的访问权限)方法。
④ 如果一直都找不到符合要求的方法,那么就会抛异常。

invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

  • 例一

「apple.test()」:

16 aload_1    // 从局部变量中加载引用
17 invokevirtual #6 <com/bayern/shengsiyuan/jvm_lecture/bytecode/lesson13/Fruit.test>    // 这里在运行期,会将“<com/bayern/shengsiyuan/jvm_lecture/bytecode/lesson13/Fruit.test>”这个符号引用转换为“<com/bayern/shengsiyuan/jvm_lecture/bytecode/lesson13/Apple.test>方法的直接应用”

👆可见从字节码的参数上,我们看不出它真正调用的是 Apple 的 test 方法。


  • 例二
public class Parent {
    void test1
    void test2
}
public class Child extends Parent {
    void test1
    void test2
    void test3
}
Parent child = new Child();
child.test3();    // 编译期就会报错

Q:为什么 child.test3() 无法被识别了?
A:从字节码角度解释。child.test3() 一定对应的一个 invokevirtual 指令,而 child 变量的静态类型是 Parent,invokevirtual 指令跟着的参数就是 Parent.test3,而 test3 方法在 Parent 中是找不到的,编译期就会报错。而动态链接是发生在“运行期”的,但你“编译期”就无法通过,根本不会走到“运行期”


3, “方法重载(overload)” vs “方法重写(overwrite)”
比较”方法重载(overload)”与”方法重写(overwrite)”,我们可以得到这样一个结论:
方法重载是静态的,是编译期行为;方法重写是动态的,是运行期行为。
因此 Java 中的多态与方法重载并没有什么关系,而跟方法重写有着紧密的关系。


4, 虚拟机动态分派的实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(Vritual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Inteface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。

为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。


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

解释执行:所谓解释执行,就是通过解释器来读取字节码,遇到相应的指令就去执行该指令。
编译执行:所谓编译执行,就是通过即时编译器(Just In Time,JIT)将字节码转换为本地机器码来执行;现代 JVM 会根据代码热点来生成相应的本地机器码。

4.1 解释执行

对于有一些的 Java 虚拟机,它们在执行 Java 字节码的时候,采取的是解释执行方式。这显然就需要相应的解释器来去解释相应的字节码。所谓的解释执行就是:从头读取字节码,读取到相应的指令就去执行相应的执行。

不要将“编译执行”与“程序源代码的编译”混为一谈。编译执行,实际是通过即时编译器(JIT —— just in time)产生本地代码(即,机器码),然后执行这些机器码。

现代的虚拟机并不是只能选择一种编译方式,通常是采用两者结合的方式。特别是对于一些热点代码(即,访问次数非常多的代码),那么 JIT 编译器就会把这部分代码编译成相应的本地代码,然后进行机器码的一个执行。

本地机器码的执行效率明显高于字节码的执行。但是将字节码转换成本地机器码后,本地机器码就不具备可移植性了。


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

  • 基于栈的指令集

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

对于 Java 虚拟机来说,它的指令集主要是基于栈的指令集。除了基于栈的指令集之外,还有一种指令集,叫做基于寄存器的指令集。


  • 基于寄存器的指令集

与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集,说得通俗一些,就是现在我们主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作。


  • 那么,基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?
    举个最简单的例子,分别使用这两种指令集计算“1+1”的结果,基于栈的指令集会是这样子的:
iconst_1
iconst_1
iadd
istore_0

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

如果基于寄存器,那程序可能会是这个样子:

mov eax,1
add eax,1

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

  • 这两套指令集谁更好一些呢?
    应该这么说,既然两套指令集会同时并存和发展,那肯定是各有优势的,如果有一套指令集全面优于另外一套的话,就不会存在选择的问题了。

  • 基于栈的指令集的优缺点

「优点」
① 基于栈的指令集主要的优点就是可移植
寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。

② 代码相对更加紧凑
字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数

③ 编译器实现更加简单
不需要考虑空间分配的问题,所需空间都在栈上操作

④ 等等。。。。

「缺点」
① 相同功能所需的指令数量一般会比寄存器架构多
虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。

② 频繁的内存访问
基于栈的指令集是在内存中进行操作的,而基于寄存器的指令集是在 CPU 缓冲区中执行的。相对于 CPU 的缓冲区来说,内存的速度是相当相当慢的。而基于栈的指令集的指令数量又多(入栈、出栈操作都是在内存中完成的),这就导致了基于栈的指令集操作速度慢于基于寄存器指令集的操作速度。
尽管很多虚拟机都会采取一种缓存的方式,比如,将一些对栈的常用操作映射到寄存器上面,但实际上,这不是一个根本的解决方式。它无外乎是将一些热点操作给映射到相应的寄存器当中,也就是说它能将部分操作映射到寄存器当中。

③ 「基于栈的指令架构」的指令集的主要缺点是执行速度相对于「基于寄存器的指令架构」的指令集的执行速度来说会稍慢一些。
由于指令数量和频繁出栈入栈的操作带来的内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。


4.3 基于栈的解释器执行过程

  • 例子
public int myCalculate() {
    int a = 1;
    int b = 2;
    int c = 3;
    int d = 4;
    int result = (a + b - c) * d ;
    return result;
}
 public int myCalculate();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=6, args_size=1
         0: iconst_1                // 将整数 1 压入栈顶
         1: istore_1                // 将栈顶元素弹出,并将其放置到局部变量表索引为 1 的 slot 中
         2: iconst_2
         3: istore_2
         4: iconst_3
         5: istore_3
         6: iconst_4
         7: istore        4
         9: iload_1                // 将局部变量表索引为 1 中的 int 元素值压入栈顶
        10: iload_2
        11: iadd                   // 将栈顶的前两个元素(必须都为 int 类型)弹出栈,并执行‘+’ 操作,然后将相加后的结果压入栈顶
        12: iload_3
        13: isub
        14: iload         4
        16: imul
        17: istore        5
        19: iload         5
        21: ireturn                // 从当前栈帧的操作数栈顶元素弹出,然后将这个元素压入执行这个栈帧操作的栈帧它的操作数栈中(即,你可以理解为当前方法也是一个指令,如 isub 这样的指令,我们将指令结果压入调用指令的栈帧所在的操作数栈中)。
      LineNumberTable:
        line 21: 0
        line 22: 2
        line 23: 4
        line 24: 6
        line 26: 9
        line 28: 19
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      22     0  this   Lcom/bayern/shengsiyuan/jvm_lecture/bytecode/MyTest8;
            2      20     1     a   I
            4      18     2     b   I
            6      16     3     c   I
            9      13     4     d   I
           19       3     5 result   I

PS :你会发现所有的‘操作指令’都涉及出栈操作,所以又会导致,在执行新的操作指令之前,需要将操作数从局部变量表中重新压入操作数栈中。。。这也在此验证了「JVM 执行指令时所采取的方式是基于栈的指令集」这个论点。

上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做一些优化来提高性能,实际的运作过程不一定完全符合概念模型的描述。更准确地说,实际情况会和上面描述的概念模型差距非常大,这种差距产生的原因是虚拟机中解析器和即时编译器都会对输入的字节码进行优化,例如,在HotSpot虚拟机中,有很多以”fast_“开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,而即时编译器的优化手段更加花样繁多。