Java虚拟机字节码执行引擎

前言

Java虚拟机是基于栈的虚拟机,方法的调用会被编译成具体的字节码指令,然后通过字节码指令对栈内的数据进行操作。在方法调用时,还会涉及到方法的重载重写,虚拟机要能够找到正确的方法进行执行。

虚拟机字节码指令

字节码指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码)以及跟随其后的零至多个代表此操作所需参数(称为操作数)而构成。因为操作码的长度为一个字节,所以指令集的总数不可能超过256条。在指令集中,大多数的指令都包含了其操作所对应的数据类型信息,虽然实现上可能共用了同一段代码,但是在Class文件中它们必须拥有各自独立的操作码。

大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务,例如:i代表int类型,l代表long类型,s代表short类型,b代表byte类型,c代表char类型,f代表float类型,d代表double类型,a代表reference类型。

指令集的最大容量是256,但是目前只使用到了其中的201条,这些指令按照大致用途可以分为9种类型。

1. 加载和存储指令

  • 将一个局部变量加载到操作栈:iload、iload_< n>、lload、lload_< n>、fload、fload_< n>、dload、dload_< n>、aload、aload_< n>。
  • 将一个数值从操作数栈存储到局部变量表:istore、istore_< n>、lstore、lstore_< n>、fstore、fstore_< n>、dstore、dstore_< n>、astore、astore_< n>。
  • 将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_ml、iconst_< i>、lconst_< l>、fconst_< f>、dconst_< d>。
  • 扩充局部变量表的访问索引的指令:wide。

上述指令后面带着\是代表着一组指令,这些指令里的n是具体的数字,n是几后面就会跟着几个操作数。

2. 运算指令

运算指令主要是对栈中的两个操作数的值进行某种特定运算,并把结果重新存入到操作栈顶。虚拟机没有直接对byte、short、char和boolean类型的算数指令,会用int类型的指令来代替。

  • 加法指令:iadd、ladd、fadd、dadd。
  • 加法指令:isub、lsub、fsub、dsub。
  • 乘法指令:imul、lmul、fmul、dmul。
  • 除法指令:idiv、ldiv、fdiv、ddiv。
  • 求余指令:irem、lrem、frem、drem。
  • 取反指令:ineg、lneg、fneg、dneg。
  • 位移指令:ishl、ishr、lushr、lshl、lshr、lushr。
  • 按位或指令:ior、lor。
  • 按位与指令:iand、land。
  • 按位异或指令:ixor、lxor。
  • 局部变量自增指令:iinc。
  • 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp。

3. 类型转换指令

Java虚拟机支持小范围类型向大范围类型的安全转换,无需显式的转换指令:

  • int类型到long、float或者double类型。
  • long类型到float、double类型。
  • float类型到double类型。

如果进行窄化类型转换就必须显式地使用转换指令来完成,这些指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。

尽管数据类型窄化等操作会发生溢出和精度丢失等情况,但是虚拟机规范中明确规定数值类型的窄化不能抛出运行时异常。

4. 对象创建与访问指令

实例和数组都算作一个对象,但是虚拟机在创建他们时使用了不同的指令。

  • 创建类实例的指令:new。
  • 创建数组的指令:newarray、anewarray、multianewarray。
  • 访问类字段和实例字段的指令:getfield、putfield、getstatic、putstatic。
  • 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload。
  • 将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore。
  • 取数组长度的指令:arraylength。
  • 检查类实例类型的指令:instanceof、checkcast。

5. 操作数栈管理指令

  • 将操作数栈的栈顶一个或两个元素出栈:pop、pop2.
  • 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2.
  • 将栈最顶端的两个数值互换:swap。

6. 控制转移指令

控制转移指令就是在有条件或无条件地修改PC寄存器的值。

  • 条件分支:ifeg、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne。
  • 复合条件分支:tableswitch、lookupswitch。
  • 无条件分支:goto、goto_w、jsr、jsr_w、ret。

7. 方法调用和返回指令

  • invokevirtual指令用于调用对象的方法。
  • invokeinterface指令用于调用接口方法,会搜索一个实现了该接口方法的对象中找到适合的方法进行调用。
  • invokespecial指令用于调用实例初始化方法、私有方法和弗雷方法。
  • invokestatic指令用于调用类方法。
  • invokedynamic指令是用于动态类型语言的指令,将虚拟机如何查找目标方法的决定权交给用户代码中,区别于上述4种指令。
  • 返回指令:lreturn、freturn、dreturn和areturn,还有一条return指令用与返回void。

8. 异常处理指令

Java程序中显示地抛出异常是由athrow指令来实现的。

9. 同步指令

Java语言中的synchrenized语句块的同步,利用monitorenter和monitorexit两条指令来支持,无论方法是正常结束还是异常结束,编译器都必须保证每条monitorenter指令都必须执行其对应的monitorexit指令。

运行时栈帧结构

栈帧是虚拟机栈中的元素,用于支持方法的调用和执行。栈帧中储存了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。在编译时,变量表和操作数栈的大小都会确定下来,不会受到程序运行时的影响。每个方法的调用到执行完成都是栈帧入栈和出栈的过程,因此只有栈顶的栈帧对于一个线程来说是有效的,执行引擎只能对有效栈帧进行操作。下面介绍栈帧中常用的部分:

