深入理解Java虚拟机之虚拟机字节码执行引擎

1.概述
“虚拟机是相对于“”物理机”的一个概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器,硬件,指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

虚拟机的执行引擎:输入字节码,处理是字节码解析的等效过程,输出的是执行结果。

2.运行时栈帧结构
在编译程序代码的时候,栈帧需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写到了方法表的Code属性中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法
执行引擎所有字节码指令都只针对当前栈帧进行操作。
在这里插入图片描述
2.1局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。

在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中就确定了该方法所需要分配的局部变量表的最大容量。

局部变量表的容量以变量槽(称Slot)为最小单位,每个Slot可以存放一个boolean,byte,char,short,int,float,reference或returnAddress类型的数据,这8种数据类型。

reference类型表示一个对象实例引用,虚拟机通过这个引用直接或间接地查找到对象所属数据类型在方法区中存储的类型信息。

returnAddress是古老虚拟机来实现异常处理,现在由异常表替代。

方法执行时如果是实例方法,局部变量表的第0位索引默认用于传递方法所属对象实例的引用,方法中可以通过“this”来访问到这个隐含的参数。从1位开始先分配方法参数表,再分配方法内定义的变量按定义顺序。

为了节省空间Slot可以重用。如何重用:方法体定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。但是在某些情况下会有副作用,如影响系统gc。

2.2操作数栈
操作数栈也常称为操作栈,后进先出(LIFO)栈。操作数栈的最大深度同局部变量表一样在编译时就写入Code属性的max_stacks数据项。

操作数栈的元素可以是Java中的任意数据类型,32位占一个Slot,64位(double,long)占两个。方法执行的时候是不会超过max_stacks数据项设定的最大值的。

方法刚开始执行操作数栈是空的,方法执行的过程中会有各种字节码指令往操作数栈写入和提取内容。

大多数虚拟机的实现里会让两个栈帧出现的一部分重叠。下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,在进行方法调用的时候就可以共用一部分数据,无需进行额外的参数复制传递。
在这里插入图片描述
2.3动态连接
Class文件的常量池有大量的符号引用,字节码中方法的调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分在每一次运行期间转化为直接引用,这部分称为动态连接

2.4方法返回地址
退出方法的两种方式

  • 正常完成出口:执行引擎遇到任意一个方法返回的字节码指令。
  • 异常完成出口:执行过程中遇到了异常,异常没有在方法体内处理,无论什么异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。

方法退出时,栈帧会保存pc计数器的值作为返回地址,用来恢复它的上层方法的执行状态。

2.5附加信息
虚拟机允许增加一些规范里没有描述的信息到栈帧中,比如调试信息。

3.方法调用
任务:确定被调用方法的版本(调用哪一个方法),暂时不涉及内部的具体运行过程。

Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都是符号引用,而不是方法在实际运行时的内存地址(相当于直接引用)。所以Java方法调用过程复杂,需要在类加载阶段甚至运行期间才能确定目标方法的直接引用。

3.1解析
如果一个方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期间不可改变。那么这类方法的调用称为解析。类加载阶段(解析阶段)将其中的一部分符号引用(在常量池中)转化为直接引用。
符合解析方法调用的方法主要包括 静态方法,父类方法,被final修饰的方法,实例构造器,私有方法。称为非虚方法。能被invokestatic,invokespecial指令调用(final被invokevirtual调用)。
调用方法的条字节码指令

  • invokestatic:调用静态方法

  • invokespecial:调用实例构造器< init>方法,私有方法,父类方法。

  • invokevirtual:调用所有虚方法

  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象

  • invokedynamic:先在运行时动态解析出调用点限符所引用的方法,然后再执行该方法。

    前4条指令分派逻辑固化在虚拟机内部,invokedynamic指令的分派逻辑是由用户所设定的引导方法所决定的。

3.2分派
静态分派
演示代码

public class StaticDispatch {
	static abstract class Human{	
	}
	
	static class Man extends Human{	
	}
	
	static class Woman extends Human{
	}
	
	public void sayHello(Human guy){
		System.out.println("hello,guy");
	}
	
	public void sayHello(Man guy){
		System.out.println("hello,gentleman");
	}
	
	public void sayHello(Woman huy){
		System.out.println("hello,lady");
	}
	
	public static void main(String[] args){
		Human man=new Man();
		Human woman=new Woman();
		StaticDispatch sr=new StaticDispatch();
		sr.sayHello(man);
		sr.sayHello(woman);
	}
	
}

运行结果

hello,guy
hello,guy
Human man = new Man();

上面代码中的“Human”称为变量的静态类型,也称为外观类型,后面的“Man”称为实际类型。
静态类型的变化仅仅在使用时发生,最终的静态类型是在编译器可知的;实际类型变化的结果在运行期才可确定,编译器在编译程序的时候不知道一个对象的实际类型是什么。
动态分派

在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
动态分派与多态的重要体现重写有密切的关联。
下面看演示代码


public class DynamicDispatch {
	static abstract class Human{
		protected abstract void sayHello();
	}
	static class Man extends Human{
		@Override
		protected void sayHello() {
			System.out.println("man say Hello~");
		}
		
	}
	static class Woman extends Human{
		@Override
		protected void sayHello() {
			System.out.println("woman say Hello~");
		}
	}
	public static void main(String[] args){
		//实际类型变化
		Human man=new Man();
		Human woman=new Woman();
		man.sayHello();
		woman.sayHello();
		man=new Woman();
		man.sayHello();
	}
	
}

输出结果

man say Hello~
woman say Hello~
woman say Hello~

3.单分派与多分派
宗量:方法的接收者与方法的参数
单分派:根据一个宗量对目标方法进行选择 多分派:根据多于一个宗量对目标方法进行选择

