JVM--虚拟机字节码执行引擎
文章目录
- 1、 概述
- 2、 运行时帧栈结构
- 1.1 局部变量表
- 1.2 操作数栈
- 1.3 动态连接
- 1.4 方法返回地址
- 1.5 附加信息
- 3、 方法调用
- 3.1 解析
- 3.2 分派
- 1). 静态分派
- 2). 动态分派
- 3). 单分派和多分派
- 4). 虚拟机动态分派的实现
- 3.3 动态类型语言支持
- 1). 动态类型语言
- 2). JDK 1.7与动态类型
- 3). java.lang.invoke包
- 4). invokedynamic指令
- 5). 掌控方法分派规则
- 4、 基于栈的字节码解释执行器
- 4.1 解释执行
- 4.2 基于栈的指令集与基于寄存器的指令集
- 4.3 基于栈的解释器执行过程
1、 概述
所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果.
2、 运行时帧栈结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素(一个栈帧代表一层方法调用).每一个栈帧都包括了局部变量表,操作数栈,动态链接,方法返回地址和一些额外的附加信息.
在编译代码的时候,栈帧中需要局部变量表的大小,操作数栈的深度都是完全确定的,并且写入到方法表的Code属性中了,因此一个栈帧需要分配多大内存不会受到程序运行的影响,仅仅取决于虚拟机的具体实现.
一个线程中的方法调用链可能会很长,对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧对应的方法称为当前方法.
典型的栈帧结构如下所示:
1.1 局部变量表
- 局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量.
- 局部变量表的容量以变量槽(Slot)为最小单位,虚拟机规范中并没有制定一个Slot的大小,只是很有导向性的指明”每个Slot都应该能存放一个boolean,byte,char,short,int,float,reference或者returnAddress类型的数据”.二这八种数据类型都可以使用32字节来存放,但这并不代表一个Slot就是32字节,仅仅代表Slot的长度可以随着处理器,操作系统或虚拟机的不同而发生变化.
- 对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间.
- 虚拟机通过索引定位的方式使用局部变量表,索引的范围是从0开始带局部变量表最大的Slot数量.对于64位数据类型的变量,会同时使用n和n+1两个Slot,这种情况下,不允许采用任何方式单独访问其中任意一个Slot,如果遇到了这种字节码指令,Java虚拟机会在校验阶段抛出异常.
- 方法执行时,虚拟机是使用局部变量表完成参数值到参数列表的传递的,如果是实例方法(非static),第0位索引值的Slot默认是用于传递方法所属对象,也就是this对象的引用的.其余参数按照参数表顺序排列,占用从1开始的后续的Slot,参数表分配完后,再根据方法体内部定义的变量的顺序和作用域分配其余的Slot.
- 为了尽可能的节省栈帧空间,局部变量表中的Slot是可以重用的,如果一个变量的作用域已经失效了,这个Slot就会被分配给新的变量使用.
1.2 操作数栈
- 操作数栈也常常被称为操作栈,它是一个后入先出的栈.与局部变量表一样,操作数栈的最大深度也在编译的时候就已经写入到了Code属性中了.操作数栈的每一个元素可以使任意的Java数据类型,包括64位的long和double(占据的栈容量为2).
- 当一个方法开始执行的时候,操作数栈是空的,方法执行过程中,随着各种指令的直接作用或者需要,往操作数栈中写入和提取内容.例如,如果要进行
int a = 1 + 1;
,虚拟机会先将1和1压入操作数栈,然后执行相加的命令,这个命令就会从操作数栈中取出两个值进行求和的操作,然后返回结果值. - 大多数的虚拟机中都会对操作数栈做一些优化处理,另两个帧栈出现一部分重叠,是的两个方法间可以实现变量共享.
1.3 动态连接
- 每个栈帧都包含一个只想运行时常量池中该帧栈所属方法的引用,这样是为了支持方法调用过程中的动态连接.
- Class文件的常量池中有大量的符号引用,字节码中的方法调用指令就是以常量池中的符号引用作为参数执行方法的.一部分符号引用会在类加载阶段就转化为直接引用,成为静态连接,另一部分在每一次运行期间转换为直接引用,成为动态连接.
1.4 方法返回地址
- 当一个方法开始执行后,只有两种方式可以退出这个方法.第一种是执行引擎遇到了任意一个方法返回的字节码指令,这个时候可能会有返回值返回给上层的方法调用者.这是正常完成出口.
- 另一种退出方式是,在方法执行过程中遇到了异常,而且在方法内没有被处理.这种退出成为异常完成出口,不会给上层调用者任何返回值.
- 无论哪种退出方式,方法退出后,都需要回到上层调用该方法的位置继续执行程序,方法返回时可能需要在栈帧中保存一些信息,用来帮助其恢复上层方法的执行状态.一般来说,调用者的计数器PC的值可以作为返回地址,帧栈中很可能就会保存这个值.
- 方法退出的过程实际上就是栈帧出栈的过程.
1.5 附加信息
虚拟机允许具体的实现增加一些规范里没有的描述信息到栈帧中,实际开发中,一般会把动态连接,方法返回地址与其他附加信息全部归于一类,称为帧栈信息.
3、 方法调用
方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本,也就是确定调用哪一个方法.还没有涉及方法内部的具体运行过程.
3.1 解析
所有方法调用中的目标方法在Class文件中都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,这种解析的前提是这个方法在编译器编译时就不许确定下来.主要包含静态方法和私有方法,它们都不可能通过继承或者别的方式重写为其他版本.
3.2 分派
分派调用过程会揭示多态特性的一些最基本实现.
1). 静态分派
先确立一个定义:
Human man = new Man();
我们把上面的Human称为变量的静态类型,而把Man称为变量的实际类型.静态类型和实际类型在程序中都可以发生一些变化,区别在于静态类型只在使用时发生变化,变量本身不会变化.而实际类型变化的结果只有在程序运行期才能确定下来,编译期并不能知道一个对象的实际类型是什么.
编辑器在进行重载时是根据对象的静态类型进行方法的选择的而不是实际类型.
所有依赖静态类型来定位方法执行版本的分派动作都称为静态分派.典型的就是重载.
静态分派能确定出方法调用的版本,但很多情况下并不唯一,只是确定一个最适合的版本(会找到参数类型转型最少的哪一个重载函数).
2). 动态分派
动态分派和重写有很密切的关系.
在调用方法的过程中,会调用invokevirtual指令,这个指令用于调用所有虚方法,即调用除了静态方法,私有方法,父类方法,类构造器方法,接口方法和动态执行之外的所有方法.这个指令会找到操作数栈定的第一个元素所指向的对象的实际类型,然后按照继承关系从子类到父类一个一个的查找是否有满足要求的方法.这就意味着,普通方法的执行是根据对象的实际类型来寻找分派方法的.
3). 单分派和多分派
方法的接收者和方法的参数统称为方法的宗量,根据分配基于多少种宗量,可以讲分派划分为单分派和多分派,
单分派是根据一个宗量对目标方法进行选择,多分派则根据多个宗量.
Java是静态多分派,动态单分派的语言(重载方法只根据调用者的实际类型选择分派方法,所以是单分派的).
4). 虚拟机动态分派的实现
动态分派是非常频繁的动作,每一次动态分派都在类的方法元数据中搜索合数的目标方法,因此在虚拟机的实际实现中大部分都会加以优化.最常用的手段就是为类在方法区建立一个虚方法表,记录类内部各种方法和方法的信息,使用虚方法表索引来代替元数据查找以提高性能.
虚方法表中存放着各个方法的入口,如果某个方法在子类中没有被重写,那么子类的虚方法表中方法的地址入口和父类相同方法中的地址入口是一致的.
3.3 动态类型语言支持
1). 动态类型语言
动态类型语言的关键特征是它的类型检查的主题是在运行期而不是编译期.(Java是静态类型语言,在编译器就进行了类型检查)
2). JDK 1.7与动态类型
Java虚拟机虽然支持许多的动态类型语言,但是支持一直存在着欠缺.
3). java.lang.invoke包
这个包提供了一种新的动态确定目标方法的机制,称为MethodHandle.
与反射机制的区别:
- 都是在模拟方法调用,但MethodHandle是在字节码层面,而反射是方法层面
- 反射的Method对象不MethodHandle机制中的对象包含的信息多.MethodHandle是轻量级的.
- MethodHandle是对字节码的方法调用指令的模拟,理论上可以优化.
- 反射仅仅可以用于Java语言,而MethodHandle可以适用于任何运行在Java虚拟机上的语言.
4). invokedynamic指令
每一处含有invokedynamic指令的位置都称作动态调用点,这条指令的第一个参数可以得到3项信息:引导方法,方法类型和名称.引导方法代表真正要执行的目标方法调用.
5). 掌控方法分派规则
invokedynamic指令和前面四条invoke*指令的最大区别在于它的分派逻辑不是有虚拟机决定的,而是程序员.
4、 基于栈的字节码解释执行器
4.1 解释执行
Java经常被认定为解释执行,在早期(JDK 1.0时代),这种定义还算准确,但当主流的虚拟机中都包含了即时编译器后,Class文件中的代码究竟是会被解释执行还是会被编译执行,就只有虚拟机自己才能判断的事情了.
对于一门具体语言来说,词法分析,语法分析以至后面的优化器和目标代码生成都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类的代表是C/C++语言.
把其中一部分步骤实现为一个半独立的编译器,这类的代表是Java.
把这些步骤和执行引擎全部封装到一起,这是大多是JavaScript执行器的处理方式.
4.2 基于栈的指令集与基于寄存器的指令集
Java编译器输出的指令流,基本上是一种基于栈的指令集架构,依赖操作数栈进行工作.
与之相对的是基于寄存器的指令集,最经典的就是x86的二进制指令集,就是主流PC机都支持的指令集架构.依赖寄存器进行工作.
分别用两种指令集计算”1+1”:
//基于栈的指令集
iconst_1 //将常量1压入栈
iconst_1
iadd //取出栈顶的两个元素,相加,把结果放回栈顶
istore_0 //把栈顶的值放入局部变量表的第0个Slot中
//基于寄存器的指令集
mov eax,1 //把EAX寄存器的值设置为1
add eax,1 //给EAX寄存器中的值加上一,结果保存在EAX寄存器中
基于栈的指令集主要的有点就是可移植,因为寄存器由硬件直接提供,所以不可避免的会被硬件所约束.栈架构的指令集还有一些其他的优点,如代码相对更紧凑,编译器实现更加简单.缺点是执行速度相对来说会慢一些.所有的物理机的指令集都是寄存器架构.
虽然栈架构指令集的代码非常紧凑,但完成相同功能的指令数量会比寄存器指令集多,因为入栈出栈操作本身就产生了相当多的指令,更重要的是,栈是存在于内存之中的,使用栈架构指令集意味着对内存的访问会非常频繁.学过微机原理会知道,寄存器的访问在CPU内部,而内存在CPU外部,用总线访问,速度差距还是很大的.
4.3 基于栈的解释器执行过程
Java代码:
public int calc(){
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
字节码表示:
public int calc(){
code;
Stack=2, Locals=4, Args_size=1
0: bipush 100
2: istore_1
3: sipush 200
6: isstore_2
7: sipush 300
10: istore_3
11: iload_1
12: iload_2
13: iadd
14: iload_3
15: imul
16: ireturn
}
第3行表示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间.
首先执行偏移地址为0的指令,bipush指令讲单字节的整型常量压入栈,滚虽有一个参数指明压栈的常量值.
然后执行istore_1指令将栈顶的元素保存在第1个Slot中.
后面重复这个过程,将100,200,300分别放入第1,2,3个Slot中.
执行偏移地址为11的指令,iload_1指令可以将第1个Slot中的整型值压入栈顶.12指令功能相同.
然后执行13指令iadd,将操作数栈中的前两个元素出栈,并将它们的和压入栈中.
14指令将局部变量表中的300压入栈中.
15指令与13指令类似,是将两数的乘积压入栈中.
16指令ireturn结束此方法的调用,并将操作数栈顶的元素返回给调用者.
以上仅仅是概念上的做法,实际上虚拟机还会对执行过程做一些优化,确切的说,实际情况会和上面的描述相差非常大.
还没有评论,来说两句吧...