美文网首页我爱编程
深入理解java虚拟机(七)-虚拟机字节码执行引擎

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

作者: 阳光的技术小栈 | 来源:发表于2017-12-02 17:19 被阅读134次

本文基于周志明的《深入理解java虚拟机 JVM高级特性与最佳实践》所写。特此推荐。

运行时栈帧结构

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。每一个栈帧存储了方法的局部变量表、操作数栈、动态链接、方法返回地址和一些附加的额外信息。
对于执行引擎来说,只有处于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与之相关联的方法称为当前方法(Current Method)。在概念模型下,典型的栈帧结构如下图:

栈帧的概念结构

局部变量表

局部标量表 是一组变量值的存储空间,用于存放 方法参数 和 局部变量。在Class 文件的方法表的 Code 属性的 max_locals 指定了该方法所需局部变量表的最大容量。
变量槽 (Variable Slot)是局部变量表的最小单位,没有强制规定大小为 32 位,虽然32位足够存放大部分类型的数据。一个 Slot 可以存放 boolean、byte、char、short、int、float、reference 和 returnAddress 8种类型。其中 reference 表示对一个对象实例的引用,通过它可以得到对象在Java 堆中存放的起始地址的索引和该数据所属数据类型在方法区的类型信息。returnAddress 则指向了一条字节码指令的地址。 对于64位的 long 和 double 变量而言,虚拟机会为其分配两个连续的 Slot 空间。
虚拟机通过索引定位的方式使用局部变量表,范围从0至最大的Slot数量。32位变量,索引n代表第n个Slot;64位变量,则同时使用n和n+1两个Slot。不允许单独访问64位数据的两个Slot的其中的某一个。
方式执行时,如果执行的是实例方法(非static的方法),第0位索引的Slot默认是用于传递方法所属对象实例的引用(关键字“this”来访问),其余参数从1开始排列。
为了节省栈帧空间,局部变量表中的 Slot 是可以重用的。当离开了某些变量的作用域之后,这些变量对应的 Slot 就可以交给其他变量使用。这种机制有时候会影响垃圾回收行为。考虑下面代码(需加上 -verbose:gc参数):

第一段

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

运用结果:

[GC (System.gc())  69468K->66136K(251392K), 0.0011099 secs]
[Full GC (System.gc())  66136K->65944K(251392K), 0.0052083 secs]

分析:
System.gc()后并没有回收64MB的内存。因为在执行System.gc(),变量placeholder还处于作用域之内,虚拟机自然不敢回收placeholder的内存。

第二段

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

运行结果

[GC (System.gc())  69468K->66136K(251392K), 0.0008202 secs]
[Full GC (System.gc())  66136K->65944K(251392K), 0.0047893 secs]

分析:
虽然加了花括号,在执行System.gc()时,placeholder已经不可以再被访问了,但结果64MB的内存还是没有被回收。

第三段

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

运行结果:

[GC (System.gc())  69468K->66072K(251392K), 0.0007405 secs]
[Full GC (System.gc())  66072K->408K(251392K), 0.0045230 secs]

分析:
这次placeholder被回收了,根本原因在于:局部变量表中的Slot是否还存有关于placeholder数组对象的引用。第一段和第二段代码中 placeholder 虽然离开了作用域,但之后没有任何局部变量对其进行读写,也就是说其占用的 Slot 没有被复用,也就是说 placeholder 占用的内存仍然有引用指向它,因而它没有被回收。而第三段代码中的变量a由于复用了 placeholder 的 Slot ,导致 placeholder 引用被删除,因此占用的内存空间被回收。

《Practical Java》一书中把”不使用的对象应手动赋值为 null “作为一条推荐的编码规则,这并不是一个完全没有意义的操作。但是不应该对 赋 null 值有过多的依赖,主要有两点原因

  • 从编码的角度来讲,用恰当的变量作用域来控制变量的回收才是最优雅的解决方法。
  • 从执行角度将,使用赋值 null 的操作优化内存回收是建立在对字节码执行引擎概念模型基础上的,但是概念模型与实际执行模型可能完全不同。在使用解释器执行时,通常离概念模型还比较接近,但是一旦经过JIT 编译为本地代码才是虚拟机执行代码的主要方式,赋 null 值在JIT编译优化之后会被完全消除,这时候赋 null 值是完全没有意义的。(其实,上面代码一在 JIT 编译为本地代码之后,gc() 之后内存也会被自动回收)