局部变量表

局部变量表用于存放方法参数方法内定义的局部变量。表的容量以Slot为单位,Slot并没有具体的大小,以处理器、操作系统而变化。一个Slot能存放下一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,而long和double需要占用两个Slot空间。一般一个Slot占用32位物理内存,如果是64位的虚拟机,则需要用对齐补白的方式填充。

对于占用两个Slot的数据类型,虚拟机会用高位对齐的方式为其分配两个连续的Slot空间,并且不允许单独访问其中一个Slot,如果遇到这样的字节码,虚拟机在校验阶段就会抛出异常。

Slot空间是可以重用的,当字节码计数器的值已经超过某个变量的作用域时,那个变量对用的Slot就可以交给其他变量使用,但是这样的设计会引起一些副作用。例如会影响垃圾收集行为,当定义一个占用内存大的对象时,不会再引用它但是又没有任何变量再复写这个大对象的Slot时,这个对象就不会被垃圾收集器回收,通常我们会再在后面添加一句将变量置空的语句,从而避免大对象不被回收。

变量的不存在准备阶段,因此也没有初始化,所以在使用前必须对变量进行初始化,否则编译会报错。

操作数栈

操作数栈和局部变量表一样,在编译阶段就确定了栈最大深度,栈内可以储存任意Java类型,一个Slot所占容量为1,两个Slot所占容量为2,在方式执行时栈的深度都不会超过编译时确定的最大深度。

一个方式开始执行时,栈是空的,在方法执行过程中,字节码指令不断的往栈中写入和提取数据。例如iadd指令在做加法运算时,距离栈顶的两个元素已经存入了两个整型的数值,执行指令时会将这两个数出栈,相加后再将结果入栈。指令操作的数据必须严格匹配,在编译和类校验时都要保证这一点,例如iadd指令必须操作的是两个int类型的数据,不能一个是long一个是float。

虽然一个方法对应着一个栈帧,但是虚拟机会对栈帧进行优化,把两个相邻的栈帧中的局部变量表部分重叠,这样在方法调用时可以共用一部分数据,无需再进行额外的参数复制。

方法返回地址

当一个方法执行时,只有两种方式可以退出这个方法。一种是正常完成出口,在遇到任意一个方法返回指令时,会把返回值传递给调用者。另一种是异常完成出口,无论是虚拟机内部产生还是代码中使用athorow指令产生的异常,如果方法异常表中没有搜索到匹配的异常处理器就会导致方法退出,并且不会给调用者产生任何返回值。

不管方法是正常还是异常退出,退出后都会返回到方法调用的位置。在正常退出时,在栈帧中保存了调用者PC计数器的值。在异常退出时,由异常表来确定返回地址。

方法退出可能执行的操作有:恢复调用者的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整PC计数器的值指向调用者的下一条指令。

方法调用

方法调用是指虚拟机确定要调用哪一个方法,并不涉及方法的具体执行。Class文件在编译时并不包含C\C++中的连接步骤,所有的方法调用都是以符号引用的形式保存在文件的常量池中,而不是内存中的直接引用。

解析

在Class文件中存放着方法的符号引用,在类加载的解析阶段,会将一部分符号引用转化为直接引用,这部分符号引用的方法的特点是在编译时就确定好了的,运行时不可变的。符合解析调用的方法分为4类:

  • 静态方法
  • 私有方法
  • 实例构造器
  • 父类方法

这4类方法加上被final修饰的方法也可以称为非虚方法,其他的方法都称为虚方法

分派

分派调用的过程就是解释Java面向对象语言中多态性特征的一些基本体现,例如重载重写,虚拟机通过分派调用来确定应该调用哪一个目标方法。

静态分派

在说明静态分派前先了解一下两个概念:

A a = new B();

其中A成为变量a的静态类型,或者叫做外观类型,B则称为变量a的实际类型。静态类型和实际类型都会发生一些变化,但是变量最终的静态类型在编译期就可以得知,而变量的实际类型在运行时才能确定,编译器在编译时并不知道对象的实际类型是什么。

根据上述概念,可以定义在重载的方法中,编译器会根据变量的静态类型来确定目标方法。但是很多情况下重载的版本不是唯一的,编译器只能选择一个“更加适合的”版本。例如两个重载方法中

public static void sayHello(int arg){}

public static void sayHello(char arg){}

如果我们调用

sayHello('a');

则会优先选择带char参数的方法,但是如果我们把这个方法注释掉,编译器就会去选择带int参数的方法。因为‘a’除了表示一个字符外,还可以表示Unicode码的十进制数字97,所以int参数的重载也是适合的。其实这个自动转型是有一定的顺序的:char->int->long->float->double。编译器会按照这个顺序来查找是否有合适的方法,如果都没有找到则会自动装箱为对应的类,例如char会变为Character,如果在装箱后还是查找不到目标方法,则会自动转型为它的父类或者接口。如果存在同等级类型参数的重载方法,编译器会提示错误拒绝编译,必须在传参时手动转型才能编译通过。如果上述方法都没有找到,最后会去查找可变长参数的重载方法。

