深入理解JVM虚拟机(七):虚拟机字节码执行引擎

代码编译的结果就是从本地机器码转变为字节码。我们都知道,编译器将Java源代码转换成字节码?那么字节码是如何被执行的呢?这就涉及到了JVM字节码执行引擎,执行引擎负责具体的代码调用及执行过程。就目前而言,所有的执行引擎的基本一致:

  1. 输入:字节码文件
  2. 处理:字节码解析
  3. 输出:执行结果。

所有的Java虚拟机的执行引擎都是一致的:输入的是字节码执行文件,处理的过程是字节码解析的等效过程,输出的是执行结果。物理机的执行引擎是由硬件实现的,和物理机的执行过程不同的是虚拟机的执行引擎由于自己实现的。

1.方法调用

方法调用的主要任务就是确定被调用方法的版本(即调用哪一个方法),该过程不涉及方法具体的运行过程。按照调用方式共分为两类:

  1. 解析调用是静态的过程,在编译期间就完全确定目标方法。
  2. 分派调用即可能是静态,也可能是动态的,根据分派标准可以分为单分派和多分派。两两组合有形成了静态单分派、静态多分派、动态单分派、动态多分派

Class文件的编译过程不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存不急的入口地址(相当于说的是直接引用)。

我们知道class文件是源代码经过编译后得到的字节码,如果学过编译原理会知道,这个仅仅完成了一半的工作(词法分析、语法分析、语义分析、中间代码生成),接下来就是实际的运行了。而Java选择的是动态链接的方式,即用到某个类再加载进内存,而不是像C++那样使用静态链接:将所有类加载,不论是否使用到。当然了,孰优孰劣不好判断。静态链接优点在速度,动态链接优点在灵活。下面我们来详细介绍一下动态链接和静态链接。

2. 静态链接

如上面的概念所述,在C/C++中静态链接就是在编译期将所有类加载并找到他们的直接引用,不论是否使用到。而在Java中我们知道,编译Java程序之后,会得到程序中每一个类或者接口的独立的class文件。虽然独立看上去毫无关联,但是他们之间通过接口(harbor)符号互相联系,或者与Java API的class文件相联系。

我们之前也讲述了类加载机制中的一个过程—解析,并在其中提到了解析就是将class文件中的一部分符号引用直接解析为直接引用的过程,但是当时我们并没有详细说明这种解析所发生的条件,现在我给大家进行补充:

方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。可以概括为:编译期可知、运行期不可变。此类方法主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可访问,因此决定了他们都不可能通过继承或者别的方式重写该方法,符合这两类的方法主要有以下几种:静态方法、私有方法、实例构造器、父类方法。

3. 动态链接

如上所述,在Class文件中的常量持中存有大量的符号引用。字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分在类的加载阶段(解析)或第一次使用的时候就转化为了直接引用(指向数据所存地址的指针或句柄等),这种转化称为静态链接。而相反的,另一部分在运行期间转化为直接引用,就称为动态链接。

与那些在编译时进行链接的语言不同,Java类型的加载和链接过程都是在运行的时候进行的,这样虽然在类加载的时候稍微增加一些性能开销,但是却能为Java应用程序提供高度的灵活性,Java中天生可以动态扩展的语言特性就是依赖动态加载和动态链接这个特点实现的。

4. 解析

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

  1. invokestatic:调用静态方法,解析阶段确定唯一方法版本
  2. invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
  3. invokevirtual:调用所有虚方法
  4. invokeinterface:调用接口方法
  5. invokedynamic:动态解析出需要调用的方法,然后执行

前四条指令固化在虚拟机内部,方法的调用执行不可认为干预,而invokedynamic指令则支持由用户确定方法版本。

非虚方法:其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,符合这个条件的有静态方法、私有方法、实例构造器、分类方法这4类。Java中的非虚方法除了使用invokestatic指令和invokespecial指令调用的方法之外还有一种,就是final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖没有其他版本,所以也无须对方法接受者进行多态选择,又或者多态选择的结果是唯一的。Java语言规范中明确说明了final方法也是一直用非虚方法。所以对于非虚方法中,Java通过编译阶段,将方法的符号引用转换为直接引用。因为它是编译器可知、运行期不可变得方法。

解析调用一定是一个静态过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转换为确定的直接引用,不会延迟到运行期再去完成。而分派调用则可能是静态的也可能是动态的,根据分派依据的宗量数量可以分为单分派和多分派。

5. 分派

分派调用更多的体现在多态上。

宗量的定义:方法的接受者(亦即方法的调用者)与方法的参数统称为方法的宗量。单分派是根据一个宗量对目标方法进行选择,多分派是根据多于一个宗量对目标方法进行选择。

  • 静态分派:所有依赖静态类型3来定位方法执行版本的分派成为静态分派,发生在编译阶段,典型应用是方法重载。
  • 动态分派:在运行期间根据实际类型4来确定方法执行版本的分派成为动态分派,发生在程序运行期间,典型的应用是方法的重写。
  • 单分派:根据一个宗量对目标方法进行选择。
  • 多分派:根据多于一个宗量对目标方法进行选择。