操作数栈

操作数栈(Operand Stack)也常称为操作栈,是一个后入先出栈。在Class 文件的Code 属性的 max_stacks 指定了执行过程中最大的栈深度。Java 虚拟机的解释执行引擎称为”基于栈的执行引擎“,这里的栈就是指操作数栈。
方法执行中进行算术运算或者是调用其他的方法进行参数传递的时候是通过操作数栈进行的。
在概念模型中,两个栈帧是相互独立的。但是大多数虚拟机的实现都会进行优化,令两个栈帧出现一部分重叠。令下面的部分操作数栈与上面的局部变量表重叠在一块,这样在方法调用的时候可以共用一部分数据,无需进行额外的参数复制传递。

动态链接

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

方法返回地址

当一个方法开始执行以后,只有两种方法可以退出当前方法:

  • 当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出的方式称为正常完成出口(Normal Method Invocation Completion),一般来说,调用者的PC计数器可以作为返回地址。
  • 当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口(Abrupt Method Invocation Completion),返回地址要通过异常处理器表来确定。

当方法退出(也就是当前栈帧出栈)时,可能执行的操作有:

  • 恢复上层方法的局部变量表和操作数栈
  • 把返回值压入调用者调用者栈帧的操作数栈
  • 调整 PC 计数器的值以指向方法调用指令后面的一条指令

附加信息

虚拟机规范并没有规定具体虚拟机实现包含什么附加信息,这部分的内容完全取决于具体实现。在实际开发中,一般会把动态连接,方法返回地址和附加信息全部归为一类,称为栈帧信息。

方法调用

方法调用的唯一任务就是确定被调用方法的版本。

解析

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可变的,换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。
在java语言中符合这个要求的方法,主要包括静态方法和私有方法。
与之相对应的是,在java虚拟机里面提供了5条方法调用字节码指令,分别如下:

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

只要能被invokestatic与invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法,私有方法,实例构造器和父类方法四类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以统称为非虚方法,与之相反,其它方法就称为虚方法(除去final方法)。
Java中的非虚方法除了使用invokestatic与invokespecial指令调用的方法之后还有一种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其它版本,所以也无须对方法接收都进行多态选择,又或者说多态选择的结果是唯一的。在Java语言规范中明确说明了final方法是一种非虚方法。
解析调用一定是个静态过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派与多分派。这两类分派方式两两组件就构成了静态单分派,静态多分派,动态单分派与动态多分派情况。

分派

静态分派

方法静态分派代码演示如下

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 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!

分析
“Human”称为变量的静态类型( Static Type ) , 或者叫做的外观类型 ( Apparent Type ) , 后面的“Man”则称为变量的实际类型( Actual Type ), 静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此 ,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human) 作为调用目标, 并把这个方法的符号引用写到main() 方法里的两条invokevirtual指令的中 。

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

动态分派

方法动态分派代码演示如下

public class DynamicDispatch {
    static abstract class Human{
        protected abstract void sayHello();
    }

    static class Man extends Human{
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }

    static class Woman extends Human{
        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();
    }
}

运行结果

man say hello
woman say hello
woman say hello

分析
原因就需要从invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:

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

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

单分派与多分派

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

public class Dispatch {
    static class QQ {}
    static class _360 {}

    public static class Father {
        public void hardChoice (QQ arg) {
            System.out.println("father choose qq");
        }

        public void hardChoice(_360 arg){
            System.out.println("father choose 360");
        }
    }

    public static class Son extends Father{
        public void hardChoice(QQ arg){
            System.out.println("son choose qq");
        }

        public void hardChoice(_360 arg){
            System.out.println("son choose 360");
        }
    }

    public static void main(String[] args) {
        Father father = new Father();
        Father son = new Son();
        father.hardChoice(new _360());
        son.hardChoice(new QQ());
    }
}

运行结果

father choose 360
son choose qq

分析
在main函数中调用了两次hardChoice() 方法 ,这两次hardChoice() 方法的选择结果在程序输出中已经显示得很清楚了。
我们来看看编译阶段编译器的选择过程,也就是静态分派的过程。这时选择目标方法的依据有两点: 一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果的最终产物是产生了两条invokevirtual指令 ,两条指令的参数分别为常量池中指向 Father.hardChoice ( 360 ) 及Father.hardChoice ( QQ ) 方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoice ( new QQ ( ) ) ”这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice ( QQ ) , 虛拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ” ,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

