虚拟机字节码执行引擎

虚拟机字节码执行引擎

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

我们已经知道了在Java虚拟机内存中有一个部分是和方法调用相关的,即虚拟机栈。而且,这个虚拟机栈是线程私有的,所有方法的调用与退出就是栈帧在虚拟机栈的入栈与出栈。接下来就从栈帧的结构开始,介绍Java虚拟机字节码执行引擎执行字节码的过程。


运行时栈帧结构

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

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

局部变量表

局部变量表是一组存储变量值的存储空间,来存放方法参数和方法内部定义的局部变量。在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开始的地方。

下面的三个例子演示了局部变量表中的slot重用对垃圾回收行为的影响。
1. GC不回收仍处于作用域的变量

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

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

[GC (System.gc())  67175K->66176K(129024K), 0.0016734 secs]
[Full GC (System.gc())  66176K->66062K(129024K), 0.0081248 secs]
// 可以看到,System.gc()并没有收集这个64MB的对象,这是因为这个对象还处于作用域中。
  1. GC也有可能不回收不在作用域中的对象
public class GCTest {
    public static void main(String[] args) {
        {
        byte[] by = new byte[64*1024*1024];
        }
        //这时by已经不再作用域了
        System.gc();
    }   
}
[GC (System.gc())  67175K->66144K(129024K), 0.0012129 secs]
[Full GC (System.gc())  66144K->66062K(129024K), 0.0087839 secs]
//不在作用域中的变量仍然没有被回收。
  1. slot重用会影响垃圾回收
public class GC_test {
    public static void main(String[] args) {
        {
        byte[] by = new byte[64*1024*1024];
        }
        int a = 0;
        System.gc();
    }   
}
[GC (System.gc())  67175K->66176K(129024K), 0.0013187 secs]
[Full GC (System.gc())  66176K->526K(129024K), 0.0071123 secs]
//可以看到,这时回收了那个64MB的对象。所以,对象b能否被回收的依据是:局部变量表中的slot是否还存有关于b数组对象的引用。

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

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


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

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

操作数栈

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

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

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

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

这里写图片描述

动态链接

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

方法返回地址

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

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

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

附加信息

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


方法调用

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

这里所说的方法调用并不等同于方法执行,这个阶段的唯一目的就是确定被调用方法的版本,还不涉及方法内部的具体运行过程。对于方法的版本,需要解释的就是由于重载与多态的存在,一个符号引用可能对应多个真正的方法,这就是方法的版本。
在Java虚拟机中提供了5条方法调用的字节码指令,分别是:

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

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

解析

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

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

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

public class Static{
 public static void main(String args[]){
   Static.sayhello();
}
public static void sayhello(){
  System.out.println("hello world");
 }
}

使用javap查看程序的字节码文件。
这里写图片描述

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

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

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

分派

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

静态分派

package san;

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指令的参数中。

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

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的方法。正是调用了参数是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语言实现方法重载的本质。

动态分派

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

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

单分派与多分派

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

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

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

    public static class Father{
        public void like(QQ args){
            System.out.println("Father likes QQ");
        }
        public void like(_360 args){
            System.out.println("Father likes _360");
        }
    }
    public static class Son extends Father{
        public void like(QQ args){
            System.out.println("Son likes QQ");
        }
        public void like(_360 args){
            System.out.println("Son likes _360");
        }
    }
    public static void main(String[] args) {
        Father father=new Father();
        Son son=new Son();
        father.like(new QQ());
        son.like(new _360());
    }
}
Father likes QQ
Son likes _360

虚拟机动态分派的实现

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

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

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

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值