单分派与多分派的演示代码


public class Dispatch {
	static class QQ{}
	
	static class _360{}
	public static class Father{
		public void hardChoice(QQ arg){
			System.out.println("father choose qq");
		}
		
		public void hardChoice(_360 arg){
			System.out.println("father choose 360");
		}
	}
	
	public static class Son extends Father{
		public void hardChoice(QQ arg){
			System.out.println("son choose qq");
		}
		
		public void hardChoice(_360 arg){
			System.out.println("son choose 360");
		}
	}
	
	public static void main(String[] args){
		Father father=new Father();
		Father son=new Son();
		father.hardChoice(new _360());
		father.hardChoice(new QQ());
		
	}
}

运行结果

father choose 360
son choose qq

结果解析:
编译阶段编译器的选择过程(静态分派过程)
选择目标方法的依据有两点:一是静态类型Father还是Son,二是方法参数是QQ还是360.选择结果最终产物是产生了两条invokevirtual指令,两条指令参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。根据两个宗量进行选择,所以静态分派。
虚拟机选择阶段(动态分派过程)
在执行“son.hardChoice(new QQ())”这句代码时,更准确地说,是在执行这句代码的invokevirtual指令时,由于编译器已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ”,因为这时参数的静态类型,实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son.
根据一个宗量进行选择,所以动态分派过程是单分派类型。
总结:Java是一门静态多分派动态单分派的语言。

虚拟机动态分派的实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。 虚方法表存放着各个方法的实际入口地址。
3.3动态类型语言的支持

1.动态类型语言

A、关键特征:它的类型检查的主体过程是在运行期而不是编译期。

比如:int[] array=new int[-1]这段代码能够正常编译,但在运行时会抛出异常。而在C++中int array[-1]在编译时就会报错。

B、类型检查——“变量无类型而变量值才有类型”

比如:obj.println(“Hello World”);

这段代码“没头没尾”是无法执行的,它需要一个具体的上下文才有讨论的意义。

假设它在Java语言中,并且变量obj的静态类型为java.io.PrintStream,那变量obj的实际类型就必须是PrintStream的子类(实现了PrintStream接口的类)才是合法的。否则哪怕obj属于一个确实有println(String)方法,但与PrintStream接口没有继承关系,代码也不可能运行——因为类型检查合法。因为Java语言在编译期间已将println(String)方法完整的符号引用生成出来,作为方法调用指令的参数存到Class文件中。

但相同的代码在JavaScript中情况则不一样,无论obj具体是何种类型,只要这种类型的定义中确实包含有println(String)方法,那方法便可调用成功。因为在动态语言中,变量obj本身是没有类型的,变量obj的值才有类型,编译时最多只能确定方法名称、参数、返回值这些信息,而不会确定方法所在的具体类型(即方法接受者不固定)。

2.JDK1.7和动态类型

不足:4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接受者类型。这样在Java虚拟机上实现的动态类型语言就不得不使用其他方式来实现,增加复杂度,带来额外的性能或内存开销。

解决:引入了invokedynamic指令以及java.lang.invoke包

3.java.lang.invoke包

目的:在之前单纯依靠符号引用来确定调用目标方法这种方式之外,提供一种新的动态确定目标方法的机制,称为MethodHandle。(把另一个函数当做参数)

格式:void sort(Lsit list,MethodHandle cpmpare)

MethodHandle与反射的区别:

#从本质上讲:Reflection和MethodHandle机制都是在模拟方法调用,但Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用

#Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息多。Reflection是重量级,而MethodHandle是轻量级

#由于MethodHandle是对字节码的方法指令调用的模拟,所以理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前还不完善)。而通过反射去调用方法则不行。

关键点:Reflection API的设计目标是只为Java语言服务的,而MethodHandle则设计成可服务于所有的Java虚拟机之上的语言,当然也包括Java语言。

4.invokedynamic指令

目的:invokedynamic指令和MethodHandle一样为了解决原有4条“invoke*”指令分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体的用户代码中,让用户有更高的自由度。前者采用上层Java代码和API来实现,后者用字节码和Class中其他属性、常量来完成。

“动态调用点”:每一处含有invokedynamic指令的位置

参数:invokedynamic指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是CONSTANT_InvokeDynamic_info常量。它提供了三个信息:引导方法(Bootstrap Method,此方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。

invokedynamic指令与前面4条“invoke”指令最大差别就是它都的分派逻辑不是由虚拟机决定,而是由程序员决定。

4基于栈的字节码解释执行引擎(虚拟机是如何执行字节码的)
4.1解释执行
现代经典编译原理的思路
在这里插入图片描述
Java语言中,javac编译器完成了程序代码经过语法分析,语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是再Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立实现的。
4.2基于栈的指令集与基于寄存器的指令集
基于栈的指令集:Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。

主要优点:主要是可移植(因为寄存器受硬件约束) 其他优点:代码紧凑(每个字节对应一条指令,多地址指令集还要存放参数),编译器实现更加简单(不用考虑空间分配)

主要缺点:执行速度相对稍慢。(因此主流物理机的指令集都是寄存器架构)
基于寄存器的指令集:典型的就是x86的二地址指令集,说的通俗些,就是现在我们主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作。

主要优点:执行速度块
缺点:不可移植

(以计算1+1为例)基于栈的指令集

iconst_1//把常量1入栈
iconst_1//把常量1入栈
iadd//把两个常量出栈,相加,把结果放回栈顶
istore_0//把栈顶的值放到局部变量表的第0个Slot中

(以计算1+1为例)基于寄存器的指令集

mov eax,1//把eax寄存器的值设为1
add eax,1//把eax寄存器的值再加上1,结果保存到eax中
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值