虚拟机字节码执行引擎

概述

物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

运行时栈帧结构

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

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

对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧。

Java栈帧结构

局部变量表

编译的时候,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。

Java 语言中明确的( reference 类型则可能是 32 位也可能是 64 位) 64 位的数据类型只有 long 和 double 两种。

在方法执行时,虚拟机是使用局部变量表完成参数变量列表的传递过程,如果是实例方法,那么局部变量表中的每0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数,其余参数则按照参数列表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域来分配其余的Slot。局部变量表中的Slot是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法,如果当前字节码PC计算器的值已经超出了某个变量的作用域,那么这个变量对应的Slot就可以交给其它变量使用。

为了尽可能节省栈帧空间,局部变量表中的 Slot(32位) 是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体。

局部变量不像前面介绍的类变量那样存在“准备阶段”。类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的值。因此即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样了,如果一个局部变量定义了但没有赋初始值是不能使用的。

原书这里有个有趣的例子,局部变量表复用对垃圾收集的影响
public static void main(String[] args){
    byte[] placeHolder = new byte[64 * 1024 * 1024];
    System.gc();
}

placeholder 原本所占用的 Slot 还没有被其他变量所复用,所以作为 GC Roots 一部分的局部变量表仍然保持着对它的关联。

书籍《 Practical Java》 中把“不使用的对象应手动赋值为 null” 作为一条推荐的编码规则,赋 null 值的操作在经过 JIT 编译优化后就会被消除掉,这时候将变量设置为 null 就是没有意义的。

以下代码片段1在经过 JIT 编译后, System.gc() 执行时就可以正确地回收掉内存,无须写成代码清单2的样子。

代码片段1

public static void main(String[] args){
    {
        byte[] placeHolder = new byte[64 * 1024 * 1024];
    }
    System.gc();
}

代码片段2

public static void main(String[] args){
    {
        byte[] placeHolder = new byte[64 * 1024 * 1024];
        // 这样直接设置为null也是可以被及时回收的
        // placeHolder = null;
    }
    // 离开了placeHolder的作用域之后,执行对局部变量表的读写,让垃圾回收器能够回收到placeHolder占用的内存
    int a = 0;
    System.gc();
}

局部变量不像前面介绍的类变量那样存在“准备阶段”。如果一个局部变量定义了但没有赋初始值是不能使用的。

操作数栈

操作数栈也常被称为操作栈,它是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也是编译的时候被写入到方法表的Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。栈容量的单位为“字宽”,对于32位虚拟机来说,一个”字宽“占4个字节,对于64位虚拟机来说,一个”字宽“占8个字节。

当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指向操作数栈中写入和提取值,也就是入栈与出栈操作。例如,在做算术运算的时候就是通过操作数栈来进行的,又或者调用其它方法的时候是通过操作数栈来行参数传递的。

另外,在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的,但是大多数虚拟机的实现里都会作一些优化处理,令两个栈帧出现一部分重叠。让下栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用返回时就可以共用一部分数据,而无须进行额外的参数复制传递了,重叠过程如下图:

两个栈帧之间的数据共享

动态链接

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

方法返回地址

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

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

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

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

附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与高度相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息。

方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作。在Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法的调用过程变得相对复杂,需要在类加载期间甚至到运行期间才能确定目标方法的直接引用。

解析

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一可确定的调用版本,并且这个方法的调用版本是运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

在Java语言中,符合“编译期可知,运行期不可变”这个要求的方法有静态方法和私有方法两大类,前者与类型直接相关联,后者在外部不可被访问,这两种方法都不可能通过继承或者别的方式重写出其它版本,因此它们都适合在类加载阶段进行静态解析。

与之相对应,在Java虚拟机里提供了5条方法调用字节码指令,分别是:

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

只要能被invokestatic与invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法,私有方法,实例构造器和父类方法四类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以统称为非虚方法,与之相反,其它方法就称为虚方法(除去final方法)。