介绍分派之前我们先来对静态类型实际类型进行定义:

Human man = new Man();

如上代码,Human被称为静态类型,Man被称为实际类型。

//实际类型变化
Human man = new Man();
man = new Woman();

//静态类型变化
StaticDispatch sr = new StaticDispatch();
sr.sayHello((Human) man);
sr.sayHello((Woman) man);

可以看到的静态类型和实际类型都会发生变化,但是有区别:静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的,而实际类型变化的结果在运行期才可确定。

5.1 静态分派(重载 静态类型)

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。

我们来看一下下面这个应用程序:

class Human {
}

class Man extends Human {
}

class Woman extends Human {
}

public class StaticDispatch {

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

    public void sayHello(Man guy){
        System.out.println("hello, gentleman!");
    }

    public void sayHello(Woman guy){
        System.out.println("hello, lady!");
    }

    public static void main(String[] args){
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
    }
}

运行结果:
hello, guy!
hello, guy!

如上代码与运行结果,在调用 sayHello()方法时,方法的调用者都为sr的前提下,使用哪个重载版本,完全取决于传入参数的数量和数据类型。代码中刻意定义了两个静态类型相同、实际类型不同的变量,可见编译器(不是虚拟机,因为如果是根据静态类型做出的判断,那么在编译期就确定了)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,所以在编译阶段,javac 编译器就根据参数的静态类型决定使用哪个重载版本。因此,在编译期间,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法的两条invokevirtual指令参数中。

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

5.2 动态分派(重写 实际类型)

动态分派与多态性的另一个重要体现——方法重写有着很紧密的关系。向上转型后调用子类覆写的方法便是一个很好地说明动态分派的例子。这种情况很常见,因此这里不再用示例程序进行分析。很显然,在判断执行父类中的方法还是子类中覆盖的方法时,如果用静态类型来判断,那么无论怎么进行向上转型,都只会调用父类中的方法,但实际情况是,根据对父类实例化的子类的不同,调用的是不同子类中覆写的方法,很明显,这里是要根据变量的实际类型来分派方法的执行版本。而实际类型的确定需要在程序运行时才能确定下来,这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

我们再来看一下下下面应用程序:

/**
 * locate com.basic.java.classExecution
 * Created by MasterTj on 2018/12/14.
 * 方法动态分派演示
 */
public class DynamicDispatch {
    static abstract class Human{
        protected abstract void sayHello();
    }

    static class Man extends Human{

        @Override
        protected void sayHello() {
            System.out.println("man SayHello!!");
        }
    }

    static class Woman extends Human{

        @Override
        protected void sayHello() {
            System.out.println("Woman SayHello!!");
        }
    }

