当代码被编译成二进制字节码,由Classloader加载到内存,经过加载、验证、准备、解释后将数据转化为JVM可以接受的格式存储到方法区,并在堆内存中创建一个Class对象,作为外部访问的入口。
接下来就由字节码执行引擎来解释执行代码。
《Java》虚拟机规范中规定了Java虚拟机字节码执行引擎的概念模型,输入字节码二进制流,输出执行结果。
在不同的虚拟机实现中,执行引擎在执行字节码的时候会有两种:
- 解释执行(Sun Classic 虚拟机)
- 编译执行(Jrockit)
- 两种兼备 (HotSpot)
运行时栈帧结构
栈帧是用于支持虚拟机方法进行调用和执行的数据结构,每个方法从调用开始到执行结束的过程,都对应一个栈帧从虚拟机栈中入栈到出栈的过程。
栈帧存储了方法的局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。
栈帧中需要多大的局部变量表、多深的操作数栈在编译Java的时候就已经计算出来存放在方法表的Code属性中。
局部变量表
用于存放方法参数和方法内部定义的局部变量。
在代码被编译成Class文件时,在方法的Code属性中max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。局部变量表的容量以变量槽(Variable Slot)为最小单位,每个变量槽应该可以存放一个boolean、byte、char、short、int、float、reference、returnAddress这8种不超过32位数据类型(对于超过32位的long和double,则使用两个32位的变量槽)。
引用类型:
- 通过引用可以找到这个对象在堆中的数据存放的起始地址或者索引;
- 找到对象所属类型在方法区中的存储类型;
returnAddress是为字节码指令jsr、jsr_w 和ret服务,指向了一条字节码指令的地址,一些jvm使用这几条指令来实现异常处理时的跳转。
现在全部改为采用异常表来代替。
当一个方法被调用的时候,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,如果是实例方法局部变量表第0位索引的变量槽存放的是this。
以恰当的变量作用域来控制变量的回收时间才是最优雅的解决方法,而不是使用完后就将其引用赋予null, 因为这仅仅是基于虚拟机的概念模型层面作出的优化,虚拟机的具体实现不同,解释执行与编译执行结果都不同。
操作数栈
一个后入先出的栈,同局部变量表一样,操作数栈的最大深度也在编译的时候就被写入到Code属性的max_stacks数据项中。32位数据类型所占用的栈容量是1, 64位数据类型所占的栈容量是2.
通过将操作数入栈出栈来进行算术运算;
通过操作数栈将方法的参数进行传递;
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用时为了支持方法调用过程中的动态连接。
Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令以常量池指向方法的符号引用作为参数。
这些符号引用一部分在类加载阶段或者第一次使用会被转化为直接引用;
另一部分在每次运行期间都转化为直接引用,这部分称为动态连接;
方法返回地址
当一个方法退出以后,必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,来帮助恢复主调方法的执行状态。
一个方法的退出有两种方式:
第一种是执行引擎遇到方法返回的字节码指令;主调方法的PC计数器的值就可以作为返回地址。
另一种是遇到了异常,并且在本方法的异常表中没有搜索到匹配的异常处理器导致方法退出;
方法调用
方法调用阶段的任务是确认被调用方法的版本。
Class文件的编译过程不包含传统程序语言的连接步骤,一切方法调用在Class文件里面存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址。因此一些调用需要在加载期间,甚至到运行期间才能确定目标方法的直接引用。
解析
在类加载的解析阶段,将静态方法和私有方法对应的符号引用转化为直接引用。因为静态方法与类关联,私有方法不可能被继承或者重写,在编译阶段即可确定。
字节码指令集设计了不同的指令调用不同类型的方法:
- invokestatic, 调用静态方法
- invokespecial, 调用实例构造器方法,私有方法,父类中的方法。
- invokevirtual, 用于调用所有的虚方法(被final修饰的方法)
- invokeinterface,用于调用接口方法,会在运行时再确定一个实现接口的对象
- invokedynamic, 在运行时动态解析出调用点限定符引用的方法。
前四条指令,分派逻辑都固化在Java虚拟机内部,invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。
只要能被invokestatic 和 invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本。Java语言中符合这个条件的方法有静态方法、构造方法、私有方法、父类方法和被final修饰的方法,这五种方法被称为非虚方法,其他的被称为虚方法。
解析调用是一个静态的过程, 在类加载的解析阶段就把涉及的符号引用转换为明确的直接引用,不必延迟到运行期再去完成。
分派
静态分派
所有依赖静态类型来决定方法执行版本的分派叫做静态分派。静态分派的典型应用表现是方法重载。
方法重载发生在同一个类中,方法名称相同,参数列表不同。动态分派与实现
在运行期根据时机类型确定方法执行版本的分派过程叫动态分派,它就是Java语言中方法重写的本质。多态的根源在于虚方法调用指令invokevirtual的执行逻辑,因此多态只对方法有效,对字段无效(字段永远不参与多态)。动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接受着类型方法元数据中搜索合适的目标方法,
单分派和多分派
方法的接收者(要被执行的方法的所有者叫接收者)与参数统称为方法的宗量。
单分派是根据一个宗量对目标方法进行选择;
多分派是根据多个宗量对目标方法进行选择;
Java中的解释执行与编译执行
在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树。
对于C/C++来说,词法、语法分析以后到后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整有意义的编译器去实现,这类就是编译执行。
对于Java来说,javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再便利语法树生成线性的字节码指令流的过程。
基于栈的指令集与给予寄存器的指令集
- 基于栈的指令集
零地址指令,依赖操作数栈进行工作,
优点是可移植性高,不与具体的硬件绑定。编译器实现简单。
缺点是速度慢。 - 给予寄存器的指令集
而地址指令集,物理硬件直接支持的指令集架构,依赖寄存器工作。
虚拟机最终会对执行过程作出一系列优化来提高性能, 实际的情况会和盖面模型差距非常大,差距产生的根本原因是虚拟机中解析起和即时编译起都会对输入的字节码进行优化,即使解释其中也不是按照字节码指令去逐条执行的。