解析和分派并不是一个二选一的关系,它们属于不同层次的过程,例如静态方法在类加载的时候会进行解析,但是静态方法也可以重载,选择重载的版本是通过静态分派来完成的。

动态分派

动态分派与重写有着很密切的关联,举下面的例子:

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

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

    static class Woman extends Human {
        public void sayHello(){
            System.out.println("woman say hello");
        }
    }

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

当Human变量调用sayHello抽象方法时,虚拟机会经过以下的步骤来确定具体是哪一个方法(Man中的还是Woman中的sayHello方法):

  1. 找到该变量所指的对象的实际类型。
  2. 在这个类型中找到与常量中的描述符和简单名称都相符的方法,如果有则进行访问权限校验,通过则返回,不通过则抛出异常。
  3. 如果在类型中找不到则按照继承关系从下到上依次查找进行第2步的搜索验证。
  4. 如果都没有找到则抛出异常。

虚拟机在执行invokevirtual指令时,第一步就是要确定对象的实际类型,所以才会把符号引用解析到了正确的直接引用上,这就是重写的含义。

动态分派的实现

虚拟机在实现动态分派时,基于性能的考虑不可能每次都去搜寻方法,所以一个最常用的手段就是建立一个虚方法表,使用表内的索引来代替查找来提高性能。一个类的虚方法表中会包含父类和自身的方法索引,如果子类没有重写父类的方法,那么子类方法的地址入口和父类方法的地址入口是一样的。为了程序上的便利,具有相同签名的方法在父类和子类的表中应该具有一样的索引序号,这样做的话在类型转换时,只用切换方法表就可以找到具体方法的入口地址。

除了使用方法表之外,还提供了某些条件下的内联缓存和基于“类型继承关系分析”技术的守护内联两种手段来获取更高的性能。

单分派与多分派

方法的接收者与方法的参数统称为方法的宗量,单分派是指根据一个宗量对目标方法进行选择,多分派则是根据一个以上的宗量进行选择。举下面的例子:

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 {
        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 Son();
        father.hardChoice(new QQ());
    }
}

在编译阶段,编译器根据变量的静态类型和参数类型两个宗量来选择哪一个重载方法,这就属于多分派,所以Java是一门静态多分派的语言。

在程序运行阶段,虚拟机根据接收者的实际类型来确定调用哪一个类的方法,所以Java也是一门动态单分派的语言。

基于栈的字节码解释执行

基于栈和基于寄存器的区别

举个简单的例子,计算“1+1”的结果,基于栈的指令集是:

iconst_1    //把常量1入栈
iconst_1    //把常量1入栈
iadd        //把栈顶两个值出栈,相加后再入栈
istore_0    //将栈顶的值存入局部变量表中

如果是基于寄存器的指令集则是:

mov eax, 1  //把EAX寄存器的值设为1
add eax, 1  //把EAX的值加1再保存在EAX中

两种指令集都各有优势,基于栈的可移植性高,程序不会直接使用硬件的寄存器,而是有虚拟机来实现具体的操作,缺点就是执行速度相对来说会慢一些,因为栈的操作会频繁的访问内存,会降低处理器的执行速度。

解释器的执行过程

以一段代码为例:

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

转化为字节码指令就是:

public int calc();
    Code:
        Stack=2, Locals=4, Args_size=1
        0:  bipush 100  //将单字节的整型常量(-128~127)推入数据栈顶
        2:  istore_1    //将操作数栈顶的整型值出栈并存入第一个局部变量Slot中
        3:  sipush 200  //推入一个整型数200入栈
        6:  isotre_2    //将操作数栈顶的整型值出栈并存入第二个局部变量Slot中
        7:  sipush 300  //推入一个整型数300入栈
        10: isotre_3    //将操作数栈顶的整型值出栈并存入第三个局部变量Slot中
        11: iload_1     //将变量表第一个Slot的值复制到操作数栈顶
        12: iload_2     //将变量表第二个Slot的值复制到操作数栈顶
        13: iadd        //将栈顶的两个元素出栈相加,然后把结果重新入栈
        14: iload_3     //将变量表第三个Slot的值复制到操作数栈顶
        15: imul        //将栈顶的两个元素出栈相乘,然后把结果重新入栈
        16: ireturn     //将操作数栈顶的整型值返回给方法的调用者,方法执行结束

根据这段指令集可以看到,操作数栈的最大深度为2,局部变量表需要占用4个Slot空间。但是上述的执行过程只是一种概念模型,虚拟机在实际的操作中会进行优化,来提升解释执行的性能。

总结

Java虚拟机的执行过程是基于栈架构的,每一条指令的操作都是针对操作数栈内的元素,这样的执行模式有优势也有劣势,与基于寄存器架构的模式没有绝对的好坏之分。记录这些内容还只是简单的描述了虚拟机在执行指令时的概念模型,并不是Java虚拟机真正的执行过程,在真实环境下,虚拟机会对指令做适当的优化来提高虚拟机的执行效率。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值