zoukankan      html  css  js  c++  java
  • 虚拟机字节码执行引擎

    1、运行时栈帧结构

    在Java虚拟机内存结构中介绍了虚拟机栈,也说明了栈帧是虚拟机栈的构成元素,但没有具体介绍栈帧的细节。栈帧是虚拟机栈的构成元素,每一个栈帧对应一个方法调用,入栈和出栈操作就相当于方法的调用与退出。每一个栈帧都包含了局部变量表、操作数栈、动态连接、方法返回地址和其它的附加信息。在介绍Class文件结构的时候,我们知道了在编译的时候就知道了栈帧中需要多大的局部变量表,多深的操作数栈,并写入了方法表的Code属性中。所以,一个栈帧需要多大的内存,不会受运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

    我们知道,虚拟机栈是线程私有的,也就是说每一个线程都有自己的虚拟机栈。在多线程中会有变量共享导致的同步问题,这是因为线程共享的对象存储在Java堆中,而Java堆是线程共享的。这样,线程私有的虚拟机栈就没有了多线程的问题。这里也仅仅是讨论单线程的情况。对于单线程来说,程序的执行是线性的,所以如果这个线程是活动的,那么只有栈顶的栈帧处于运行状态,这个栈帧称为当前栈帧,与这个栈帧相关联的方法叫做当前方法。执行引擎的所有字节码指令都是针对当前栈帧进行操作的。

    下图是栈帧的概念结构:

    接下来按照上图的结构介绍一下栈帧中局部变量表、操作栈、动态连接和放回地址等各个结构的功能与结构。

    1.1、局部变量表

    局部变量表是一组存储变量值的存储空间,来存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。

    在介绍HotSpot虚拟机管理内存对象时了解到,在虚拟机内存中最小的空间单位是slot,但是虚拟机规范并没有规定一个slot要占多大的空间,只是说每个slot都应该能放下一个boolean、byte、char、int、float、reference或returnAddress类型的数据,也就是说这些类型的变量需要一个slot来存储。

    上面这些类型都可以用32位来存储,但Java中还有64位的类型,比如long和double。这时就需要两个slot来存储long和double类型的数据了,然后以高位对齐的方式分配。

    虚拟机通过索引定位的方式来使用局部变量表,索引的范围是从0开始至局部变量表最大的slot数量-1,也就是说,局部变量表可以看成是一个数组,每个数组的大小是一个slot,可以通过下标来定位每一个局部变量。但是对于64位的long或double类型数据,在定位时要使用两个索引,而且不能单独访问其中的一个,这种操作会在类加载中的校验阶段禁止。

    在方法的执行时,虚拟机是使用局部变量表来完成参数值到参数变量列表的传递过程的,如果执行的实例方法(即没有static修饰),那局部变量表中的第一个slot中存放的是方法所属的实例的引用,即this,这是一个隐含的参数,即使方法中没有显式的定义参数,这个实例方法也有这个this参数。然后方法的其他参数按照顺序存储在局部变量表从下标为1开始的地方。如果是类方法(即static修饰的方法),就没有this参数。下图就是方法的参数存放到局部变量表的示意图:

    其实,在局部变量表中要注意的一点就是,并不是方法中所有局部变量的总大小就是局部变量表的大小,因为在局部变量表中会有空间重用。因为,在方法体中定义的变量,它的作用域不会是整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那这个变量的slot就可以给其它变量使用。这样的设计不但会节省局部变量表的空间,还会影响到垃圾回收的行为。

    下面的三个例子演示了局部变量表中的slot重用对垃圾回收行为的影响。

    (1)GC不回收仍处于作用域的变量

    代码如下:

    [java] view plain copy
     
    1. public class GCTest {  
    2.     @SuppressWarnings("unused")  
    3.     public static void main(String[] args) {  
    4.         byte[] b=new byte[64*1024*1024];  
    5.         System.gc();  
    6.     }  
    7. }  


    在虚拟机运行参数中设置“-verbose:gc”,可以查看垃圾收集的过程。代码中为了占位,定义了一个64MB的对象,在显式调用系统的垃圾收集机制后,结果如下:

    [GC 66857K->66168K(250880K), 0.0010805 secs]
    [Full GC 66168K->66008K(250880K), 0.0083561 secs]

    可以看到,System.gc()并没有收集这个64MB的对象,这是因为这个对象还处于作用域中。

    (2)GC也有可能不回收不在作用域中的对象

    接下来,代码修改如下:

    [java] view plain copy
     
    1. public class GCTest {  
    2.     @SuppressWarnings("unused")  
    3.     public static void main(String[] args) {  
    4.         {  
    5.             byte[] b=new byte[64*1024*1024];  
    6.         }  
    7.         System.gc();  
    8.     }  
    9. }  


    这时,在调用System.gc()时,变量b已经不在作用域了,结果如下:

    [GC 66857K->66216K(250880K), 0.0009166 secs]
    [Full GC 66216K->66008K(250880K), 0.0074272 secs]

    疑惑的是,不在作用域中的变量仍然没有被回收。

    (3)slot重用会影响垃圾回收

    然后,修改代码如下:

    [java] view plain copy
     
    1. public class GCTest {  
    2.     @SuppressWarnings("unused")  
    3.     public static void main(String[] args) {  
    4.         {  
    5.             byte[] b=new byte[64*1024*1024];  
    6.         }  
    7.         int a=1;  
    8.         System.gc();  
    9.     }  
    10. }  


    这里仅仅加入一个变量定义,结果如下:

    [GC 66857K->66152K(250880K), 0.0009012 secs]
    [Full GC 66152K->472K(250880K), 0.0073830 secs]

    可以看到,这时回收了那个64MB的对象。所以,对象b能否被回收的依据是:局部变量表中的slot是否还存有关于b数组对象的引用。

    第一次修改时,对象b虽然离开了作用域,但是在此之后,没有任何对局部变量表的读写操作,b原本所占用的slot还没有被其它变量重用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。这种关联没有被及时打断,在绝大多数情况下没有什么影响。

    关于局部变量表,还需要注意的一点就是,局部变量表并不会像类变量那样有准备阶段。在类的加载机制中,我们已经知道,类变量在加载中会经历两个初始化过程。第一个是在准备阶段,变量会赋值为系统初始值,即零值;另一个是在初始化阶段,会给变量赋代码中定义的值。因此,即使在初始化阶段没有为类变量赋值也没有关系,因为类变量至少有一个系统初始值。但局部变量就不一样了,一个没有赋初始值的局部变量是不能使用的,因为局部变量没有赋系统初始值的准备阶段。比如下面的代码就不能编译:

    [java] view plain copy
     
    1. public static void main(String[] args){  
    2.     int a;  
    3.     System.out.println(a);  
    4. }  


    即使手动生成一个这样的字节码文件而跳过编译检查,在字节码校验阶段也会被虚拟机发现而导致类加载失败。

    1.2、操作数栈

    操作数栈也叫操作栈,这就是一个后入先出的栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈中可以存放任何类型的数据,32位数据的栈容量是1,,64位数据的栈容量是2。在方法执行的过程中,操作数栈的深度都不会超过max_stacks数据项中所设定的最大值。

    在方法开始执行的时候,操作数栈是空的,随着方法的执行,会有各种字节码指令向操作数栈中写入和提取数据,也就是出栈和入栈操作。比如,iadd指令将栈顶的两个元素去除,计算两个数的和,然后将结果入栈。

    操作数栈中元素的数据类型必须与字节码指令的序列完全匹配,在编译程序代码的时候编译器就会要求这一点,在类校验的时候还会进行检查。

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

    Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,这里的栈就是操作数栈。

    1.3、动态连接

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

    1.4、方法返回地址

    方法的退出一共有两种方式。第一种是正常退出,这时是执行引擎遇到了任意一个方法返回的字节码指令。第二种方式是在方法执行的过程中出现了异常,不管是Java虚拟机内部产生的异常还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,并不会给调用者返回值。

    在方法退出后,需要返回到方法被调用的地方,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。

    方法退出的过程实际上就是当前栈帧出栈,之后的动作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令。

    1.5、附加信息

    虚拟机规范允许具体的虚拟机实现增加一些额外的信息到栈帧中,比如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接、方法返回地址和其它附加信息归为栈帧信息。

    2、方法调用

    在介绍Class文件的时候我们知道,Class文件的编译过程并不包含传统编译的连接阶段,Class文件中方法都是以符号引用的形式存储的,而不是方法的入口地址。这个特性使得Java具有强大的动态扩展的能力,但同时也增加了Java方法调用过程的复杂性,因为方法需要在类加载期间甚至是运行时才能确定真正的入口地址,即将符号引用转换为直接引用。

    这里所说的方法调用并不等同于方法执行,这个阶段的唯一目的就是确定被调用方法的版本,还不涉及方法内部的具体运行过程。对于方法的版本,需要解释的就是由于重载与多态的存在,一个符号引用可能对应多个真正的方法,这就是方法的版本。

    在Java虚拟机中提供了5条方法调用的字节码指令,分别是:

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

    只要能被invokestatic和invokespecial指令调用的方法,都可以在类加载过程中的解析阶段中确定唯一的调用版本,符合这个条件的方法有静态方法、私有方法、实例构造器和父类方法四种,它们在类加载过程中的解析阶段就会将符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之对应的就是虚方法(除去final方法,后面会有介绍)。虚方法需要在运行阶段才能确定目标方法的直接引用。这样,对于方法的调用就分为两种,一种可以在类加载过程中的解析阶段完成,另一种要在运行时完成,叫做分派。

    2.1、解析

    解析的过程就是在类加载过程中的解析阶段。在类加载过程中,我们知道解析阶段就是将符号引用转换为直接引用的过程,那个时候的解析阶段解析了类或接口、字段、类方法和接口方法。在这个阶段,会将Class文件中的一部分方法的符号引用解析为直接引用,这种解析能够成立的条件是,方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的电泳版本在运行期间是不变的。也就是说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。

    这样的方法有静态方法、私有方法、实例构造器和父类方法,这些方法的特点决定了它们都不可能通过继承或别的方式重写版本,所以这些方法适合在类加载阶段进行解析。

    下面的代码演示了一个最常见的解析调用的例子,代码如下:

    [java] view plain copy
     
    1. public class StaticResolution {  
    2.     public static void sayHello(String name){  
    3.         System.out.println("Hello, "+name);  
    4.     }  
    5.     public static void main(String[] args) {  
    6.         StaticResolution.sayHello("Liu");  
    7.     }  
    8. }  


    方法sayHello是一个静态方法,是属于类StaticResolution的方法,没有任何手段能够覆盖或隐藏这个方法。

    使用程序编译后,可以使用javap -verbose StaticResolution指令得到这个类的字节码指令,部分内容如下:

    [java] view plain copy
     
    1. public static void main(java.lang.String[]);  
    2.   descriptor: ([Ljava/lang/String;)V  
    3.   flags: ACC_PUBLIC, ACC_STATIC  
    4.   Code:  
    5.     stack=1, locals=1, args_size=1  
    6.        0: ldc           #45                 // String Liu  
    7.        2: invokestatic  #47                 // Method sayHello:(Ljava/lang/String;)V  
    8.        5: return  
    9.     LineNumberTable:  
    10.       line 8: 0  
    11.       line 9: 5  
    12.     LocalVariableTable:  
    13.       Start  Length  Slot  Name   Signature  
    14.           0       6     0  args   [Ljava/lang/String;  


    可以看到,在main主方法中对类方法sayHello的调用确实是使用了invokestatic指令。

    Java中的非虚方法除了使用invokestatic、invokespecial指令调用方法之外还有一种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也不需要对方法接受者进行多态选择,所以在Java虚拟机规范中明确说明final方法是一种非虚方法。

    解析调用是一个静态的过程,在编译期间就已经完全确定,在类加载的解析阶段就会把涉及到的符号引用转化为直接引用,不会延迟到运行期再去完成。而分派调用则既可能是静态的也可能是动态的,根据分派的宗量数可以分为单分派和多分派,这两类分派方法的两两组合就构成了静态单分派、静态多分派、动态单分派和动态多分派四种,接下来就看看分派是如何进行的。

    2.2、分派

    Java是一门面向对象的语言,它具备三个主要的面向对象特征:继承、封装和多态。正是由于多态的存在,使得在判断方法调用的版本的时候会存在选择的问题,这也正是分派阶段存在的原因。这一部分会在Java虚拟机的角度介绍“重载”和“重写”的底层实现原理(重载为静态分派,也是多分派; 重写是动态分派,也是单分派)。

    上面已经提到过分派一共有四种,下面就按照这四种进行介绍。

    (1)静态分派

    首先看看下面这个代码,涉及到了正是继承中的重载问题。

    [java] view plain copy
     
    1. public class StaticDispatch {  
    2.     static class Human{  
    3.           
    4.     }  
    5.     static class Man extends Human{  
    6.           
    7.     }  
    8.     static class Woman extends Human{  
    9.           
    10.     }  
    11.     public void sayHello(Human human){  
    12.         System.out.println("hello,guy!");  
    13.     }  
    14.     public void sayHello(Man man){  
    15.         System.out.println("Hello,gentleman!");  
    16.     }  
    17.     public void sayHello(Woman woman){  
    18.         System.out.println("Hello,lady!");  
    19.     }  
    20.     public static void main(String[] args) {  
    21.         Human man=new Man();  
    22.         Human woman=new Woman();  
    23.         StaticDispatch sr=new StaticDispatch();  
    24.         sr.sayHello(man);  
    25.         sr.sayHello(woman);  
    26.     }  
    27. }  


    运行结果如下:

    hello,guy!
    hello,guy!

    这是考察多态的经典问题。要想了解这个问题的本质,需要知道这两个概念:静态类型和实际类型。

    什么是静态类型?静态类型可以理解为变量声明的类型,比如上面的man这个变量,它的静态类型就是Human。而实际类型就是创建这个对象的类型,man这个变量的实际类型就是Man。这两种类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会发生变化,并且最终的静态类型是编译期间可知的。而实际类型变化的结果在运行期才可以确定,编译器在编译程序时并不知道一个对象的实际类型是什么。比如下面的代码:

    [java] view plain copy
     
    1. //实际类型变化  
    2. Human man=new Man();  
    3. man=new Woman();  
    4. //静态类型变化  
    5. sr.sayHello((Man)man);  
    6. sr.sayHello((Woman)man);  


    了解了这两个概念之后,回头看看上面的代码。main方法里main的两次sayHello方法调用,在方法接收者已经确定是对象sr的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。但是这里的代码定义了两个静态类型相同但实际类型不同的变量,编译器在重载时是通过静态类型而不是实际类型作为判断依据的。并且静态类型是编译期间可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)这个版本作为调用目标,并把这个方法的符号引用写到main方法里的invokevirtual指令的参数中。这一点我们可以使用javap -verbose命令得到的字节码文件中的main方法的字节码指令中得到验证,结果如下图:

    所有依赖静态类型来定位方法版本的分派动作叫做静态分派,静态分派的典型应用是方法重载。静态分派发生在编译期间,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是唯一的,往往只是一个相对来说更加合适的版本。接下来以一个重载的例子说明这个“更加合适”的情况,代码如下:

    [java] view plain copy
     
    1. public class Overload {  
    2.     public static void sayHello(Object arg){  
    3.         System.out.println("Hello Object");  
    4.     }  
    5.     public static void sayHello(int arg){  
    6.         System.out.println("Hello Int");  
    7.     }  
    8.     public static void sayHello(long arg){  
    9.         System.out.println("Hello Long");  
    10.     }  
    11.     public static void sayHello(Character arg){  
    12.         System.out.println("Hello Character");  
    13.     }  
    14.     public static void sayHello(char arg){  
    15.         System.out.println("Hello Char");  
    16.     }  
    17.     public static void sayHello(char...arg){  
    18.         System.out.println("Hello Char ...");  
    19.     }  
    20.     public static void sayHello(Serializable arg){  
    21.         System.out.println("Hello Serializable");  
    22.     }  
    23.     public static void main(String[] args) {  
    24.         sayHello('a');  
    25.     }  
    26. }  


    执行后的结果是:Hello Char

    这很好理解,毕竟重载的方法中就有一个参数类型是char的方法。main方法的字节码中invokevirtual指令如下:

    2: invokestatic  #58                 // Method sayHello:(C)V

    可以看到,正是调用了参数是char类型的版本。

    但是如果将这个方法删除呢?

    结果变成了Hello Int。这就是说在确定方法时,如果静态类型没有匹配的,可以发生类型转换,这里将'a'转换为了数字97,然后调用参数是int类型的版本。

    接着,去掉参数是int的方法,结果是Hello Long。这又发生了一次类型转换,将97转换为了long。

    这种类型转换会按照char->int->long->float->double的顺序继续下去。但不会转换到byte和short,因为这种转换是不安全的。

    继续注释掉参数是long类型的版本,结果为:Hello Character。发生了一次自动装箱,将char类型的参数装箱为Character类型。继续注释掉这个版本后,结果为:

    Hello Serializable

    这个时候找不到了装箱类,但是找到了装箱类Character实现的一个接口Serializable,所以又发生了一次自动转换。Character类还是实现了一个接口Comparable<Character>,如果同时出现两个参数分别是Serbializable和Comparable<Character>的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转换为哪个类型,会拒绝编译。这时需要在调用时显式指出字面量的静态类型,如sayHello(Comparable<Character>'a')才可以。如果继续注释,结果是:

    Hello Object

    这时转换为父类Object,如果有多个父类,那就从下往上搜索,越接近上层优先级越低。继续注释,结果是:

    Hello Char ...

    可见边长参数的重载优先级是最低的。

    上面演示了编译期间选择静态分派的目标的过程,这也是Java语言实现方法重载的本质。

    (2)动态分派

    在了解了静态分派后,再看看动态分派的过程,它和多态性的另一个重要的特性重写有关。下面用一个例子来介绍,代码如下:

    [java] view plain copy
     
    1. public class DynamicDispatch {  
    2.     static abstract class Human{  
    3.         protected abstract void sayHello();  
    4.     }  
    5.     static class Man extends Human{  
    6.         public void sayHello(){  
    7.             System.out.println("Hello gentleman");  
    8.         }  
    9.     }  
    10.     static class Woman extends Human{  
    11.         public void sayHello(){  
    12.             System.out.println("Hello lady");  
    13.         }  
    14.     }  
    15.     public static void main(String[] args) {  
    16.         Human man=new Man();  
    17.         Human woman=new Woman();  
    18.         man.sayHello();  
    19.         woman.sayHello();  
    20.         man=new Woman();  
    21.         man.sayHello();  
    22.     }  
    23. }  


    结果如下:

    Hello gentleman
    Hello lady
    Hello lady

    这个结果对于熟悉Java面向对象编程的人来说都不陌生。这里要说明的是,虚拟机是如何知道要调用哪个版本的。

    显然这不是根据静态类型决定的,因为两个对象的静态类型都是Human。但是调用的结果却不同,这是因为这两个对象的实际类型不同。所以,Java虚拟机是通过实际类型来判断要调用方法的版本的。

    不过Java虚拟机又是如何做到的呢?使用javap -verbose命令得到main方法的字节码指令如下:

    [java] view plain copy
     
    1. public static void main(java.lang.String[]);  
    2.     descriptor: ([Ljava/lang/String;)V  
    3.     flags: ACC_PUBLIC, ACC_STATIC  
    4.     Code:  
    5.       stack=2, locals=3, args_size=1  
    6.          0: new           #16                 // class ch08/DynamicDispatch$Man  
    7.          3: dup  
    8.          4: invokespecial #18                 // Method ch08/DynamicDispatch$Man."<init>":()V  
    9.          7: astore_1  
    10.          8: new           #19                 // class ch08/DynamicDispatch$Woman  
    11.         11: dup  
    12.         12: invokespecial #21                 // Method ch08/DynamicDispatch$Woman."<init>":()V  
    13.         15: astore_2  
    14.         16: aload_1  
    15.         17: invokevirtual #22                 // Method ch08/DynamicDispatch$Human.sayHello:()V  
    16.         20: aload_2  
    17.         21: invokevirtual #22                 // Method ch08/DynamicDispatch$Human.sayHello:()V  
    18.         24: new           #19                 // class ch08/DynamicDispatch$Woman  
    19.         27: dup  
    20.         28: invokespecial #21                 // Method ch08/DynamicDispatch$Woman."<init>":()V  
    21.         31: astore_1  
    22.         32: aload_1  
    23.         33: invokevirtual #22                 // Method ch08/DynamicDispatch$Human.sayHello:()V  
    24.         36: return  
    25.       LineNumberTable:  
    26.         line 18: 0  
    27.         line 19: 8  
    28.         line 20: 16  
    29.         line 21: 20  
    30.         line 22: 24  
    31.         line 23: 32  
    32.         line 24: 36  
    33.       LocalVariableTable:  
    34.         Start  Length  Slot  Name   Signature  
    35.             0      37     0  args   [Ljava/lang/String;  
    36.             8      29     1   man   Lch08/DynamicDispatch$Human;  
    37.            16      21     2 woman   Lch08/DynamicDispatch$Human;  


    0~15行是准备阶段,是为了建立man和woman的内存空间,调用man和woman的类实例构造器,然后将这两个实例的引用放在局部变量表中的第一和第二的位置。

    接下来的16~21是方法调用的关键。16、20两句分别把刚才创建的两个对象的引用压入栈顶,这两个对象是将要执行的sayHello方法的所有者,称为接收者;17和21两句诗方法调用指令,这两条指令在这里看来都是一样的,指令都是invokevirtual,参数也都是一样的,但这两条指令最终执行的结果却不同。原因就在invokevirtual指令的多态查找过程上。invokevirtual指令的运行时解析过程大致分为以下几个步骤:

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

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

    (3)单分派与多分派

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

    下面以一个例子介绍一下单分派或多分派,代码如下:

    [java] view plain copy
     
    1. public class Dispatch {  
    2.     static class Pepsi{}  
    3.     static class Coca{}  
    4.       
    5.     public static class Father{  
    6.         public void like(Pepsi p){  
    7.             System.out.println("Father likes pepsi");  
    8.         }  
    9.         public void like(Coca c){  
    10.             System.out.println("Father likes coca");  
    11.         }  
    12.     }  
    13.     public static class Son extends Father{  
    14.         public void like(Pepsi p){  
    15.             System.out.println("Son likes pepsi");  
    16.         }  
    17.         public void like(Coca c){  
    18.             System.out.println("Son likes coca");  
    19.         }  
    20.     }  
    21.     public static void main(String[] args) {  
    22.         Father father=new Father();  
    23.         Son son=new Son();  
    24.         father.like(new Coca());  
    25.         son.like(new Pepsi());  
    26.     }  
    27. }  


    结果如下:

    Father likes coca
    Son likes pepsi

    这个结果没有什么意外的地方,主要是看一下虚拟机是如何确定方法调用的版本的。

    先看看静态分派过程,这个时候选择的依据有两个:静态类型是Father还是Son,方法参数是Pepsi还是Coca。这次选择产生了两个invokevirtual指令,两条指令的参数分别为常量池中指向Father.like(Coca)和Father.like(Pepsi)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型

    然后看看运行时虚拟机的选择,即动态分派过程。在执行son.like(new Pepsi())时,也就是说在执行invokevirtual指令时,由于编译期间已经决定目标方法的签名必须是like(Pepsi),虚拟机此时不会关心传递过来的参数是什么,因为这时参数的静态类型、实际类型都对方法的选择不会构成影响,唯一有影响的就是方法的接收者的实际类型是Father还是Son。因为只有一个宗量,所以Java的动态分派属于单分派

    (4)虚拟机动态分派的实现

    由于动态分派是非常频繁的操作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此虚拟机会进行优化。常用的方法就是为类在方法区中建立一个虚方法表(Virtual Method Table,在invokeinterface执行时也会用到接口方法表,Interface Method Table),使用虚方法表索引来替代元数据查找以提升性能。下图就是前面代码的虚方法表结构:

    虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类重写了父类的方法,子类方法表中的地址会替换为指向子类实现版本的入口地址。在上图中,Son重写了Father的全部方法,所以Son的方法表替换了父类的地址。但是Son和Father都没有重写Object的方法,所以方法表都指向了Object的数据类型。

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

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

    3、动态语言类型的支持

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

    1. public static void main(String[] args) {   
    2.     int[][][] array = new int[1][0][-1];   
    3. }   

      这段代码能够正常编译,但运行的时候会报NegativeArraySizeException异常。在《Java虚拟机规范》中明确规定了NegativeArraySizeException是一个运行时异常,通俗一点说,运行时异常就是只要代码不运行到这一行就不会有问题。与运行时异常相对应的是连接时异常,例如很常见的NoClassDefFoundError便属于连接时异常,即使会导致连接时异常的代码放在一条无法执行到的分支路径上,类加载时(Java的连接过程不在编译阶段,而在类加载阶段)也照样会抛出异常。 
      不过,在C语言里,含义相同的代码的代码就会在编译期报错: 

    C++代码  收藏代码
    1. int main(void) {  
    2.     int i[1][0][-1]; // GCC拒绝编译,报“size of array is negative”   
    3.     return 0;  
    4. }  

      由此看来,一门语言的哪一种检查行为要在运行期进行,哪一种检查要在编译期进行并没有必然的因果逻辑关系,关键是在语言规范中人为规定的,再举一个例子来解释“类型检查”,例如下面这一句再普通不过的代码: 

    Code代码  收藏代码
    1. obj.println(“hello world”);   

      显然,这行代码需要一个具体的上下文才有讨论的意义,假设它在Java语言中,并且变量obj的类型为java.io.PrintStream,那obj的值就必须是PrintStream的子类(实现了PrintStream接口的类)才是合法的。否则,哪怕obj属于一个确实有用println(String)方法,但与PrintStream接口没有继承关系,代码依然不能运行——因为类型检查不合法。 
      但是相同的代码在ECMAScript(JavaScript)中情况则不一样,无论obj具体是何种类型,只要这种类型的定义中确实包含有println(String)方法,那方法调用便可成功。 
      这种差别产生的原因是Java语言在编译期间却已将println(String)方法完整的符号引用(本例中为一项CONSTANT_InterfaceMethodref_info常量)生成出来,作为方法调用指令的参数存储到Class文件中,例如下面这个样子: 

    Bytecode代码  收藏代码
    1. invokevirtual #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V   

      这个符号引用包含了此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,虚拟机就可以翻译出这个方法的直接引用(譬如方法内存地址或者其他实现形式)。而在ECMAScript等动态类型语言中,变量obj本身是没有类型的,变量obj的值才具有的类型,编译时候最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型(方法接收者不固定)。“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个重要特征。 

    java.lang.invoke包 
      JDK 7实现了JSR 292 《Supporting Dynamically Typed Languages on the Java Platform》,新加入的java.lang.invoke包[注3]是就是JSR 292的一个重要组成部分,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为Method Handle。这个表达也不好懂?那不妨把Method Handle与C/C++中的Function Pointer,或者C#里面的Delegate类比一下。举个例子,如果我们要实现一个带谓词的排序函数,在C/C++中常用做法是把谓词定义为函数,用函数指针来把谓词传递到排序方法,像这样: 

    C代码  收藏代码
    1. void sort(int list[], const int size, int (*compare)(int, int))   

      但Java语言中做不到这一点,没有办法单独把一个函数作为参数进行传递。普遍的做法是设计一个带有compare()方法的Comparator接口,以实现了这个接口的对象作为参数,例如Collections.sort()就是这样定义的: 

    Java代码  收藏代码
    1. void sort(List list, Comparator c)  

      不过,在拥有Method Handle之后,Java语言也可以拥有类似于函数指针或者委托的方法别名的工具了。下面代码演示了MethodHandle的基本用途,无论obj是何种类型(临时定义的ClassA抑或是实现PrintStream接口的实现类System.out),都可以正确调用到println()方法。 

    Java代码  收藏代码
    1. import static java.lang.invoke.MethodHandles.lookup;  
    2. import java.lang.invoke.MethodHandle;  
    3. import java.lang.invoke.MethodType;  
    4. /**  
    5.  * JSR 292 MethodHandle基础用法演示 
    6.  * @author IcyFenix 
    7.  */  
    8. public class MethodHandleTest {  
    9.     static class ClassA {  
    10.         public void println(String s) {  
    11.             System.out.println(s);  
    12.         }  
    13.     }  
    14.     public static void main(String[] args) throws Throwable {  
    15.         Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();  
    16.         // 无论obj最终是哪个实现类,下面这句都能正确调用到println方法。   
    17.         getPrintlnMH(obj).invokeExact("icyfenix");  
    18.     }  
    19.     private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable {  
    20.         // MethodType:代表“方法类型”,包含了方法的返回值(methodType()的第一个参数)和具体参数(methodType()第二个及以后的参数)。   
    21.         MethodType mt = MethodType.methodType(void.class, String.class);  
    22.         // lookup()方法来自于MethodHandles.lookup,这句的作用是在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄。   
    23.         // 因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,也即是this指向的对象,这个参数以前是放在参数列表中进行传递,现在提供了bindTo()方法来完成这件事情。   
    24.         return lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);  
    25.     }  
    26. }  

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

    Java代码  收藏代码
    1. void sort(List list, MethodHandle compare)   

      从上面的例子看来,使用MethodHandle并没有多少困难,不过看完它的用法之后,读者大概就会疑问到,相同的事情,用反射不是早就可以实现了吗? 
      确实,仅站在Java语言的角度看,MethodHandle的使用方法和效果上与Reflection都有众多相似之处。不过,它们也有以下这些区别: 

    • Reflection和MethodHandle机制本质上都是在模拟方法调用,但是Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在MethodHandles.Lookup上的三个方法findStatic()、findVirtual()、findSpecial()正是为了对应于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指令 
      本文一开始就提到了JDK 7为了更好地支持动态类型语言,引入了第五条方法调用的字节码指令invokedynamic,但前面一直没有再提到它,甚至把之前使用MethodHandle的示例代码反编译后也不会看见invokedynamic的身影,它到底有什么应用呢? 
      某种程度上可以说invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有四条invoke*指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(包含其他语言的设计者)有更高的自由度。而且,它们两者的思路也是可类比的,可以想象作为了达成同一个目的,一个用上层代码和API来实现,另一个是用字节码和Class中其他属性和常量来完成。因此,如果前面MethodHandle的例子看懂了,理解invokedynamic指令并不困难。 
      每一处含有invokedynamic指令的位置都被称作“动态调用点(Dynamic Call Site)”,这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK 7新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法(Bootstrap Method,此方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。引导方法是有固定的参数,并且返回值是java.lang.invoke.CallSite对象,这个代表真正要执行的目标方法调用。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用要执行的目标方法上。我们还是照例拿一个实际例子来解释这个过程吧。如下面代码清单所示: 

    Java代码  收藏代码
    1. import static java.lang.invoke.MethodHandles.lookup;  
    2. import java.lang.invoke.CallSite;  
    3. import java.lang.invoke.ConstantCallSite;  
    4. import java.lang.invoke.MethodHandle;  
    5. import java.lang.invoke.MethodHandles;  
    6. import java.lang.invoke.MethodType;  
    7. public class InvokeDynamicTest {  
    8.     public static void main(String[] args) throws Throwable {  
    9.         INDY_BootstrapMethod().invokeExact("icyfenix");  
    10.     }  
    11.     public static void testMethod(String s) {  
    12.         System.out.println("hello String:" + s);  
    13.     }  
    14.     public static CallSite BootstrapMethod(MethodHandles.Lookup lookup, String name, MethodType mt) throws Throwable {  
    15.         return new ConstantCallSite(lookup.findStatic(InvokeDynamicTest.class, name, mt));  
    16.     }  
    17.     private static MethodType MT_BootstrapMethod() {  
    18.         return MethodType.fromMethodDescriptorString("(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;", null);  
    19.     }  
    20.     private static MethodHandle MH_BootstrapMethod() throws Throwable {  
    21.         return lookup().findStatic(InvokeDynamicTest.class, "BootstrapMethod", MT_BootstrapMethod());  
    22.     }  
    23.     private static MethodHandle INDY_BootstrapMethod() throws Throwable {  
    24.         CallSite cs = (CallSite) MH_BootstrapMethod().invokeWithArguments(lookup(), "testMethod", MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V", null));  
    25.         return cs.dynamicInvoker();  
    26.     }  
    27. }  

      由于目前光靠Java语言的编译器javac没有办法生成带有invokedynamic 指令的字节码(曾经有一个java.dyn.InvokeDynamic的语法糖可以实现,但后来被取消了),所以只能用一些变通的办法,John Rose(Da Vinci Machine Project的Leader)编写了一个把程序的字节码转换为使用invokedynamic的简单工具INDY[注4]来完成这件事情,我们要使用这个工具来产生最终要的字节码,因此这个示例代码中的方法名称不能乱改,更不能把几个方法合并到一起写。
      把上面代码编译、转换后重新生成的字节码如下(结果使用javap输出,因版面原因,精简了许多无关的内容): 

    Bytecode代码  收藏代码
    1. Constant pool:   
    2.     #121 = NameAndType #33:#30 // testMethod:(Ljava/lang/String;)V   
    3.     #123 = InvokeDynamic #0:#121 // #0:testMethod:(Ljava/lang/String;)V   
    4.     public static void main(java.lang.String[]) throws java.lang.Throwable;   
    5. Code:   
    6.     stack=2, locals=1, args_size=1   
    7. 0: ldc #23 // String abc   
    8. 2: invokedynamic #123, 0 // InvokeDynamic #0:testMethod:(Ljava/lang/String;)V   
    9. 7: nop   
    10. 8: return   
    11.     public static java.lang.invoke.CallSite BootstrapMethod(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType) throws java.lang.Throwable;   
    12. Code:   
    13.     stack=6, locals=3, args_size=3   
    14. 0: new #63 // class java/lang/invoke/ConstantCallSite   
    15. 3: dup   
    16. 4: aload_0   
    17. 5: ldc #1 // class org/fenixsoft/InvokeDynamicTest   
    18. 7: aload_1   
    19. 8: aload_2   
    20. 9: invokevirtual #65 // Method         java/lang/invoke/MethodHandles$Lookup.findStatic:(Ljava/lang/Class;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/MethodHandle;   
    21. 12: invokespecial #71 // Method java/lang/invoke/ConstantCallSite."<init>":(Ljava/lang/invoke/MethodHandle;)V   
    22. 15: areturn   

      从main()方法的字节码中可见,原本的方法调用指令已经被替换为invokedynamic了,它的参数为第123项常量(第二个值为0的参数在HotSpot中用不到,与invokeinterface那个的值为0的参数一样是占位的): 

    Bytecode代码  收藏代码
    1. 2: invokedynamic #123, 0 // InvokeDynamic #0:testMethod:(Ljava/lang/String;)V   

      从常量池中可见,第123项常量显示“#123 = InvokeDynamic #0:#121”说明它是一项CONSTANT_InvokeDynamic_info类型常量,常量值中前面“#0”代表引导方法取BootstrapMethods属性表的第0项(javap没有列出属性表的具体内容,不过示例中仅有一个引导方法,即BootstrapMethod()),而后面的“#121”代表引用第121项类型为CONSTANT_NameAndType_info的常量,从个常量中可以获取方法名称和描述符,既后面输出的“testMethod:(Ljava/lang/String;)V”。 
      再看BootstrapMethod(),它的字节码很容易读懂,所有逻辑就是调用MethodHandles$Lookup的findStatic()方法,产生testMethod()方法的MethodHandle,然后用它创建一个ConstantCallSite对象。最后,这个对象返回给invokedynamic指令实现对testMethod()方法的调用,invokedynamic指令的调用过程到此就宣告完成了。 

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

    在了解了虚拟机是如何调用方法之后,接下来看看虚拟机是如何执行字节码中的指令的。Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。这和常用的基于寄存器的指令集有一些区别,比如典型的x86的二地址指令集。

    基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器就会受到硬件的限制。但是基于栈的指令集中用户程序不直接使用这些寄存器,就可以由虚拟机实现来决定把一些访问最频繁的数据放到寄存器中来获得最好的性能。

    不过,栈架构指令集的主要缺点就是执行速度会比较慢。

    下面以一个简单的例子看看虚拟机执行字节码的过程。代码如下:

    [java] view plain copy
     
    1. public int func(){  
    2.     int a=10;  
    3.     int b=20;  
    4.     int c=30;  
    5.     return (a+b)*c;  
    6. }  


    代码很简单,使用javap -verbose命令得到这个函数的字节码指令,如下:

    [java] view plain copy
     
    1. 0:        bipush        10  
    2. 2:        istore_1  
    3. 3:        bipush        20  
    4. 5:        istore_2  
    5. 6:        bipush        30  
    6. 8:        istore_3  
    7. 9:        iload_1  
    8. 10:       iload_2  
    9. 11:       iadd  
    10. 12:       iload_3  
    11. 13:       imul  
    12. 14:       ireturn  


    下面以图示的形式看看执行过程:

    (1)偏移地址是0

    (2)偏移地址是2

    (3)偏移地址是9

    (4)偏移地址是10

    (5)偏移地址是11

    (6)偏移地址是12

    (7)偏移地址是14

  • 相关阅读:
    查看Oracle的redo日志切换频率
    MySQL 5.6 my.cnf 参数说明(转)
    MySQL性能优化之参数配置
    centos7安装mysql(MariaDB)
    centos6.5安装sendmail
    zabbix安装配置
    linux设置安全连接设置(私钥)
    linux本机root账户无法登录,但是远程ssh可登录
    ORACLE AWR
    maven 依赖(依赖范围,聚合,继承等)
  • 原文地址:https://www.cnblogs.com/kexianting/p/8528842.html
Copyright © 2011-2022 走看看