虚拟机字节码执行引擎(笔记)

一、概述

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

执行引擎在执行代码的时候可能有两种选择,一种是解释执行(通过解释器执行),另一种是编译执行(通过即时编译器产生本地代码执行)。但在外观上看起来所有的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的执行结果。

二、运行时栈帧结构

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

存储内容:方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧的入栈和出栈。

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

一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。但是只有位于栈顶的栈帧才是有效的,称为“当前栈帧”,与这个栈帧相关联的方法称为“当前方法”。执行引擎运行的所有字节码指令只对当前栈帧进行操作。典型的栈帧结构如下:

1、局部变量表

1.1.作用:一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。

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

1.3.单位:局部变量表的容量以变量槽(Slot)为最小单位,没有明确指定大小。只是很有导向性地说到每个Slot都应该可以使用32位或更小的物理内存来存放。

1.4.Java中占用32位以内的数据类型:

boolean、byte、char、short、int、float、reference和returnAddress。

前6种数据类型可以按照Java语言中对应数据类型的概念去理解它们(仅是这样理解而已,Java语言和Java虚拟机中的基本数据类型是存在本质差别的)。

reference类型:

#虚拟机规范既没有说明它的长度,也没有明确指出这种引用应有怎样的结构

#作用:A、从此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引;B、此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息。

1.5.虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。

特殊:如果执行的是实例方法,那么这个局部变量表的第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过this关键字来访问这个参数。

为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的。但是会带来一些副作用。在某些情况下它会影响到系统的垃圾收集行为。

package Initialization;

public class test1 {
	public static void main(String[] args){
		{
			byte[] placeholder=new byte[64*1024*1024];
		}
		//int a=0;
		System.gc();
	}
}

这段代码在触发GC的时候并不会回收placeholder所占的空间,尽管它已经离开了作用域。因为在此之后没有任何对局部变量表的读写操作,placeholder原本所占用的Slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联,GC无法回收。我们可以手动将其设置为null来解决这个问题。这种操作可以作为一种在极特殊情况(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到JIT的编译条件)下的“奇技”来使用。

更好地解决方案:以恰当的变量作用域来控制变量回收时间。

注意点:经过JIT编译器后,才是虚拟机执行代码的主要方式,赋null值的操作在经过JIT编译优化后就会被消除掉,这时候将变量设置为null就是没有意义的。字节码被编译为本地代码后,对GC Roots的枚举也与解释执行时期有巨大差别,上面那个例子在经过JIT编译后,System.gc()执行时就可以正确地回收掉内存。

与“类变量”的区别:局部变量表不像类变量那样存在“准备阶段”。类变量存在两次初始化,一次在准备阶段,赋予系统初始值(零值),一次在初始化阶段,赋予程序员定义的初始值。因此,即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但是局部变量不同,如果一个局部变量定义了但没有赋初值是不能使用的,Java中并不是任何情况下都存在诸如整形变量默认为0等操作的。

程序报错,提示我们要给变量赋初值。

2、操作数栈

2.1.定义:也常成为操作栈,它是一个后入先出栈。

2.2.容量:同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks中。

2.3.要求:操作数栈中元素的数据类型必须与字节码指令的序列严格匹配。

2.4.注意点:在概念模型中,两个栈帧作为虚拟机栈的元素,是完全独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无需进行额外的参数复制传递。

3、动态连接

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

3.2.静态解析和动态连接

静态解析:Class文件的常量池中存储的大量符号引用一部分会在类解析阶段或者第一次使用的时候就转化为直接引用,这种转化就称为静态解析。

动态连接:另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

4、方法返回地址

4.1.方法退出的两种方式

#正常完成出口:执行引擎遇到任意一个方法返回的字节码指令。(可能有返回值)

#异常完成出口:在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到处理。(不会产生任何返回值)

4.2.返回地址

无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。

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

5、附加信息

这部分信息完全取决于具体的虚拟机实现。一般会把动态连接、方法返回地址、附加信息全部归为一类,称为栈帧信息。

 

三、方法调用

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

1、解析

1.1.定义:在类加载的解析阶段,会将Class文件中的一部分方法的符号引用转化为直接引用。这类方法的调用称为解析。

1.2.解析成立的条件:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。也就是说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。(“编译器可知,运行期不可变”)

1.3.符合条件的方法:静态方法和私有方法。前者与类型直接关联,后者在外部不可访问,这两种方法各自的特点决定了他们都不可能通过继承或别的方式重写其他版本。

1.4.5条方法调用字节码指令

#invokestatic:调用静态方法

#invokespecial:调用实例构造器<init>方法、私有方法和父类方法

#invokevirtual:调用所有的虚方法

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

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

1.5.虚方法和非虚方法

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法四类,它们在类加载的时候就会把符号引用解析为直接引用。这些方法称为非虚方法。与之相反,其他方法称为虚方法(除去final方法)。

final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,多态选择的结果肯定是唯一的。因此它也是一个非虚方法。

1.6.解析和分派的不同

解析调用一定是个静态的过程,而分派调用则可能是静态的也可能是动态的。

2、分派

2.1.静态分派

A、静态分派演示

测试代码

package Initialization;

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);
	}
	
}

运行结果:

概念补充——静态类型和实际类型:

Human man=new Man();

