字节码执行引擎是java虚拟机的核心组成之一,虚拟机与物理机都有代码执行能力,物理机执行引擎是建立在处理器、硬件、指令集和操作系统层面上的,虚拟机的执行引擎则是自己实现可以自行指定指令集与虚拟机的结构体系,并且执行那些不被硬件直接支持的指令集格式。
不同java虚拟机实现的执行引擎在执行代码时不一样,有解释执行和编译执行两种选择,也有可能两者都使用。整体上所有的执行引擎都是一致的:输入字节码文件,处理过程是解析字节码,输出是执行结果。
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机栈中的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接、方法返回地址等信息。每个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机里面从入栈到出栈的过程。而栈帧中需要多大的局部变量表、多深的操作数栈都已经在编译期确定了,并且写入到了方法表的Code属性中,因此一个栈帧分配多少内存不会受到程序运行期影响。
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽(Variable Slot)为最小单位,一个Slot可以存放一个32位以内的数据类型。
java中占用32位以内的数据类型有boolean、byte、char、short、int、float、reference或returnAddress类型。reference是对象的引用,虚拟机实现至少都能够从此引用中直接或间接的找到对象在java堆中的起始索引地址和方法区中的对象类型数据。returnAddress是为字节码指令jsr、jsr_w和ret服务的,它指向了一条字节码指令的地址。
对于64位数据类型,虚拟机会在高位以在前的方式为其分配两个连续的Slot空间,java中的64位数据类型只有long和double两种,由于局部变量表是建议在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否是原子操作都不会引起数据安全问题。
局部变量表的几个特点如下:
代码示例1:
package com.glt.bytecodeExeEngine;
/**
* VM args:
* -verbose:gc
*/
public class SlotTest {
public static void main(String[] args) {
byte[] pg = new byte[64 * 1024 * 1024];
System.gc();
}
}
结果中看到执行GC之后没有回收掉此变量,因为变量还在作用域之内,GC就不会回收到这块内存。
代码示例2
package com.glt.bytecodeExeEngine;
/**
* VM args:
* -verbose:gc
*/
public class SlotTest {
public static void main(String[] args) {
{
byte[] pg = new byte[64 * 1024 * 1024];
}
System.gc();
}
}
结果中看到内存还是没有被回收掉,这是因为
虽然pg对象已经脱离了作用域但是之后没有任何对局部变量表的读写操作,pg原本所占用的Slot没有被其他变量所引用,所以GC Roots还保持着对这个对象关联,造成GC时候内存不会被回收
。如果在脱离作用域之后对局部变量表进行一次读写,就会顺利回收掉内存,如“代码示例3”。
代码示例3
package com.glt.bytecodeExeEngine;
/**
* VM args:
* -verbose:gc
*/
public class SlotTest {
public static void main(String[] args) {
{
byte[] pg = new byte[64 * 1024 * 1024];
}
int a = 0;
System.gc();
}
}
结果看到GC之后内存被回收了,
操作数栈也称为操作栈,它的最大深度也是在编译时候就被写入到了Code属性中。操作数栈的每个元素可以是任意的数据类型,包括long和double,32位的数据类型占用栈容量为1,64位数据类型所占的栈容量为2,在方法执行的任何时候,操作数栈的深度都不会超过在编译阶段设定的最大值。
当方法开始执行时候,这个方法的操作数栈是空的,方法执行过程中会有各种字节码指令想操作数栈中写入和提取内容,也就是入栈和出栈操作。
例如,在算术运算的时候就是通过操作数栈来进行的,整数加法的字节码执行iadd在运行时候要求操作数栈中最接近站定的两个元素已经存入了两个int型的数值,当这个指令执行时候,会将这两个int值出栈并进行相加后将结果重新入栈。
在概念模型中,两个栈帧作为虚拟机栈的元素相互之间是完全独立的,但是大多数虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠,让下面栈帧的部分操作数栈和上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,而无需进行额外的参数复制传递了。java虚拟机中的解释执行引擎称为“基于栈的执行引擎”,其中的栈就是操作数栈。
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化成为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
一个方法被执行后,有两种方式退出这个方法:
方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个值。方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中不会保存这部分信息。
方法退出的过程等于是把当前栈帧出栈,因此退出时可能执行的操作有:
虚拟机规范中允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息。实际开发中一般把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用的哪一个方法),暂时还涉及方法的具体运行过程。
所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种调用的前提是调用目标在程序代码写好、编译器进行编译时就必须确定下来,这类方法的调用称为解析。符合这个要求的方法主要有静态方法和私有方法两大类,前者与类型有直接关联,后者在外部不可被访问,这两种方法都不可能通过继承或者别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。
解析调用是个静态过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为直接引用,不会延迟到运行期再去完成。而分派(Dispact)调用则可能是静态的也可能是动态的,根据分依据的宗量数可分为单分派和多分派,两类分派组合又构成了静态单分派、静态多分派、动态单分派、动态多分派等。
java虚拟机的执行引擎在执行java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生的本地代码执行)两种选择。
在出现即时编译器之前java都是解释执行,在即时编译器出现之后class文件中代码到底是编译执行还是解释执行只有虚拟机执行时候才知道了。
无论是解释还是编译,无论是虚拟机还是物理机,对于应用程序,大部分的程序代码到物理机的目标代码或者虚拟机能执行的指令集之前,都需要经过如下几个步骤:
其中中间一行为解释执行,下面一行为编译执行。对于java来说,javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在java虚拟机之外进行的,而解释器是在虚拟机的内部,所以java程序的编译就是半独立的实现。
java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流里面的指令大部分都是零地址指令,他们依赖操作数栈进行工作。而另外一种就是基于寄存器的指令集。
基于栈的指令集特点:
执行运算的整个过程及中间变量都是以操作数栈的出栈和入栈为信息交换途径,如1+2=3
上面的过程仅是一种概念模型,虚拟机最终会对执行过程做出一些优化来提高性能,如虚拟机的解析器和即时编译器都会对输入的字节码进行优化。