Java中的非虚方法除了使用invokestatic与invokespecial指令调用的方法之后还有一种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其它版本,所以也无须对方法接收都进行多态选择,又或者说多态选择的结果是唯一的。在Java语言规范中明确说明了final方法是一种非虚方法。

解析调用一定是个静态过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派与多分派。这两类分派方式两两组件就构成了静态单分派,静态多分派,动态单分派与动态多分派情况。

分派

静态分派

下面是一段程序代码:

package com.xtayfjpk.jvm.chapter8;  

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("hello guy...");  
    }  
    public void sayHello(Man man) {  
        System.out.println("hello man...");  
    }  
    public void sayHello(Woman woman) {  
        System.out.println("hello woman...");  
    }  

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

执行结果为:

hello man...
hello guy...

但为什么会选择执行参数为Human的重载呢?在这之前,先按如下代码定义两个重要的概念:Human man = new Man();

上面代码中的“Human”称为变量的静态类型(Static Type)或者外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是编译期可知的;而实际类型变化的结果在运行期才可确定,编译期在编译程序的时候并不知道一个对象的实际类型是什么?如下面的代码:

//实际类型变化  
Human man = new Man();  
man = new Woman();  
//静态类型变化  
sd.sayHello((Man)man);  
sd.sayHello((Woman)man);

解释了这两个概念,再回到上术代码中。main()里面的两次sayHello()方法调用,在方法接收者已经确定是对象“sr”的前提下,使用哪个重载版本,就完全取决于传入参数和数据类型。代码中刻意定义了两个静态类型相同,实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型在编译期是可知的,所以在编译阶段,Javac编译器就根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法的两条invokevirual指令的参数中。

所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动力实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但是很多情况下,这个重载版本并不是“唯一的”,往往只能确定一个“更适合的”版本。这种模糊的结论在0和1构成的计算机世界中算是个比较“稀罕”的事件,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。

动态分派

动态分派与重写(Override)有着很密切的关联。如下代码:

package com.xtayfjpk.jvm.chapter8;  

public class DynamicDispatch {  
    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();  
        man = new Woman();  
        man.sayHello();  
    }  
}

这里显示不可能是根据静态类型来决定的,因为静态类型都是Human的两个变量man和woman在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原是是这两个变量的实际类型不同。那么Java虚拟机是如何根据实际类型来分派方法执行版本的呢,我们使用javap命令输出这段代码的字节码,结果如下:

public static void main(java.lang.String[]);  
  flags: ACC_PUBLIC, ACC_STATIC  
  Code:  
    stack=2, locals=3, args_size=1  
       0: new           #16                 // class com/xtayfjpk/jvm/chapter8/DynamicDispatch$Man  
       3: dup  
       4: invokespecial #18                 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Man."<init>":()V  
       7: astore_1  
       8: new           #19                 // class com/xtayfjpk/jvm/chapter8/DynamicDispatch$Woman  
      11: dup  
      12: invokespecial #21                 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Woman."<init>":()V  
      15: astore_2  
      16: aload_1  
      17: invokevirtual #22                 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Human.sayHello:()V  
      20: aload_2  
      21: invokevirtual #22                 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Human.sayHello:()V  
      24: new           #19                 // class com/xtayfjpk/jvm/chapter8/DynamicDispatch$Woman  
      27: dup  
      28: invokespecial #21                 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Woman."<init>":()V  
      31: astore_1  
      32: aload_1  
      33: invokevirtual #22                 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Human.sayHello:()V  
      36: return

0-15行的字节码是准备动作,作用是建立man和woman的内存空间,调用Man和Woman类的实例构造器,将这两个实例的引用存放在第1和第2个局部变量表Slot之中,这个动作对应了代码中这两句:

Human man = new Man();  
Human woman = new Woman();