我们把代码中的“Human”称为变量的静态类型(Static Type),或者叫做变量的外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type)。静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型属性并不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。我们改变一下代码,如下图

	public static void main(String[] args){
		//实际类型变化
		Human man=new Man();
		Human woman=new Woman();
		StaticDispatch sr=new StaticDispatch();	
		sr.sayHello(man);
		sr.sayHello(woman);
		System.out.println("----------");
		//静态类型变化
		sr.sayHello((Man)man);
		sr.sayHello((Woman)woman);
		System.out.println("----------");
		sr.sayHello(man);
		sr.sayHello(woman);
	}

运行结果

结果解析:也就是说man和woman这两个本身的静态类型已经固定了,如果你想改变它的静态类型,只有在使用的时候强制改变,但是使用完,它又会恢复为原来的静态类型。而实际类型变化如下:

//实际类型变化
Human man=new Man();
man=new Woman();

结果解析:

main()里面的两次sayHello()方法调用,在方法接受者已经确定时对象“sr”的前提下,使用哪个重载版本完全取决于传入参数的数量和数据类型。虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此在编译阶段,javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。

B、定义:所有依赖静态类型来定位方法的执行版本的分派动作称为静态分派。——典型应用:方法重载

C、发生时期:编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

D、重载方法匹配优先级

原因:在很多情况下重载版本并不是“唯一的”,往往只能确定一个“更加适合的”版本。产生这种模糊的原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。

测试代码

package Initialization;

public class Overload {
	public static void sayHello(Object arg){
		System.out.println("Hello Object~");
	}
	public static void sayHello(int arg){
		System.out.println("Hello int~");
	}
	public static void sayHello(char arg){
		System.out.println("Hello char~");
	}
	
	public static void main(String[] args){
		Overload.sayHello('a');
	}
}

测试结果

注释掉参数为char的那个方法,运行结果

注释掉参数为int的那个方法,运行结果

总结:当我们传入的参数是个字面量的时候,编译器会根据一定的优先级来为匹配“最合适的”重载方法。

2.2.动态分派

A、动态分配的演示

测试代码

package Initialization;
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();
	}
	
}

运行结果

结果解析:虚拟机是如何知道要调用哪个方法的呢?显然这里不可能再根据静态类型来决定了,因为这两个变量的静态类型同样都是Human,但是调用的方法却不一样。显然虚拟机是根据实际类型来分派方法执行版本的。

invokevirtual指令的运行时解析步骤:

#找到操作数栈顶的第一个元素所指向的对象的实际类型,记为C

#如果在类型C中找到与常量中中的描述符和简单名称都相符的方法,则进行权限访问校验,如果通过就返回这个方法的引用;否则抛出IllegalAccessError异常

#否则按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程

#如果始终没有找到合适的方法,则抛出AbstractMethodError异常

由于invokevirtual指令执行的第一步就是在运行期确定接受者的类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中类重写的本质。

B、动态分配的定义:在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

C、虚拟机动态分配的实现

问题:动态分配时非常频繁的动作,为了降低搜索的频率需要对其进行优化

优化:建立一个虚方法表(Virtual Method Table,itable),使用虚方法表索引来代替元数据查找以提高性能。——“稳定手段”。另外还有两种“激进手段”:内联缓存和基于“类型继承关系分析”技术的守护内联。

方法表的结构:

#虚方法表中存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那么子类的虚方法表里面的地址入口和父类相同方法的地址入口是一样的,都指向父类的实现入口。如果子类重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。

#为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号(同一行)。这样当类型变换时,仅需要变更查找的方法表(行号不用变),就可以从不同的虚方法表汇总按索引转换出所需的入口地址。

#方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

2.3.单分派和多分派

A、划分标准:根据分派基于多少种宗量(方法的接收者和方法的参数统称为宗量),将分派划分为单分派和多分派两种。

单分派是根据一个宗量对目标方法进行选择,多分派是根据多个宗量对目标方法进行选择。

B、过程演示

演示代码

package Initialization;

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还是Son;二是方法参数是QQ还是360。这次选择结果的最终产物是产生了两条invokevirtual指令,分别指向Father.hardChoice(360)和Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型

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

总结:今天的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的值才有类型,编译时最多只能确定方法名称、参数、返回值这些信息,而不会确定方法所在的具体类型(即方法接受者不固定)。

3.2.JDK1.7和动态类型

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

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

3.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语言。

3.4.invokedynamic指令

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

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

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

 

四、基于栈的字节码执行引擎

1、现代经典编译原理的思路

2、基于栈的指令集与基于寄存器的指令集

2.1.基于栈的指令集

#典型应用:Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,它们以来操作数栈进行工作。

#格式:(以计算1+1为例)

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

#优点:A、可移植。使用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能。B、代码相对紧凑。字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数。C、编译器实现更加简单。不需要考虑空间分配的问题,所需空间都在栈上操作。

#缺点:执行速度相对来说会稍慢一些。(指令数量和内存问题)所有物理机的指令集都是寄存器架构也从侧面印证了这一点。虽然代码紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是栈在内存中,频繁的栈操作就意味着频繁的内存访问。

2.2.基于寄存器的指令集

#典型应用:x86的二进制指令集,也就是我们主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作

#格式:(以计算1+1为例)

mov eax,1//把eax寄存器的值设为1
add eax,1//把eax寄存器的值再加上1,结果保存到eax中

#优点:执行速度快。

#缺点:不可移植。寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。

 

博客内容来自《深入理解Java虚拟机》

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值