JVM

深入理解Java虚拟机总结-虚拟机字节码执行引擎

Posted by AlstonWilliams on February 17, 2019

注:此文是我在读完周志明老师的深入理解Java虚拟机之后总结的一篇文章,请阅读此书获取更加详细的信息.

另外,需要注意的是,读此文前,各位应当对Java字节码文件格式以及字节码指令有一个清楚的认识.

运行时栈帧结构

在介绍Java内存布局时,我们就提到过,每个方法在执行时,都会在虚拟机栈中创建一个栈帧,其中包括局部变量表,操作数栈,动态链接,返回地址等.

那么这几个区域到底都是做什么用的呢?

局部变量表

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

局部变量表的容量以及变量槽(Variable Slot,下称Slot)为最小单位,一个Slot可以存放一个32位以内的数据类型,Java中占用32位以内的数据类型有boolean, byte, char, short, int, float, reference和returnAddress8种类型.对于64位的数据类型,虚拟机会以高位对其的方式为其分配两个连续的Slot空间,Java语言中明确的64位的数据类型只有long和double两种.

reference类型表示对一个对象实例的引用,虚拟机规范既没有说明它的长度,也没有明确指出这种引用应有怎样的结构.但一般来说,虚拟机实现至少都应当能通过这个引用做到两点,一是此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引,二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现Java语言规范中定义的语法约束.第8种即returnAddress类型目前已经很少见了,它是为字节码指令jsr,jsr_w和ret服务的,指向了一条字节码指令的地址,很古老的Java虚拟机曾经使用这几条指令来实现异常处理,现在已经由异常表代替.

虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量.如果访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型的变量,则说明会同时使用n和n+1两个Slot.对于两个相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个,Java虚拟机规范中明确要求了如果遇到进行这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常.

在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static方法),那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字”this”来访问到这个隐含的参数.其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完成后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot.

为了尽可能节省栈帧空间,局部变量表的Slot是可以重用的,方法体重定义的变量,其作用域不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用.

局部变量不像类变量那样,在类加载时有一个准备阶段,所以,即使你不给类变量赋初值,依然能够使用,如果不给局部变量赋初值,那么这个局部变量是不能使用的.

操作数栈

操作数栈中存放了字节码指令的参数,如iadd指令就是取操作数栈中处于栈顶的两个操作数来进行加法操作.

同局部变量表一样,操作数栈的最大深度,在编译期也是可以被确定的,写入到Code属性中的max_stacks数据项中.操作数栈的每一个元素可以是任意的Java数据类型,包括long和double.32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2.在方法执行的过程中,操作数栈的深入不会超过max_stacks.

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器需要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点.

动态连接

我们在之前的文章中也提到过,Java在类加载阶段,会对一部分符号引用进行解析,将其转化为直接引用.如类,接口,类方法,字段等.而还有一部分符号引用,需要在链接时才能确定其直接引用,才能够解析,这部分就被称为动态连接.

方法返回地址

这个就很容易理解了,因为栈帧本身就是方法独有的,那么方法执行完毕后,肯定还需要知道它要返回到哪里呀,所以就需要保存方法返回地址.

一个方法开始执行后,只有两种方式可以退出这个方法.第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式成为正常完成出口.

另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码块中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口.一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的.

方法调用

方法调用的唯一任务是确定需要被执行的方法,并不是执行被执行的方法,它跟方法执行是有区别的.

解析

方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的.换句话说,调用目标在程序代码写好,编译器运行编译时就必须完全确定下来.这类方法的调用称为解析.

在Java语言中符合”编译器可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者直接与类型直接关联,后者在外部不可访问,这两种各自的特性决定了它们在运行期不可能被改变,因此它们适合在类加载阶段进行解析.

Java虚拟机中提供了五条方法调动字节码指令,分别是:

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

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段唯一确定一个调用版本,符合这个条件的有静态方法,私有方法,实例构造器,父类方法,用final修饰的方法五类,它们在类加载阶段就可以把符号引用解析为该方法的直接引用.这些方法可以称为非虚方法,与之相反,其他方法称为虚方法.

分派

在介绍静态分派和动态分派之前,我们先来介绍一下什么叫做静态类型,什么叫做实际类型.考虑下面的代码:

Human man = new Man();

其中Man是变量man的实际类型,而Human是变量man的静态类型.

静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变量仅仅在使用时发生,变量本身的静态类型是不会发生变化的,并且最终的静态类型是在编译器克制的;而实际类型变化的结果在运行期才可确定,编译器在编译程序时,并不知道一个对象的实际类型是什么.

那么什么是静态类型变化,什么是实际类型变化呢?

// 实际类型变化
Human man = new Man();
man = new Woman();

// 静态类型变化
sr.sayHello((Man)man);
sr.sayHello((Woman)man);

为什么静态变化的结果在编译器可知,而实际类型的变化只能在运行时才可知呢?

我是这么理解的,看这段代码:

1 Human man = new Man();
2 sr.sayHello((Woman)man);

即使在第二行中,静态类型发生了变化,但是由于这个变化并没有保存,而只是作为一个临时变化传递给了sayHello()方法,所以,实际上,运行到最后,变量man的静态类型还是Human.

在考虑下面的这段代码:

1 Human man = new Man();
2 man = new Woman();

在这里,明显实际类型的变化被保存了下来,所以,实际类型的变化是随着运行而改变的,所以,编译期明显无法确定最终的实际类型.

静态分派

静态分派在重载中用的比较多.

编译器在重载时,是通过参数的静态类型而不是实际类型作为判定依据的,编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中.

动态分派

动态分派就是在运行期根据实际类型确定方法执行版本的分派过程,动态分派在重写中用的比较多,动态分派在执行invokevirtual指令的运行时解析过程大致分为以下几个步骤:

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

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

1+1用这两种指令集计算时,基于栈的指令集是这样的:

iconst_1
iconst_1
iadd
istore_0

而如果采用基于寄存器的指令集,则程序可能是这样的:

mov eax, 1
add eax, 1

基于栈的指令集的主要优点就是可移植,当然还有一些其他的优点,比如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数),编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等.

栈架构指令集的主要缺点是执行速度相对来说会慢一些,完成相同功能所需的指令数量一般会比寄存器架构多.而且,由于栈是在内存中实现的,所以频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈.尽管虚拟机可以采用栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法.