读书笔记 ---- 《深入理解Java虚拟机》---- 第7篇:虚拟机字节码执行引擎

上一篇:虚拟机类加载机制:https://blog.csdn.net/pcwl1206/article/details/84260914

第7篇:虚拟机字节码执行引擎

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

所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理的过程是字节码解析的等效过程,输出的是执行结果

1、运行时栈帧结构

  • 栈帧(Stack  Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。

  • 栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。

  • 每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

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

  • 一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是最有效的,称为当前栈(Current  Stack  Frame),与这个栈帧相关联的方法称为当前方法(Current  Method)。执行引擎运行的字节码指令都只针对当前栈帧进行操作。

1.1  局部变量表

局部变量表(Local  Variable  Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。

  • 局部变量表的容量以变量槽(Slot)为最小单位,每个Slot都应该能存放下一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,对于64位的数据类型只有double和long两种(reference可能是32位也可能是64位),这两种数据类型占两个Slot。
  • 虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程,如果执行的是实例方法(非static方法),那局部变量表中的第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数列表排列,占用从1开始的局部变量表Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot
  • 局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。但是Slot的复用会影响到垃圾回收。具体影响这里不再详解,可看书上241页案例讲解。
  • 类变量有两次的赋值过程,一次在准备阶段,赋予系统初始值(比如:int默认值为0,booelan默认值为false,object类型默认为null),另外一次在初始化阶段,赋予程序员定义的初始值。因此即使在初始化阶段,程序员没有对类变量再进行赋值也没有关系,类变量仍然具有一个默认的初始值。但是局部变量如果定义了但是没有在初始化阶段赋予初始值,那么将导致类加载失败。如下所示:
// 未赋值的局部变量表,将导致加载失败
public static void main(String[] args){
    int a;  // 定义了,但是未赋值
    System.out.println(a);
} 

1.2  操作数栈

操作数栈(Operand)也常称为操作栈,它是一个后入先出(Last  In  First  Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。

当一个方法刚刚开始的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。

两个栈帧之间的数据共享:下面的栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数赋值传递。

  • Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。

1.3  动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic  LInking)

Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中所指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

1.4  方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法:

第一种是:执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值类型将根据遇到何种方法返回指令来决定,这种退出的方法称为正常完成出口

第二种是:在方法的执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

无论采用何种退出方式,在方法退出后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令。


2、方法调用

方法调用不等同于方法执行,方法调用的唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程

Class文件的编译过程中不包括传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。

2.1  解析

所有方法调用中的目标方法在Class文件里面都是一个常量池的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。符合这个条件的有:静态方法、私有方法、实例构造器和父类方法四类,它们在加载的时候会把符号引用解析为该方法的直接引用。

解析调用一定是一个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。

2.2  分派

分派调用可以是静态的也可以是动态的,根据分派依据的宗量数可分为单分派和多分派。多分派机制与Java的多态(重载和重写)机制关系密切。比如:重载的方法有多个,那么应该选择那一个呢?如何选择?

分派的具体分类如下:

  1. 静态分派:依赖静态类型来定位方法执行版本的分派动作,称为静态分派。静态分派的最典型的应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的;
  2. 动态分派:在运行期根据实际类型来确定方法执行版本的分派调用过程称为动态分派。这和多态的另一个体现----重写有着密切的关系;
  3. 单分派:根据一个宗量对目标方法进行选择;
  4. 多分派:根据多于一个的宗量对目标方法进行选择。

1、静态分派

如下代码中定义了两个静态类型相同但实际类型不同的变量Man和Woman,但虚拟机(准确说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标。

Human man = new Man();

代码中的“Human”称为:变量的静态类型/外观类型;“Man”称为:变量的实际类型。

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 guy){
		System.out.println("hello, lady!");
	}
	
	public static void main(String[] args) {
		Human man = new Man();
		Human woman = new Woman();
		StaticDispatch sd = new StaticDispatch();
		sd.sayHello(man);    // hello, guy!
		sd.sayHello(woman);  // hello, guy!
	}
}
// 实际类型变化
Human man = new Man();
man = new Woman();

// 静态类型变化
sr.sayHello((Man)man);
sr.sayHello((Woman)man);

说明:书上对重载方法匹配的优先级做了案例讲解,这里不再贴代码了,需要可以看书249页。

2、动态分派

看下面的案例中的代码:显然这里不可能再根据静态类型来决定,因为静态类型同样是Human的两个变量man和woman在调用sayHello()方法时执行了不同的行为。导致这个现象的原因是这两个变量的实际类型不同,Java虚拟机根据实际类型来分派方法的执行版本。

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();       // man say hello
		woman.sayHello();     // woman say hello
		man = new Woman();
		man.sayHello();       // woman say hello
	}
}

3、单分派与多分派

方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

public class Dispatch {

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

编译阶段编译器的选择过程,也就是静态分派过程。这h时选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择的参数是QQ还是360.这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)以及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

运行阶段虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoice(QQ())”这句代码对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须是hardChoice(QQ),这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属性属于单分派类型。

综上:现在的Java语言是一门静态多分派、动态单分派的语言

4、虚拟机动态分派的实现

虚拟机在分派中“会做什么?”上面已经讲了,那么虚拟机“具体是如何做到的?”,不同虚拟机会有所差别。

动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机实际实现中基于性能的考虑,大部分实现都不会真正进行如此频繁的搜索。

常用的稳定优化手段是为类在方法区中建立一个虚方法表。虚方法表存放着各个方法的实际入口地址。

2.3  动态语言支持

动态类型语言的关键特征是:它的类型检查的主体过程是在运行期而不是在编译期

在编译期就进行类型检查过程的语言(Java和C++等)就是最常用的静态语言。JDK1.7中新增invokedynamic指令用于支持动态语言。

super关键字可以访问到父类中的方法,如果要访问祖父的方法可以使用invokedynamic指令的分派,它的逻辑就是按照方法接收者的实际类型进行分派。


3  基于栈的字节码解释执行引擎

上面主要讲诉了如何调用方法的内容,本节对虚拟机是如何执行方法中的字节码指令进行讲解。虚拟机的执行引擎在执行Java代码的时候都有解释执行编译执行两种选择。

3.1  解释执行  

Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。

3.2  基于栈的指令集与基于寄存器的指令集

Java编译器输出的指令流,基本上是一种基于栈的指令集架构。

基于栈的指令集的优缺点:

优点

1、可移植;

2、代码相对更加紧凑;

3、编译实现更加简单。

缺点

1、指令数多,出栈入栈产生太多的指令;

2、频繁访问内存,增加CPU压力,执行速度相对较慢

3.3 基于栈的解释执行过程

看下下面这个例子,再把执行过程图贴出来,但是注意这里的执行过程仅仅是一种概念模型,虚拟机往往会对其中的执行过程进行优化以提高性能。

先看一段简单的算术代码:

public int calc(){
    int a = 100;
    int b = 200;
    int c = 300;
    return (a+b)*c;
}

使用javap命令查看它的字节码指令:

javap提示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间。

具体执行过程如下:


上一篇:虚拟机类加载机制:https://blog.csdn.net/pcwl1206/article/details/84260914

下一篇:

参考及推荐:

1、虚拟机字节码执行引擎:https://blog.csdn.net/a724888/article/details/78404643

 

猜你喜欢

转载自blog.csdn.net/pcwl1206/article/details/84314002
今日推荐