Java虚拟机-字节码执行引擎

概述

Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,成为各种虚拟机执行引擎的统一外观(Facade)。不同的虚拟机引擎会包含两种执行模式,解释执行和编译执行。

运行时帧栈结构

栈帧(Stack Frame)支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量、操作数栈、动态连接和方法返回地址等信息。方法调用开始到执行完成,对应这一个帧栈在虚拟机栈里面入栈和出栈的过程。
一个线程中的方法调用链可能会很长,很多方法同时处于执行状态,但是对于执行引擎来说只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与其关联的方法称之为当前方法(Current Method)。

局部变量表

局部变量表(Local Variable Table)变量值存储空间,用于存储方法参数和方法内部定义的局部变量。容量以变量槽(Variable Slot)为最小单位,一个Slot可以存放一个32位以内的数据类型,在64位虚拟机中一个Slot使用64位的物理内存,会使用对齐和补白的手段让Slot在外观上看起来和32位虚拟机一致。对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间,这里把long和double数据类型分割存储的做法与“long和double的非原子性协定”中把一次long和double数据类型读写分隔位两次32位读写的做法有些类似。
局部变量表中的对象是否能够被垃圾回收的根本原因取决于Slot是否还存有对象的引用。如果有一个方法,后端的代码有很耗时的操作,而前端又定义占用了大量的内存,对于实际不再使用的变量手动设置为Null能够使得其被垃圾回收,否则即使离开了作用域,但是局部变量表作为GC Roots的一部分仍然保持着对它的关联,会导致内存一直占用无法释放。

操作数栈

操作数栈(Operand Stack)也常称为操作栈,他是一个先入后出(Last In First Out, LIFO)栈。
在概念模型中,两个栈帧作为虚拟机的元素是完全相互独立的。但是在大多虚拟机的实现里面都会做一些优化处理,令两个栈帧出现一部分重叠,让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样进行方法调用时就可以共用一部分数据,无需进行额外的参数复制传递。
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中的栈指的就是操作数栈。

动态连接

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

方法返回地址

一个方法开始执行后,有两种退出方式。第一种是执行引擎遇到任意一个方法返回的字节码指令,也就是正常的return,这种退出方法称之为正常完成出口(Normal Method Invocation Completion)。
另一种退出方式是在方法执行过程中遇到了异常,这个异常没有在方法体内得到处理,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称之为(Abrupt Method Invocation Completion)。
无论哪种退出方式,退出之后都要返回到方法被调用的位置程序才能继续执行,方法返回时需要栈帧中保存一些信息,用来帮助恢复它上层方法的执行状态。一般来说方法正常退出调用者的PC计数器的值可以作为返回地址,栈帧中可能会保存这个计数器值。异常退出时,返回地址是要通过异常处理表来确认,栈帧中一般不会保存这部分信息。
方法退出过程实际上等同于把当前栈帧出栈,因此退出时可能的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有)压入调用者栈帧的操作数栈,调整PC计数器的值以指向调用方法指令后面的一条指令等。

方法调用

方法调用不等于方法执行,方法调用为了确定被调用方法的版本(即调用哪个方法),暂时不涉及方法内部的具体运行过程。Class文件的编译过程中存储的符号引用,而不是实际运行时内存布局中的入口地址(直接引用)。Java方法调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

解析

类加载的解析阶段,方法调用中的部分符号引用转化为直接引用,基于的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且运行期间不可改变。换句话说调用目标在代码写好,编辑器编译时就必须确定下来。这类方法的调用称之为解析(Resolution)。
Java语言符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型关联,后者在外部不可被访问,两种方法各自的特点决定了他们都不可能通过继承或别的方式重写其他版本,他们都适合在类加载阶段进行解析。
与之对应的,Java虚拟机提供了5条方法调用字节码指令:
invokestatic:调用静态方法。
invokespecial:调用实例构造器的 方法、私有方法和父类方法。
invokevirtual:调用所有的虚方法
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,再此之前的4条调用指令,分派逻辑时固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,在类加载的时候会把符号引用解析为该方法的直接引用,这些方法可以称为非虚方法,其他方法称为虚方法。

