深入理解JVM之虚拟机字节码执行引擎

概述

  • 在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行和编译执行两种选择,也可能两者兼备.
  • 所有Java虚拟机执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果

运行时栈帧结构

  • 栈帧是用于执行虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素.
  • 栈帧存储了方法局部变量表,操作数栈,动态连接和方法返回地址等信息.每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程.
  • 程序编译的过程中,需要多大的局部变量表,多深的操作数栈都已经完全确定了,因此一个栈帧需要分配多少内存,不会受到运行期变量数据的影响,而是取决于具体的虚拟机实现.


    image

局部变量表

  • 局部变量表是一组变量值存储空间,用于存放一个方法的参数和内部定义的局部变量,局部变量所需的容量大小是编译期就确定好了,大小不会改变.
  • 局部变量表的容量以变量槽(Slot)为最小单位,一个Slot可以存放一个43为以内的数据结构,Java中占用32为以内的数据类型有boolean,byte,char,short,int,float,referencereturnAddress,其中reference类型表示对一个对象实例的引用,returnAddres表示指向了一条字节码指令的地址
  • 局部变量表建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题.
  • 局部变量表中的变量只在当前方法调用中有效,方法执行完,随着方法栈的销毁,局部变量表也会随之销毁
  • 对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间,64位数据类型有longdouble两种.
  • 虚拟机内部通过所以的机制使用局部变量表,32位使用一个n表示使用了第n个Slot,64位使用一个n和一个n+1两个Slot,不允许单独访问其中的一个,否则会报错
  • 方法执行的过程中,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法,那么局部变量表中第n位索引的Slot默认是用于传递方法所属对象实例的引用,可以使用this关键字访问到这个隐含的参数,其余的参数按照参数顺序排列,占用从1开始的局部变量Slot
  • 局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体.
  • 对于类变量(静态变量),在准备阶段赋初始值,在初始化阶段,赋予程序员定义的初始值;对于局部变量,只定义不赋值是不能使用的,不能默认位初始值.

操作数栈

  • 操作数栈,是一个后入先出的栈,当一个方法刚刚开始执行的时候这个方法的操作数栈式空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和读取内容,也就是出栈入栈操作.
  • 如果被调用的方法带有返回值的化,其返回值也需要被压入当前操作数栈中
  • 操作数栈中的元素类型必须与字节码指令的序列严格匹配.比如iadd指令为例,这个指令用于整形数加法,它在执行时,最接近栈顶两个元素的数据类型必须位int型,不能出现一个long和一个float使用iadd命令相加的情况.
  • 在概念模型里面,两个栈帧作为虚拟机栈得元素,时完全独立的,但是大多数虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠,让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共有一部分数据,无需进行额外的参数复制传递.


    image

动态连接

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

方法返回地址

  • 当一个方法开始执行后,只有两种方式可以退出这个方法,第一种方式就是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式成为正常完成出口.
  • 另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理.
  • 无论采取何种退出方式,在方法退出以后,都需要返回到方法被调用的位置,程序才能够正常执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助回复它的上层方法的执行状态.
  • 方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:回复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中.

附加信息

一般把动态连接,方法返回地址与其他附加信息全部归为一类,称为栈帧信息

方法调用

  • 方法的调用并不等同于方法的执行,方法调用阶段的唯一任务就是确定被调用对象的版本,暂时不涉及方法内部的具体运行过程,方法调用在class文件种存储的都只是符号引用,而不是方法在实际运行时的内存布局的入口地址.
  • 需要在类的加载阶段,甚至到运行时期才能确定目标方法的直接引用.
    在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关.
  • 所有方法调用中的目标方法在Class文件中都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转换为直接引用。这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不改变的。
  • 静态连接: 当一个字节码文件被装载jvm内部时,如果被调用的目标方法在编译期间可知,且运行期间保持不变,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态连接.
  • 动态连接:如果调用的方法在编译期间无法被确定下来,也就是说只能够在运行期间将调用方法的符号引用转换为直接引用,由于这种引用转换具有动态性,因此也就被称之为动态连接.

解析

  • 在Java语言中符合"编译期可知,运行期间不可变"这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了他们都不可能通过继承或别的方式重写其他版本,他们都适合在类加载阶段进行解析.
  • Java虚拟机里面提供了5条方法调用字节码指令
    • invokestatic:调用静态方法
    • invokespecial:调用实例构造器方法,私有方法和父类方法
    • invokevirtual:调用所有的虚方法
    • invokeinterface:调用所有的接口方法,会在运行时再确定一个实现此接口的对象
    • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法
  • 只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法,私有方法,实例构造器,父类方法4种,他们都属于静态连接.他们在类加载的时候就会把符号引用解析为该方法的直接引用.这些方法成为非虚方法.
  • 用final修饰的方法,它是用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,但是final是一种非虚方法.
  • 解析调用一定是一个静态的过程,在编译期间就完全确定,在类装载的阶段就会把涉及的符号引用全部转换为可确定的直接引用,不会延迟到运行期间再去完成.
  • 符合解析调用的主要类
    符合解析调用的主要类:静态方法,私有方法,实例构造器等。

    这些方法的共性就是:没有通过继承或别的方式重写其他版本,通过该方法就可以唯一确定调用该方法的对象所属的类。


    解析调用一定是一个静态过程,在编译期间就完全确定。


    不满足解析调用的条件,那么将会在运行期间确定方法的调用者。

