JVM(六):虚拟机字节码执行引擎

在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种,也可能两者兼备。

但从外观上来看,所有的Java虚拟机的执行引擎都是一致的,
输入:字节码文件
处理过程:字节码解析的等效过程
输出:执行结果

一、运行时栈帧结构

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

每个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。

JVM(六):虚拟机字节码执行引擎_第1张图片
1. 局部变量表

局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。

Java程序编译为Class文件时,就可以在方法的Code属性的max_locals项中确定该方法所需要分配的局部变量表的最大值。

局部变量表的容量以变量槽(Variable Slot,Slot)为最小单位。虚拟机规范中没有明确指明一个Slot应占用的内存大小,但是每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8种类型都可以使用32位以内的物理内存来存放。

reference类型表示对一个对象实例的引用,需要满足两点:
(1)从此引用中直接或间接的查找到对象在Java堆中的数据存放的起始地址索引;
(2)从此引用中直接或间接的查找到对象所属类型(类)在方法区中存储的类型信息。

Java语言中明确的64位数据类型只有long和double,因此虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。由于局部变量表建立在虚拟机栈中,是各线程私有的,因此不存在线程安全问题。

如果执行的是实例方法(非static方法),那么局部变量表的结构如下:
(1)第0位索引的Slot默认是用于传递方法所属对象实例的引用(即this所指的对象实例)。
(2)参数表按照顺序排列,从第1位索引开始直至参数表分配完毕。
(3)根据方法体内部定义的变量顺序和作用域分配其余Slot。
注意:为了尽可能节省栈帧空间,局部变量表中的Slot可以重用。
如果当前字节码PC计数器的值已经超过了某个变量的作用域,那这个变量对应的Slot就可以被其他变量重用。

局部变量:不会初始化系统值,只会初始化程序员定义的值。(必须初始化)
类变量:先初始化系统值,再初始化程序员定义的值。(可以用系统初始值)

2. 操作数栈

操作数栈又称操作栈,它是一个后入先出的栈。
操作数栈的最大深度在编译的时候已经确定,写入到Code属性的max_stacks中。
操作数栈的每个元素可以是任意的Java数据类型,包括long和double,32位数据类型占用的栈容量为1,64位数据类型占用的栈容量为2。

举例:

public static void main(String[] args) {
    int a = 4;
    int b = 20;
    int c = a + b;
}

编译:javac Test.java
查看字节码:javap -verbose Test.class

public static void main(java.lang.String[]);
  descriptor: ([Ljava/lang/String;)V
  flags: ACC_PUBLIC, ACC_STATIC
  Code:
    stack=2, locals=4, args_size=1
       0: iconst_4            // 将整型常量4加载到操作数栈顶
       1: istore_1            // 弹出操作数栈顶的数据并存储到局部变量表Slot1处
       2: bipush      20      // 将整型常量20加载到操作数栈顶
       4: istore_2            // 弹出操作数栈顶的数据并存储到局部变量表Slot2处
       5: iload_1             // 加载局部变量表Slot1处的数据到操作数栈顶
       6: iload_2             // 加载局部变量表Slot2处的数据到操作数栈顶
       7: iadd                  // 弹出操作数栈顶的两个数据相加并将结果压入操作数栈顶
       8: istore_3            // 弹出操作数栈顶的数据并存储到局部变量表Slot3处
       9: return
3. 动态连接

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

4. 方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法:
(1)正常完成出口:执行引擎遇到任意一个方法返回的字节码指令。
(2)异常完成出口:执行过程中遇到了异常,并且这个异常没有在方法体内得到处理。
无论采用何种方式退出,在方法退出后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。

5. 附加信息

具体的虚拟机实现可以增加一些规范中没有描述的信息到栈帧中。实际开发中,一般会把动态连接、方法返回地址和附加信息归为一类,称为栈帧信息。

二、方法调用

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

Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里存储的都是符号引用,而不是直接引用。例如:invokevirtual #2 // Method add:()V,其中#2就是符号引用,对应类常量池中的add()方法。

1. 虚拟机解析

在类加载的解析阶段,会将一部分方法调用中的目标方法的符号引用转化为直接引用,这些目标方法需要满足“编译期可知,运行期不可变”这个要求,这类方法的调用称为解析。

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

  • invokestatic:调用静态方法。
  • invokespecial:调用实例构造器方法、私有方法和父类方法。
  • invokevirtual:调用所有的虚方法。
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。

只要能被invokestaticinvokespecial指令调用的方法,都可以在类加载的解析阶段确定唯一的调用版本,把符号引用解析为该方法的直接引用。这些方法被称为非虚方法。
注意:final修饰的方法也是非虚方法。虽然final方法使用invokevirtual指令调用,但是由于它无法被覆盖,所以也无需对方法接收者进行多态选择。

2. 虚拟机分派

静态分派
对于代码:Human man = new Man();
Human称为变量的静态类型,在编译期可知;
Man称为变量的实际类型,在运行期才可确定。

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

静态分派的典型应用是方法重载(Overload),方法重载改变参数类型,此参数类型就是参数的静态类型,在编译阶段,javac编译器就可以根据参数的静态类型决定使用哪个更适合的重载版本了。

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

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

动态分派的典型应用是方法重写(Override),方法重写的本质就是:在运行期把常量池中的类方法符号引用解析为不同的直接引用,从而选择对应的重写方法版本。

单分派和多分派
执行方法的所有者被称为方法的接收者。
方法的接收者与方法的参数统称为方法的宗量。

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

Java语言的静态分派根据方法接收者的静态类型和参数的静态类型这两个宗量进行选择,属于多分派。
Java语言的动态分派根据方法接收者的实际类型进行选择,属于单分派。

3. 动态类型语言支持

动态类型语言的特征:
(1)类型检查的主体过程在运行期而不是编译期(如JavaScript)
(2)变量无类型而变量值才有类型(如var)

调用方法的指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info / CONSTANT_InterfaceMethodref_info),方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型,因此这四条调用方法的指令不能用于实现动态类型语言。