根据上述论证的结果,我们可以总结一句:今天(直至Java1.8 )的Java语言是一门静态多分派、动态单分派的语言。

虚拟机动态分派的实现

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

方法表结构

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。图8-3中 ,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法 ,所以它们的方法表中所有从Object继承来的方法都指向了 Object的数据类型。

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

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

分派调用的“稳定优化”手段 ,虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存( Inline Cache )和基于“类型继承关系分析” ( Class Hierarchy Analysis,CHA ) 技术的守护内联( Guarded Mining ) 两种非稳定的“激进优化”手段来获得更高的性能,

动态类型语言支持

动态类型语言

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有很多,常用的包括:APL、Clojure、Erlang、 Groovy、JavaScript、Jython、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk和Tcl等。相对的 ,在编译期就进行类型检查过程的语言(如C++和Java等 )就是最常用的静态类型语言。

一门语言的哪一种检查行为要在运行期进行,哪一种检查要在编译期进行并没有必然的因果逻辑关系,关键是语言规范中人为规定的。

JDK1.7与动态类型

Java虚拟机层面对动态类型语言的支持一直都有所欠缺,主要表现在方法调用方面:JDK 1.7以前的字节码指令集中,4条方法调用指令(invokevirtual、 invokespecial、invokestatic、 invokeinterface ) 的第一个参数都是被调用的方法的符号引用( CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),前面已经提到过 ,方法的符号引用在编译时产生,而动态类型语言只有在运行时才能确定接收者类型。因此在Java虚拟机层面上提供动态类型的直接支持就成为了Java平台的发展趋势之一 ,这就是JDK 1.7 ( JSR-292 ) 中invokedynamic指令以及java.lang.invoke包出现的技术背景。

java.lang.invoke包

JDK1 .7实现了JSR-292,新加入的java.lang.invoke包就是JSR-292的一个重要组成部分 , 这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为MethodHandle。
MethodHandle代码演示如下

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");
    }

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

运行结果

icyfenix

分析
实际上,方法getPrintlnMH()中模拟了invokevirtual指令的执行过程,只不过它的分派逻辑并非固化在Class文件的字节码上,而是通过一个具体方法来实现。而这个方法本身的返回值(MethodHandle对象),可以视为对最终调用方法的一个“引用”。

仅站在Java语言的角度来看,MethodHandle的使用方法和效果与Reflection有众多相似之处,不过,它们还是有以下这些区别:

  • 从本质上讲,Reflection和MethodHandle机制都是在模拟方法调用,但Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在 MethodHandles.lookup中的3个方法——findStatic ( ) 、 fmdVirtual ( ) 、 fmdSpecial ( ) 正是为了对应于invokestatic、 invokevirtual 、invokeinterface和invokespecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用Reflection API时是不需要关心的。
  • Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的 java.lang.invoke.MethodHandle对象所包含的信息多。前者是方法在Java一端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。而后者仅仅包含与执行该方法相关的信息。用通俗的话来讲,Reflection是重量级 ,而MethodHandle是轻量级。
  • 由于MethodHandle是对字节码的方法指令调用的模拟,所以理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前实现还不完善)。而通过反射去调用方法则不行。
  • MethodHandle与Reflection除了上面列举的区别外,最关键的一点还在于去掉前面讨论施加的前提“仅站在Java语言的角度来看” : Reflection API的设计目标是只为Java语言服务的, 而MethodHandle则设计成可服务于所有Java虚拟机之上的语言,其中也包括Java语言。

invokedynamic指令

在某种程度上,invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条“invoke*”指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(包含其他语言的设计者)有更高的自由度。
每一处含有invokedynamic指令的位置都称做“动态调用点” ( Dynamic Call Site ) , 这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量 ,而变为JDK 1.7新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法(Bootstrap Method,此方法存放在新增的BootstrapMethods属性中)、方法类型 ( MethodType ) 和名称。

引导方法是有固定的参数,并且返回A是java.langinvoke.CallSite对象 ,这个代表真正要执行的目标方法调用。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用要执行的目标方法。
演示代码如下

public class InvokeDynamicTest {

    public static void main(String[] args) throws Throwable {
        INDY_BootstarpMethod().invokeExact("icyfenix");
    }

    public static void testMethod(String s){
        System.out.println("hello String:" + s);
    }

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

    private static MethodType MT_BootstrapMethod(){
        return MethodType.fromMethodDescriptorString("(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/" +
                "String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;", null);
    }