    public static void main(String[] args) {
        Human man=new Man();
        Human woman=new Woman();

        man.sayHello();;
        woman.sayHello();

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

运行结果:
man SayHello!!
Woman SayHello!!
Woman SayHello!!

对于虚函数的调用,在JVM指令集中是调用invokevirtual指令。下面我们来介绍一下invokevirtual指令的动态查找过程,invokevirtual指令的运行时解析过程大致可以分为以下几个步骤:

  1. 找到操作栈顶的第一个元素所指向的对象的实际类型,记做C。
  2. 如果在类型C中找到与常量的描述符和简单名称都像符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束。如果不通过,则返回java.lang.IllegalAccessError异常。
  3. 否则按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
  4. 否则始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常

由于invokevirtual指令执行把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言的方法重写的本质。

5.3 单分派与多分派

单分派是根据一个宗量对目标方法进行选择,多分派是根据多于一个宗量对目标方法进行选择。

我们再来看一下下面应用程序:

class Eat {
}

class Drink {
}

class Father {
    public void doSomething(Eat arg) {
        System.out.println("爸爸在吃饭");
    }

    public void doSomething(Drink arg) {
        System.out.println("爸爸在喝水");
    }
}

class Child extends Father {
    public void doSomething(Eat arg) {
        System.out.println("儿子在吃饭");
    }

    public void doSomething(Drink arg) {
        System.out.println("儿子在喝水");
    }
}

public class SingleDoublePai {
    public static void main(String[] args) {
        Father father = new Father();
        Father child = new Child();
        father.doSomething(new Eat());
        child.doSomething(new Drink());
    }
}

运行结果:

爸爸在吃饭
儿子在喝水

我们首先来看编译阶段编译器的选择过程,即静态分派过程。这时候选择目标方法的依据有两点:一是方法的接受者(即调用者)的静态类型是 Father 还是 Child,二是方法参数类型是 Eat 还是 Drink。因为是根据两个宗量进行选择,所以 Java 语言的静态分派属于多分派类型

再来看运行阶段虚拟机的选择,即动态分派过程。由于编译期已经了确定了目标方法的参数类型(编译期根据参数的静态类型进行静态分派),因此唯一可以影响到虚拟机选择的因素只有此方法的接受者的实际类型是 Father 还是 Child。因为只有一个宗量作为选择依据,所以 Java 语言的动态分派属于单分派类型

目前的 Java 语言(JDK1.6)是一门静态多分派(方法重载)、动态单分派(方法重写)的语言。

6. 方法的执行

下面我们来探讨虚拟机是如何执行方法中的字节码指令的,上文提到,许多Java虚拟机的执行引擎在执行Java代码的时候都用解释执行(通过解释器执行)和编译执行(通过及时编译器产生本地代码)

6.1 解释执行

在jdk 1.0时代,Java虚拟机完全是解释执行的,随着技术的发展,现在主流的虚拟机中大都包含了即时编译器(JIT)。因此,虚拟机在执行代码过程中,到底是解释执行还是编译执行,只有它自己才能准确判断了,但是无论什么虚拟机,其原理基本符合现代经典的编译原理,如下图所示: 大部分程序代码到物理机的目标代码或虚拟机能执行的指令之前,都需要经过以下各个步骤。

在这里插入图片描述

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

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

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

Java编译器输入的指令流基本上是一种基于栈的指令集架构,指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。另外一种指令集架构则是基于寄存器的指令集架构,典型的应用是x86的二进制指令集,比如传统的PC以及Android的Davlik虚拟机。两者之间最直接的区别是,基于栈的指令集架构不需要硬件的支持,而基于寄存器的指令集架构则完全依赖硬件,这意味基于寄存器的指令集架构执行效率更高,单可移植性差,而基于栈的指令集架构的移植性更高,但执行效率相对较慢,初次之外,相同的操作,基于栈的指令集往往需要更多的指令,比如同样执行2+3这种逻辑操作,其指令分别如下:

基于栈的指令集运行的就是经过JIT解释器解释执行的指令流,基于寄存器的指令集运行的就是目标机器代码的指令。

基于栈的指令集的优势和缺点:

  • 优点:可以移植性强,寄存器由硬件进行保护,程序直接依赖这些应将寄存器而不可避免地要受到硬件的约束。
  • 缺点:栈架构指令集的代码非常紧凑,但是完成相同功能所需要的指令数量一般会比寄存器的架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。

基于栈的计算流程(以Java虚拟机为例):

iconst_2  //常量2入栈
istore_1  
iconst_3  //常量3入栈
istore_2
iload_1
iload_2
iadd      //常量2、3出栈,执行相加
istore_0  //结果5入栈

而基于寄存器的计算流程:

mov eax,2  //将eax寄存器的值设为1
add eax,3  //使eax寄存器的值加3

6.3 基于栈的代码执行示例

下面我们用简单的案例来解释一下JVM代码执行的过程,代码实例如下:

public class MainTest {
    public  static int add(){
        int result=0;
        int i=2;
        int j=3;
        int c=5;
        return result =(i+j)*c;
    }

    public static void main(String[] args) {
        MainTest.add();
    }
}

使用javap指令查看字节码:

{
  public MainTest();
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 2: 0

  public static int add();
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=0     //栈深度2,局部变量4个,参数0个
         0: iconst_0  //对应result=0,0入栈
         1: istore_0  //取出栈顶元素0,将其存放在第0个局部变量solt中
         2: iconst_2  //对应i=2,2入栈
         3: istore_1  //取出栈顶元素2,将其存放在第1个局部变量solt中
         4: iconst_3  //对应 j=3,3入栈
         5: istore_2  //取出栈顶元素3,将其存放在第2个局部变量solt中
         6: iconst_5  //对应c=5,5入栈
         7: istore_3  //取出栈顶元素,将其存放在第3个局部变量solt中
         8: iload_1   //将局部变量表的第一个slot中的数值2复制到栈顶
         9: iload_2   //将局部变量表中的第二个slot中的数值3复制到栈顶
        10: iadd      //两个栈顶元素2,3出栈,执行相加,将结果5重新入栈
        11: iload_3   //将局部变量表中的第三个slot中的数字5复制到栈顶
        12: imul      //两个栈顶元素出栈5,5出栈,执行相乘,然后入栈
        13: dup       //复制栈顶元素25,并将复制值压入栈顶.
        14: istore_0  //取出栈顶元素25,将其存放在第0个局部变量solt中
        15: ireturn   //将栈顶元素25返回给它的调用者
      LineNumberTable:
        line 4: 0
        line 5: 2
        line 6: 4
        line 7: 6
        line 8: 8

  public static void main(java.lang.String[]);
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=1, args_size=1
         0: invokestatic  #2                  // Method add:()I
         3: pop
         4: return
      LineNumberTable:
        line 12: 0
        line 13: 4
}

执行过程中代码、操作数栈和局部变量表的变化情况如下:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_21125183/article/details/85001365