为提供动态类型语言支持,JDK1.7中引入了invokedynamic指令和java.lang.invoke包。

java.lang.invoke包
这个包的主要目的是在之前单纯依靠符号引用来确定目标方法这种方式外,提供一种新的动态确定目标方法的机制,称为MethodHandle。

利用 java.lang.invoke 包和 java.lang.reflect 包实现相同功能:

package test;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodHandles.Lookup;
import java.lang.invoke.MethodType;

public class TestMethodHandle {
    static class A {
        public void print(String a) {
            System.out.println("A -- " + a);
        }
    }

    static class B {
        public void print(String a) {
            System.out.println("B -- " + a);
        }
    }

    /**
     * 根据方法名、方法类型获取具体方法
     *
     * @param receiver 方法的调用者(接收者)
     * @return
     * @throws NoSuchMethodException
     * @throws IllegalAccessException
     */
    private static MethodHandle getMethodHandle(Object receiver) throws NoSuchMethodException, IllegalAccessException {
        // 定义方法的类型(返回类型、参数类型)
        MethodType methodType = MethodType.methodType(void.class, String.class);

        // 调用lookup方法,在指定类(reveiver对应的类)中查找符合方法名、方法类型、调用权限的方法句柄
        // 将找到的方法绑定到调用对象上(相当于往方法中加入了表示当前调用对象的this属性)
        Lookup lookup = MethodHandles.lookup();
        MethodHandle methodHandle = lookup.findVirtual(receiver.getClass(), "print", methodType).bindTo(receiver);

        return methodHandle;
    }

    public static void main(String[] args) throws Throwable {
        for (int i = 0; i < 5; i++) {
            Object object = i % 2 == 0 ? new A() : new B();
            MethodHandle methodHandle = getMethodHandle(object);
            methodHandle.invoke("123");
        }
    }
}

// 结果
A -- 123
B -- 123
A -- 123
B -- 123
A -- 123

(1)根据方法名、方法类型,调用MethodHandles.lookup()函数去方法中指定的类内查找匹配的方法。
(2)初始化对象实例,然后用对象实例去调用此方法。


package test;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class TestReflect {
    static class A {
        public void print(String a) {
            System.out.println("A -- " + a);
        }
    }

    static class B {
        public void print(String a) {
            System.out.println("B -- " + a);
        }
    }

    public static void main(String[] args) throws NoSuchMethodException, SecurityException, IllegalAccessException,
            IllegalArgumentException, InvocationTargetException {
        for (int i = 0; i < 5; i++) {
            Object object = i % 2 == 0 ? new A() : new B();
            Method method = object.getClass().getMethod("print", String.class);
            method.invoke(object, "123");
        }
    }
}

// 结果
A -- 123
B -- 123
A -- 123
B -- 123
A -- 123

(1)根据方法名、参数类型,利用反射去指定的类内查找匹配的方法。
(2)初始化对象实例,然后用对象实例去调用此方法。

Reflection和MethodHandle很像,但也有区别:

  • Reflection和MethodHandle都是在模拟方法调用,但是Reflection模拟Java代码层次的方法调用,MethodHandle模拟字节码层次的方法调用。lookup中的三个方法正好对应字节码中方法调用的四条指令:findStatic() - invokestaticfindSpecial() - invokespecialfindVirtual() - invokevirtual和invokeinterfaces
  • java.lang.reflect.Method对象中包含的信息远多于java.lang.invoke.MethodHandle对象

invokedynamic指令
invokedynamic指令和MethodHandle类似,都是为了把如何查找目标方法的决定权从虚拟机转移到具体用户代码中。

invokedynamic指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是CONSTANT_InvokeDynamic_info常量,此常量中包含三个信息:引导方法(Bootstrap Method)、方法类型(MethodType)和名称。利用引导方法可以得到真正要执行的目标方法调用。

public static CallSite BootstrapMethod(MethodHandles.Lookup lookup, String name, MethodType methodType) throws Throwable {
    return new ConstantCallSite(lookup.findStatic(xxx.class, name, methodType));
}

invokedynamic指令是面向Java虚拟机上所有语言的,因此利用javac无法生成带有invokedynamic指令的字节码,利用INDY工具可以把程序的字节码转换为使用invokedynamic指令。

三、基于栈的字节码解释执行引擎

许多Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。

JVM(六):虚拟机字节码执行引擎_第2张图片

Java语言中,javac编译器完成了程序源码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程,这一部分动作由javac完成,独立在Java虚拟机之外。
解释器解释执行指令流的动作在Java虚拟机内部完成。

基于栈的指令集和基于寄存器的指令集
javac编译输出的字节码指令流,基本上都是基于栈的指令集架构,指令集中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。

基于栈的指令集和基于寄存器的指令集的对比:
(1)由于寄存器是由硬件直接提供的,因此如果指令集直接依赖于寄存器,则必然要受到硬件的约束,但是基于栈的指令集没有此约束,因此基于栈的指令集可移植性更高。
(2)基于栈的指令集中指令基本都是零地址指令,因此不需要考虑空间分配问题,直接在栈上操作即可,因此编译器的实现更简单。
(3)栈的实现是在内存中的,寄存器在处理器内,内存的速度远低于处理器,而且入栈、出栈操作必然会导致指令数量的增加,因此基于栈的指令集的执行速度较慢。

你可能感兴趣的:(JVM(六):虚拟机字节码执行引擎)