分派

方法分派就是把方法分派给方法的接受者,从另外一个角度来说就是把调用者和方法进行绑定。
方法分派分为 静态分派动态分派,从另外一个角度来说就是静态绑定和动态绑定

  • 什么是静态分派?

    凡是依赖 静态类型 确定方法执行的版本的分派动作都称为静态分派。因为静态类型在编译时期就可确认,所以方法的静态分派发生在编译时期。典型应用时方法重载
  • 什么是动态分派?

    这个问题不如转换成为动态分派能够做什么?动态分派能够在运行时确定方法的执行版本。
    动态分派在程序执行时是一个非常频繁的动作,因此在虚拟机的具体实际实现中要基于性能的考虑,最常用的实现手段就是在类的方法区建立一个虚方法表(Virtual Method Table),当虚拟机遇到invokevirtual指令时,会根据对象的实际类型去查找该类型所对应的虚表,然后确定这个方法的实际入口(完成符号引用变直接引用的操作)。
    典型应用时方法重写

    虚表的结构如下:

    虚表在类加载的连接阶段完成初始化。

方法重载-静态分派

package com.mutong.jvm.test;
/**
* @description:
* @Author: Mutong
* @Date: 2020/1/29 19:51
*/
public class Test14 {
    static abstract class Human{
    }
    static class Man extends Human{
    }
    static class Woman extends Human{
    }
    public void sayHello(Human guy){
        System.out.println("hello,guy");
    }
    public void sayHello(Man guy){
        System.out.println("hello man");
    }
    public void sayHello(Woman guy){
        System.out.println("hello woman");
    }
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        //Human是一个静态类型,而Man()是一个实际类型,静态类型在编译期间可知,实际类型在编译期间不可知.
        //编译器重载是通过参数的静态类型确定的。
        Test14 test14 = new Test14();
        test14.sayHello(man);
        test14.sayHello(woman);
    }
}

方法重写-动态分派

/**
* @description:
* @Author: Mutong
* @Date: 2020/1/29 21:16
*/
public class Test15 {
    static abstract class Human{
        protected abstract void sayHello();
    }
    static class Man extends Human{
        @Override
        protected void sayHello() {
            System.out.println("Man say hello");
        }
    }
    static class Woman extends Human{
        @Override
        protected 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();
        //显然,这里对方法的调用不是通过静态类型来进行的。
        //因为静态类型都为Human,却输出不同的结果。
        //并且将man指向的实际类型进行改变,执行的结果会发生改变。
        //由此便可以判断对于重写方法的调用,是针对实际类型进行调用的。
        man = new Woman();
        man.sayHello();
    }
}

动态类型语言支持

  • JDK1.7的发布,字节码指令增加了invokedynamic指令
  • 什么是动态类型语言?动态类型语言的关键特征是它的类型检查的主体过程是在运行期间而不是编译期;在编译期就进行类型检查过程的语言就是静态类型语言

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

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

public class Test17 {
    public static void main(String[] args) {
        int a = 100;
        int b = 200;
        int c = 300;
        int d = (a + b) * c;
    }
}

javap命令反编译之后的代码:

public static void main(java.lang.String[]);
    Code:
      0: bipush        100
      2: istore_1
      3: sipush        200
      6: istore_2
      7: sipush        300
      10: istore_3
      11: iload_1
      12: iload_2
      13: iadd
      14: iload_3
      15: imul
      16: istore        4
      18: return
}
  • bipush指令的作用是将单字节的整型常量值(-128~127)推入操作数栈顶.跟随一个参数,指明推送的常量值,这里面是100
  • istore_1指令的作用是将操作书栈顶的整型值出栈并存放在第1个局部变量Slot中.
  • sipush指令的作用是将当int取值-32768~32767时,JVM采用sipush指令将常量压入栈中
  • iload_1指令的作用是将局部变量表第一个Slot中的整型值复制到操作数栈顶.
  • iload_2指令的作用是将局部变量表第二个Slot中的整型值复制到操作数栈顶.此时,操作数栈栈顶元素是200,下面是100
  • iadd指令的作用是将操作书栈中的前两个元素出栈,做整型加法运算,然后把结果重新入操作数栈,此时操作数栈内元素为300.
  • iload_3指令的作用是将局部变量表中第三个Slot中的整型值复制到操作数栈顶,此时操作数栈顶元素为300,第二个元素是300
  • imull指令的作用将操作数栈顶的两个元素相乘结果入栈,此时操作数栈顶元素为90000
  • ireturn指令的作用是结束方法执行并将操作数栈顶的整型值返回给此方法的调用者.

你可能感兴趣的:(深入理解JVM之虚拟机字节码执行引擎)