    private static MethodHandle MH_BootstarpMethod() throws Throwable{
        return MethodHandles.lookup().findStatic(InvokeDynamicTest.class,"BootstrapMethod",MT_BootstrapMethod());
    }

    private static MethodHandle INDY_BootstarpMethod() throws Throwable{
        CallSite cs = (CallSite) MH_BootstarpMethod().invokeWithArguments(MethodHandles.lookup(),"testMethod",
        MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V", null));
        return cs.dynamicInvoker();
    }
}

运行结果

hello String:icyfenix

分析
从常量池中可见,显示“#123=InvokeDynamic#0 : #121”说明它是一项 CONSTANT_InvokeDynamic_info类型常量,常量值中前面的“#0”代表引导方法取BootstrapMethods属性表的第0项 (javap没备列出属性表的具体内容,不过示例中仅有一个引导方法,即BootstrapMethod() ) , 而后面的“#121”代表引用第121项类型为 CONSTANT_NameAndType_info的常量,从这个常量中可以获取方法名称和描述符,即后面输出的“testMethod : ( Ljava/lang/String ; ) V’。

再看一下BootstrapMethod() ,这个方法Java源码中没有,是INDY产生的,但是它的字节码很容易读懂,所奏逻辑就是调用MethodHandles $Lookup的findStatic ( )方 法 ,产生testMethod ( ) 方法的MethodHandle,然后用它创建一个ConstantCallSite对象。最后,这个对象返回给invokedynamic指令实现对testMethod ( ) 方法的调用,invokedynamic指令的调用过程到此就宣告完成了。

掌控方法分派规则

invokedynamic指令与前面4条“invoke*”指令的最大差别就是它的分派逻辑不是由虚拟机决定的 ,而是由程序员决定。
代码演示(书中的代码,有问题。网友提供了一个版本,可以实现)

class GrandFather{
    void thinking(){
        System.out.println("I am grandfather");
    }
}

class Father extends GrandFather {
    void thinking(){
        System.out.println("I am father");
    }
}

public class Son extends Father {
    void thinking(){
        try {

            MethodType mt = MethodType.methodType(void.class);
            Field IMPL_LOOKUP = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
            IMPL_LOOKUP.setAccessible(true);
            MethodHandles.Lookup lkp = (MethodHandles.Lookup) IMPL_LOOKUP.get(null);
            MethodHandle h1 = lkp.findSpecial(GrandFather.class, "thinking", mt, GrandFather.class);
            h1.invoke(this);

        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
    }

    public static void main(String[] args) {
        (new Son()).thinking();
    }
}

运行结果

I am grandfather

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

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

解释执行

Java语言经常被人们定位为“解释执行”的语言,但只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。编译过程如下图

编译过程

大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤。如果对编译原理的相关课程还有印象的话,很容易就会发现上图中下面那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程,而中间的那条分支,自然就是解释执行的过程。

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

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

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

Java编译器输出的指令流,基本上是一种基于栈的指令集架构( Instruction Set Architecture,ISA ) , 指令流中的指令大部分都是零地址指令,它们包赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集 ,说得通俗一些,就是现在我们主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作。
基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。

如果使用栈架构的指令集,用户程序不会直接使用这些寄存器 ,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等) 放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。栈架构的指令集还有一 些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作 ) 等。

栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。

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

基于栈的解释器执行过程

代码演示如下

    public int calc(){
        int a = 100;
        int b = 200;
        int c = 300;
        return (a + b) * c;
    }

使用javap命令看看它的字节码指令

解释器执行过程字节码演示

javap提示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间,以下7张图,用它们来描述代码执行过程中的代码、操作数栈和局部变量表的变化情况。

执行偏移地址为0的指令的情况
执行偏移地址为1的指令的情况
执行偏移地址为11的指令的情况
执行偏移地址为12的指令的情况.jpg
执行偏移地址为13的指令的情况
执行偏移地址为14的指令的情况
执行偏移地址为16的指令的情况

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

不过 ,我们从这段程序的执行中也可以看出栈结构指令集的一般运行过程,整个运算过程的中间变量都以操作数栈的出栈、入栈为信息交换途径,符合我们在前面分析的特点。

相关文章

网友评论

    本文标题:深入理解java虚拟机(七)-虚拟机字节码执行引擎

    本文链接:https://www.haomeiwen.com/subject/oefpvxtx.html