接下来的第16-21行是关键部分,第16和第20两行分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将执行的sayHello()方法的所有者,称为接收者(Receiver),第17和第21两行是方法调用指令,单从字节码的角度来看,这两条调用指令无论是指令(都是invokevirtual)还是参数(都是常量池中Human.sayHello()的符号引用)都完全一样,但是这两条指令最终执行的目标方法并不相同,其原因需要从invokevirutal指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下步骤:

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

    由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

单分派与多分派

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

在编译期的静态分派过程选择目标方法的依据有两点:一是静态类型;二是方法参数,所以Java语言的静态分派属于多分派类型。在运行阶段虚拟机的动态分派过程只能接收者的实际类型一个宗量作为目标方法选择依据,所以Java语言的动态分派属于单分派类型。所在Java语言是一门静态多分派,动态单分派语言。

这部分的实现和例子,需要去看原书。P255.

虚拟机动态分派的实现

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

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

动态类型语言支持

随着 JDK 7 的发布,字节码指令集终于迎来了第一位新成员—— invokedynamic指令。这条新增加的指令是 JDK 7 实现“动态类型语言”( Dynamically Typed Language) 支持而进行的改进之一。

动态类型语言

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期。

“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个重要特征。

JDK 1.7 与动态类型

java.lang.invoke 包

JDK 1.7 实现了 JSR-292, 新加入的 java.lang.invoke 包就是 JSR-292 的一个重要组成部分。

public class MethodHandleTest {
  static class ClassA{
      public void println(String s){
          System.out.println(s);
      }
  }

  public static void main(String[] args) throws Throwable {
      Object obj = System.currentTimeMillis() % 2 == 0 ? System.out:new ClassA();
      /* 无论obj最终是那个实现类,下面这句都能正确调用到println方法 */
      getPrintlnMH(obj).invokeExact("icyfenix");
      /* output:
       * icyfenix
       */
  }

  private static MethodHandle getPrintlnMH(Object receiver) throws Throwable{
      /* MethodType: 代表“方法类型”,包含了方法的返回值(methodType()的第一个参数)
       * 和具体参数(methodType()第二个及以后的参数) */
      MethodType mt = MethodType.methodType(void.class, String.class);
      /* lookup()方法来自于MethodHandles.lookup,
       * 这句的作用是在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄。
       * 因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,
       * 也即是this指向的对象,这个参数以前是放在参数列表中进行传递的,而现在提供给了bindTo()方法来完成这件事情 */
      return MethodHandles.lookup().findVirtual(receiver.getClass(), "println", mt)
              .bindTo(receiver);
  }
}

MethodHandle 的使用方法和效果与 Reflection 有众多相似之处,不过,它们还是有以下这些区别:

  • Reflection 是在模拟 Java 代码层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用。
  • Reflection 是重量级,而 MethodHandle 是轻量级。

Reflection API 的设计目标是只为 Java 语言服务的,而 MethodHandle 则设计成可服务于所有 Java 虚拟机之上的语言,其中也包括 Java 语言。

invokedynamic 指令

在某种程度上, invokedynamic 指令与 MethodHandle 机制的作用是一样的,都是为了解决原有 4 条" invoke*" 指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中。

掌控方法分派规则

invokedynamic 指令与前面 4 条" invoke*" 指令的最大差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。

可以通过" super" 关键字很方便地调用到父类中的方法,但如果要访问祖类的方法呢?

使用 MethodHandle 来解决相关问题.

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

解析执行

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

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

Java 编译器输出的指令流,基本上是一种基于栈的指令集架构。

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

栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。

虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。

基于栈的解析执行过程

在 HotSpot 虚拟机中,有很多以" fast_" 开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,而即时编译器的优化手段更加花样繁多。

参考

深入理解Java虚拟机笔记---运行时栈帧结构

深入理解Java虚拟机笔记---方法调用

JVM笔记 – 虚拟机执行子系统(虚拟机字节码执行引擎)

results matching ""

    No results matching ""