分派

分派实际上解释了如”重载“和”重写“在Java虚拟机之中是如何实现的。

  1. 静态分派
    例如存在3个类:
    static abstract class Human
    static class Man extends Human
    static class Woman extends Human
    其中 Human 称为变量的静态类型(Static Type) 或者外观类型(Apparent Type),后面的Man称为变量的实际类型(Actual Type),编译阶段,Javac编译期根据参数的静态类型决定使用哪个重载版本,并且将对应方法的符号引用写入invokevirtual指令的参数中。
    所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,所以静态分派的动作不是由虚拟机执行的。另外编译器虽然能确定出方法的重载版本,但是很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本,如 char 类型变量 'a' 可以被理解为char,也可以理解为int、long、Object、char...等等,所以在编译时会“选择一个更加合适”的意思就在于此。
  2. 动态分派
    invokevirtual指令的运行时解析过程大致分为一下几个步骤:
    1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C
    2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
    3)否则,按照继承关系从下往上一次对C的各个父类进行第二步的搜索和验证过程。
    4)如果始终没有找到合适方法,就抛出java.lang,AbstractMethodError异常。
    由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,这个过程实际上就是Java语言中方法重写的本质。我们把这种运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
  3. 单分派与多分派
    方法的接收者与方法的参数统称为方法的宗量,这个定义最早应该来源于《Java与模式》一书。根据分派基于多少种宗量,可以将分派划分为单分派与多分派。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
    Java1.7及之前,还是一门静态多分派,动态多分派的语言。
  4. 虚拟机动态分派的实现
    前面的内容说明了虚拟机在分派的时候“会做什么”,但是虚拟机“具体是如何做到的”,不同虚拟机实现都有些差异。
    由于动态分派是非常频繁的行为,而且动态分派的方法版本选择过程需要运行时再类的方法元数据中搜索合适的目标方法,因此基于性能的考虑,大部分虚拟机实现都不会真正进行如此频繁的搜索,最常见的稳定优化手段就是为类在方法区中建立一个虚方法表(Virtual Method Table,称为vtable,对应的invokeinterface执行时用到接口方法表--Interface Method Table,称为itable)。
    除了方法表之外,在条件允许的情况下,还会使用内联缓存(Inline Cache)和基于“类型继承关系分析”(Class Hierarchy Analysis,CHA)技术的守护内联(Guarded Inlining)两种非稳定的激进优化的手段来获得更高性能。

动态类型语言支持

Java虚拟机从第一款虚拟机到JDK7之前十余年时间里,都没有改变过字节码指令集,在JDK7中添加类invokedynamic指令,这是为了实现“动态语言类型”(Dynamic Typed Language)支持进行的改进之一,也是为了JDK8可以顺利实现Lambda表达式做技术准备。

  1. 动态类型语言
    动态类型语言的关键特征是它的类型检查的主题过程是在运行期而不是编译期,例如:Groovy,JavaScript,PHP,Lisp等,相对的在编译期就进行类型检查过程的语言(如C++ 和Java等)就是静态类型语言。
    “变量无类型而变量值才有类型”这个特点也是动态类型语言的一个重要特征。
  2. java.lang.invoke包
    JDK1.7实现了JSR-292,加入了java.lang,invoke包,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法之外,提供一种新的动态确定目标方法的机制称之为MethodHandle。
    Reflection和MethodHandle都是在模拟方法调用,但是Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用,MethodHandles.lookup的3个方法--findStatic(),findVirtual(),findSpecial()正是为了对应invodestatic invokevirtual&invokeinterface和invokespecial这几条字节码指令的执行权限校验行为,这些底层细节在使用ReflectionAPI时是不需要关心的。
posted @ 2019-12-22 17:43  陈晨_软件五千言  阅读(207)  评论(0编辑  收藏  举报