虚拟机字节码执行引擎

来源:互联网 发布:xp 淘宝伴侣 编辑:程序博客网 时间:2024/05/22 14:21

在前面的几篇文章里,从Java虚拟机内存结构开始,经历了虚拟机垃圾收集机制、Class类文件结构到后来的虚拟机类加载机制,一步步的进入到了Java虚拟机即Java底层的世界。在有了前面的基础之后,接下来就应该进入Java虚拟机最重要的部分了——虚拟机字节码执行引擎,毕竟,这是Java程序得以在不同机器上运行的核心部分。

Java是通过实现Java虚拟机来达到平台无关的。“虚拟机”的概念是相对于“物理机”来说的,两种机器都有执行代码的能力,不过物理机是直接面向处理器、指令集和操作系统的,而虚拟机则需要自己模拟一个执行引擎。因此,虚拟机可以自己实现一个指令系统与执行引擎结构。

Java程序的执行大致可以看做是各个方法的调用与执行。类的初始化在类加载阶段就已经完成了,而类实例的初始化就是构造方法的调用与执行。既然Java程序可以看做是方法的调用与执行,那么关于Java虚拟机字节码的执行引擎就应该和方法调用有很大的关系了。

我们已经知道了在Java虚拟机内存中有一个部分是和方法调用相关的,即虚拟机栈。而且,这个虚拟机栈是线程私有的,所有方法的调用与退出就是栈帧在虚拟机栈的入栈与出栈。接下来就从栈帧的结构开始,介绍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不回收仍处于作用域的变量

代码如下:

public class GCTest {@SuppressWarnings("unused")public static void main(String[] args) {byte[] b=new byte[64*1024*1024];System.gc();}}

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

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

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

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

接下来,代码修改如下:

public class GCTest {@SuppressWarnings("unused")public static void main(String[] args) {{byte[] b=new byte[64*1024*1024];}System.gc();}}

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

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

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

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

然后,修改代码如下:

public class GCTest {@SuppressWarnings("unused")public static void main(String[] args) {{byte[] b=new byte[64*1024*1024];}int a=1;System.gc();}}

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

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

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

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

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

public static void main(String[] args){int a;System.out.println(a);}

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

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文件中的一部分方法的符号引用解析为直接引用,这种解析能够成立的条件是,方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的电泳版本在运行期间是不变的。也就是说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。

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

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

public class StaticResolution {public static void sayHello(String name){System.out.println("Hello, "+name);}public static void main(String[] args) {StaticResolution.sayHello("Liu");}}

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

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

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

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

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

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

2.2、分派

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

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

(1)静态分派

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

public class StaticDispatch {static class Human{}static class Man extends Human{}static class Woman extends Human{}public void sayHello(Human human){System.out.println("hello,guy!");}public void sayHello(Man man){System.out.println("Hello,gentleman!");}public void sayHello(Woman woman){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!

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

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

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

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


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

public class Overload {public static void sayHello(Object arg){System.out.println("Hello Object");}public static void sayHello(int arg){System.out.println("Hello Int");}public static void sayHello(long arg){System.out.println("Hello Long");}public static void sayHello(Character arg){System.out.println("Hello Character");}public static void sayHello(char arg){System.out.println("Hello Char");}public static void sayHello(char...arg){System.out.println("Hello Char ...");}public static void sayHello(Serializable arg){System.out.println("Hello Serializable");}public static void main(String[] args) {sayHello('a');}}

执行后的结果是: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)动态分派

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

public class DynamicDispatch {static abstract class Human{protected abstract void sayHello();}static class Man extends Human{public void sayHello(){System.out.println("Hello gentleman");}}static class Woman extends Human{public void sayHello(){System.out.println("Hello lady");}}public static void main(String[] args) {Human man=new Man();Human woman=new Woman();man.sayHello();woman.sayHello();man=new Woman();man.sayHello();}}

结果如下:

Hello gentleman
Hello lady
Hello lady

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

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

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

public static void main(java.lang.String[]);    descriptor: ([Ljava/lang/String;)V    flags: ACC_PUBLIC, ACC_STATIC    Code:      stack=2, locals=3, args_size=1         0: new           #16                 // class ch08/DynamicDispatch$Man         3: dup         4: invokespecial #18                 // Method ch08/DynamicDispatch$Man."<init>":()V         7: astore_1         8: new           #19                 // class ch08/DynamicDispatch$Woman        11: dup        12: invokespecial #21                 // Method ch08/DynamicDispatch$Woman."<init>":()V        15: astore_2        16: aload_1        17: invokevirtual #22                 // Method ch08/DynamicDispatch$Human.sayHello:()V        20: aload_2        21: invokevirtual #22                 // Method ch08/DynamicDispatch$Human.sayHello:()V        24: new           #19                 // class ch08/DynamicDispatch$Woman        27: dup        28: invokespecial #21                 // Method ch08/DynamicDispatch$Woman."<init>":()V        31: astore_1        32: aload_1        33: invokevirtual #22                 // Method ch08/DynamicDispatch$Human.sayHello:()V        36: return      LineNumberTable:        line 18: 0        line 19: 8        line 20: 16        line 21: 20        line 22: 24        line 23: 32        line 24: 36      LocalVariableTable:        Start  Length  Slot  Name   Signature            0      37     0  args   [Ljava/lang/String;            8      29     1   man   Lch08/DynamicDispatch$Human;           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)单分派与多分派

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

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

public class Dispatch {static class Pepsi{}static class Coca{}public static class Father{public void like(Pepsi p){System.out.println("Father likes pepsi");}public void like(Coca c){System.out.println("Father likes coca");}}public static class Son extends Father{public void like(Pepsi p){System.out.println("Son likes pepsi");}public void like(Coca c){System.out.println("Son likes coca");}}public static void main(String[] args) {Father father=new Father();Son son=new Son();father.like(new Coca());son.like(new Pepsi());}}

结果如下:

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、基于栈的字节码解释执行引擎

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

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

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

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

public int func(){int a=10;int b=20;int c=30;return (a+b)*c;}

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

0:        bipush        102:        istore_13:        bipush        205:        istore_26:        bipush        308:        istore_39:        iload_110:       iload_211:       iadd12:       iload_313:       imul14:       ireturn

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

(1)偏移地址是0


(2)偏移地址是2


(3)偏移地址是9


(4)偏移地址是10


(5)偏移地址是11


(6)偏移地址是12


(7)偏移地址是14





添加公众号Machairodus,我会不时分享平时学到的东西~


0 0