精华内容
参与话题
问答
  • 字节码执行引擎

    2018-12-20 15:04:35
    栈帧: 局部变量表:编译时确定大小,slot(一般32bit,可存放reference用于找到对象和类型数据) ...操作数栈:编译时确定大小,基于栈(即操作栈)的解释执行引擎 动态连接:指向方法的引用(运行...

    栈帧:
    局部变量表:编译时确定大小,slot(一般32bit,可存放reference用于找到对象和类型数据)
    连续2slot(64bit,long,double,可以非原子操作,反正这是线程私有的)
    实例方法this,参数,局部变量
    static方法参数,局部变量
    slot可重用,局部变量必须先赋值后使用

    操作数栈:编译时确定大小,基于栈(即操作栈)的解释执行引擎

    动态连接:指向方法的引用(运行时常量池(含字面量,符号引用(对类,Field,Method等的引用),直接引用))

    返回地址:正常退出(可带返回值),异常退出,返回值压入调用者的操作数栈,pc后移。

    invokestatic 静态方法,非虚
    invokespecial init,private,父类方法,非虚
    invokevirtual final(非虚),其他虚
    invokeinterface 虚
    invokedynamic
    方法调用:指确定方法版本
    解析调用:编译期确定好,类加载时符号引用转直接引用,非虚。

    分派(dispatch)调用 :编译和运行期共同确定版本
    静态分派:编译期,根据(调用者静态类型,参数静态类型,参数个数,多分派)确定方法重载版本
    静态分派不确定性,'a’作为参数,可以依次调用char,int,long,Character,Serializable,Object,char…的 重载版本
    动态分派:运行期,根据 调用者实际类型(单分派)确定方法重写版本
    找到实际类型后不断向上找,并需进行访问权限校验
    实现:虚方法表(每个类都有虚方法表,包含所有方法的地址(Object方法也在内),重写方法指向自己类型数据(方法实现的入口地址),未重写指向父类类型数据)

    基于栈的指令集,多为零地址指令,依赖操作数栈工作
    可移植,不受硬件(寄存器)约束,虚拟机可将hot的数据放于寄存器,代码紧凑(如一个byte对应一个指令)
    但是执行慢:
    1 指令数量多,大量出入栈
    2 栈在内存中,频繁访存也慢
    1+1 (iconst_1 iconst_1 iadd istore_0 ) ( mov eax, 1 add eax, 1)

    在这里插入图片描述

    上图显示执行偏移为2的指令执行情况。

    展开全文
  • 基于栈的解释器执行过程 参考书籍:《深入理解Java虚拟机——JVM高级特性与最佳实践(第2版)》 知识点回顾:javac编译器通过对程序代码进行词法分析、语法分析、生成抽象语法树、遍历抽象语法树等复杂的编译过程,...

    目录

    运行时栈帧结构

    局部变量表

    操作数栈

    动态连接

    方法返回地址

    方法调用

    解析

    分派

    静态分派

    动态分派

    单分派与多分派

    基于栈的解释器执行过程


    参考书籍:《深入理解Java虚拟机——JVM高级特性与最佳实践(第2版)

    知识点回顾:javac编译器通过对程序代码进行词法分析、语法分析、生成抽象语法树、遍历抽象语法树等复杂的编译过程,最终,将程序代码变成了Class字节码文件。然后,生成的Class字节码文件在经历过加载、验证、准备、解析、初始化等阶段之后才能被使用/卸载。

    运行时栈帧结构

    栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。 栈帧存储了方法的局部变量表、 操作数栈、 动态连接和方法返回地址等信息。 每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

    一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。 对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current StackFrame),与这个栈帧相关联的方法称为当前方法(Current Method)。 执行引擎运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如下图所示。 

    局部变量表

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

    局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,在基本数据类型中,64 位长度的long 和double 类型的数据会占用2 个连续局部变量空间(高位对齐),其余的数据类型只占用1 个。由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问。

    在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static的方法),那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。 其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。

    为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。在某些情况下,Slot的复用会影响垃圾收集器的行为,例如:被复用的Slot中所引用的变量在超出变量的作用域后,但该Slot空间还没有被其他变量所占用,会导致作为GC Roots一部分的局部变量表仍然保持着对旧变量的关联,进而导致旧变量无法被回收。  

    操作数栈

    操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。 同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。 操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。 32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。 在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

    当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作,而且操作数栈中元素的数据类型必须与字节码指令的序列严格匹配。

    举个例子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。 

    动态连接

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

    方法返回地址

    当一个方法开始执行后,只有两种方式可以退出这个方法。 第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。

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

    无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

    方法调用

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

    Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。 这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

    解析

    所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。 换句话说,调用目标在程序代码写好、 编译器进行编译时就必须确定下来。 这类方法的调用称为解析(Resolution)。

    在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

    与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令,分别如下。

    1. invokestatic:调用静态方法。
    2. invokespecial:调用实例构造器<init>方法、 私有方法和父类方法。
    3. invokevirtual:调用所有的虚方法。
    4. invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
    5. invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

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

    Java中的非虚方法除了使用invokestatic、 invokespecial调用的方法之外还有一种,就是被final修饰的方法。 虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。

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

    分派

    静态分派

    Object obj=new String();

    形如上面这行代码,我们把代码中的“Object ”称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent Type),后面的“String”则称为变量的实际类型(Actual Type)。

    静态类型和实际类型的区别在于:一个变量的静态类型是不会被改变的,并且其最终的静态类型是在编译期可知的;而实际类型变化的结果要到运行期才能确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。  

    虚拟机(准确地说是编译器)在重载方法选择时是通过参数的静态类型而不是实际类型作为判定依据的,我们把这种依赖静态类型来定位方法执行版本的分派动作称为静态分派。 

    /**
     * 方法静态分派演示
     */
    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 sr = new StaticDispatch();
    		sr.sayHello(man);
    		sr.sayHello(woman);
    	}
    }

    动态分派

    所有依赖实际类型来定位方法执行版本的分派动作称为动态分派,比较典型的一个体现就是重写(Override)。 

    /**
     * 方法动态分派演示
     */
    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();
    	}
    }
    

    单分派与多分派

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

    基于栈的解释器执行过程

    通过一段Java代码,看看在虚拟机中实际是如何执行的。

    package com.pengjunlee;
    
    public class Test {
    	public int calc() {
    		int a = 100;
    		int b = 200;
    		int c = 300;
    		return (a + b) * c;
    	}
    }
    

    使用javap命令看看它的字节码指令,如下图所示。 

    javap提示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间,下面的7张图,用它们来描述代码执行过程中的指令、 操作数栈和局部变量表的变化情况。 

      

    展开全文
  • 深入JVM字节码执行引擎

    万次阅读 多人点赞 2015-10-30 18:57:11
    我们都知道,在当前的Java中(1.0)之后,编译器讲源代码转成字节码,那么字节码如何被执行的呢?这就涉及到了JVM的字节码执行引擎,执行引擎负责具体的代码调用及执行过程。

    我们都知道,在当前的Java中(1.0)之后,编译器讲源代码转成字节码,那么字节码如何被执行的呢?这就涉及到了JVM的字节码执行引擎,执行引擎负责具体的代码调用及执行过程。就目前而言,所有的执行引擎的基本一致:

    1. 输入:字节码文件
    2. 处理:字节码解析
    3. 输出:执行结果。

    物理机的执行引擎是由硬件实现的,和物理机的执行过程不同的是虚拟机的执行引擎由于自己实现的。


    运行时候的栈结构

    每一个线程都有一个栈,也就是前文中提到的虚拟机栈,栈中的基本元素我们称之为栈帧。栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构。每个栈帧都包括了一下几部分:局部变量表、操作数栈、动态连接、方法的返回地址 和一些额外的附加信息。栈帧中需要多大的局部变量表和多深的操作数栈在编译代码的过程中已经完全确定,并写入到方法表的Code属性中。在活动的线程中,位于当前栈顶的栈帧才是有效的,称之为当前帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令只针对当前栈帧进行操作。需要注意的是一个栈中能容纳的栈帧是受限,过深的方法调用可能会导致StackOverFlowError,当然,我们可以认为设置栈的大小。其模型示意图大体如下:
    运行时栈结构


    针对上面的栈结构,我们重点解释一下局部变量表,操作栈,指令计数器几个概念:

    1、局部变量表

    是变量值的存储空间,由方法参数和方法内部定义的局部变量组成,其容量用Slot1作为最小单位。在编译期间,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。如果是实例方法,那局部变量表第0位索引的Slot存储的是方法所属对象实例的引用,因此在方法内可以通过关键字this来访问到这个隐含的参数。其余的参数按照参数表顺序排列,参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。我们知道类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。举例说明:

    public void test(){
        call(2,3);
        ...
        call2(2,3);
    }
    
    public void call(int i,int j){
        int b=2;
          ...
    }
    
    public static void call2(int i,int j){
        int b=2;
        ...
    }

    为了方便起见,假设以上两段代码在同一个类中。这时call()所对应的栈帧中的局部变量表大体如下:
    实例方法局部变量表
    而call2()所对应的栈帧的局部变量表大体如下:
    类方法局部变量表


    2、操作数栈

    后入先出栈,由字节码指令往栈中存数据和取数据,栈中的任何一个元素都是可以任意的Java数据类型。和局部变量类似,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。当一个方法刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数中写入和提取内容,也就是出栈/入栈操作。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配2,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。另外我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。


    3、动态连接

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


    4、方法返回地址

    存放调用调用该方法的pc计数器的值。当一个方法开始之后,只有两种方式可以退出这个方法:1、执行引擎遇到任意一个方法返回的字节码指令,也就是所谓的正常完成出口。2、在方法执行的过程中遇到了异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种方式成为异常完成出口。正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
    无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置,方法正常退出时,调用者的pc计数器的值作为返回地址,而通过异常退出的,返回地址是要通过异常处理器表来确定,栈帧中一般不会保存这部分信息。本质上,方法的退出就是当前栈帧出栈的过程。


    方法调用

    方法调用的主要任务就是确定被调用方法的版本(即调用哪一个方法),该过程不涉及方法具体的运行过程。按照调用方式共分为两类:

    1. 解析调用是静态的过程,在编译期间就完全确定目标方法。
    2. 分派调用即可能是静态,也可能是动态的,根据分派标准可以分为单分派和多分派。两两组合有形成了静态单分派、静态多分派、动态单分派、动态多分派

    解析

    在Class文件中,所有方法调用中的目标方法都是常量池中的符号引用,在类加载的解析阶段,会将一部分符号引用转为直接引用,也就是在编译阶段就能够确定唯一的目标方法,这类方法的调用成为解析调用。此类方法主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可访问,因此决定了他们都不可能通过继承或者别的方式重写该方法,符合这两类的方法主要有以下几种:静态方法、私有方法、实例构造器、父类方法。虚拟机中提供了以下几条方法调用指令:

    1. invokestatic:调用静态方法,解析阶段确定唯一方法版本
    2. invokespecial:调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本
    3. invokevirtual:调用所有虚方法
    4. invokeinterface:调用接口方法
    5. invokedynamic:动态解析出需要调用的方法,然后执行

    前四条指令固化在虚拟机内部,方法的调用执行不可认为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外[^footnote4])称为虚方法。

    分派

    分派调用更多的体现在多态上。

    1. 静态分派:所有依赖静态类型3来定位方法执行版本的分派成为静态分派,发生在编译阶段,典型应用是方法重载
    2. 动态分派:在运行期间根据实际类型4来确定方法执行版本的分派成为动态分派,发生在程序运行期间,典型的应用是方法的重写
    3. 单分派:根据一个宗量5 对目标方法进行选择。
    4. 多分派:根据多于一个宗量对目标方法进行选择。

    JVM实现动态分派

    动态分派在Java中被大量使用,使用频率及其高,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率,因此JVM在类的方法区中建立虚方法表(virtual method table)来提高性能。每个类中都有一个虚方法表,表中存放着各个方法的实际入口。如果某个方法在子类中没有被重写,那子类的虚方法表中该方法的地址入口和父类该方法的地址入口一样,即子类的方法入口指向父类的方法入口。如果子类重写父类的方法,那么子类的虚方法表中该方法的实际入口将会被替换为指向子类实现版本的入口地址。
    那么虚方法表什么时候被创建?虚方法表会在类加载的连接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。


    方法的执行

    解释执行

    在jdk 1.0时代,Java虚拟机完全是解释执行的,随着技术的发展,现在主流的虚拟机中大都包含了即时编译器(JIT)。因此,虚拟机在执行代码过程中,到底是解释执行还是编译执行,只有它自己才能准确判断了,但是无论什么虚拟机,其原理基本符合现代经典的编译原理,如下图所示:
    此处输入图片的描述
    在Java中,javac编译器完成了词法分析、语法分析以及抽象语法树的过程,最终遍历语法树生成线性字节码指令流的过程,此过程发生在虚拟机外部。

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

    Java编译器输入的指令流基本上是一种基于的指令集架构,指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。另外一种指令集架构则是基于寄存器的指令集架构,典型的应用是x86的二进制指令集,比如传统的PC以及Android的Davlik虚拟机。两者之间最直接的区别是,基于栈的指令集架构不需要硬件的支持,而基于寄存器的指令集架构则完全依赖硬件,这意味基于寄存器的指令集架构执行效率更高,单可移植性差,而基于栈的指令集架构的移植性更高,但执行效率相对较慢,初次之外,相同的操作,基于栈的指令集往往需要更多的指令,比如同样执行2+3这种逻辑操作,其指令分别如下:
    基于栈的计算流程(以Java虚拟机为例):

    iconst_2  //常量2入栈
    istore_1  
    iconst_3  //常量3入栈
    istore_2
    iload_1
    iload_2
    iadd      //常量2、3出栈,执行相加
    istore_0  //结果5入栈

    而基于寄存器的计算流程:

    mov eax,2  //将eax寄存器的值设为1
    add eax,3  //使eax寄存器的值加3

    基于栈的代码执行示例

    下面我们用简单的案例来解释一下JVM代码执行的过程,代码实例如下:

    
    public class MainTest {
        public  static int add(){
            int result=0;
            int i=2;
            int j=3;
            int c=5;
            return result =(i+j)*c;
        }
    
        public static void main(String[] args) {
            MainTest.add();
        }
    }
    

    使用javap指令查看字节码:

    {
      public MainTest();
        flags: ACC_PUBLIC
        Code:
          stack=1, locals=1, args_size=1
             0: aload_0
             1: invokespecial #1                  // Method java/lang/Object."<init>":()V
             4: return
          LineNumberTable:
            line 2: 0
    
      public static int add();
        flags: ACC_PUBLIC, ACC_STATIC
        Code:
          stack=2, locals=4, args_size=0     //栈深度2,局部变量4个,参数0个
             0: iconst_0  //对应result=0,0入栈
             1: istore_0  //取出栈顶元素0,将其存放在第0个局部变量solt中
             2: iconst_2  //对应i=2,2入栈
             3: istore_1  //取出栈顶元素2,将其存放在第1个局部变量solt中
             4: iconst_3  //对应 j=3,3入栈
             5: istore_2  //取出栈顶元素3,将其存放在第2个局部变量solt中
             6: iconst_5  //对应c=5,5入栈
             7: istore_3  //取出栈顶元素,将其存放在第3个局部变量solt中
             8: iload_1   //将局部变量表的第一个slot中的数值2复制到栈顶
             9: iload_2   //将局部变量表中的第二个slot中的数值3复制到栈顶
            10: iadd      //两个栈顶元素2,3出栈,执行相加,将结果5重新入栈
            11: iload_3   //将局部变量表中的第三个slot中的数字5复制到栈顶
            12: imul      //两个栈顶元素出栈5,5出栈,执行相乘,然后入栈
            13: dup       //复制栈顶元素25,并将复制值压入栈顶.
            14: istore_0  //取出栈顶元素25,将其存放在第0个局部变量solt中
            15: ireturn   //将栈顶元素25返回给它的调用者
          LineNumberTable:
            line 4: 0
            line 5: 2
            line 6: 4
            line 7: 6
            line 8: 8
    
      public static void main(java.lang.String[]);
        flags: ACC_PUBLIC, ACC_STATIC
        Code:
          stack=1, locals=1, args_size=1
             0: invokestatic  #2                  // Method add:()I
             3: pop
             4: return
          LineNumberTable:
            line 12: 0
            line 13: 4
    }
    

    执行过程中代码、操作数栈和局部变量表的变化情况如下:
    指令0执行

    指令1执行

    指令2执行

    指令3执行

    指令4执行

    指令5执行

    指令6执行

    指令7执行

    指令8执行

    指令9执行

    指令10执行

    指令11执行

    指令12执行

    指令13执行

    指令14执行

    指令15执行


    1. 也成为容量槽,虚拟规范中并没有规定一个Slot应该占据多大的内存空间。
    2. 这里的严格匹配指的是字节码操作的栈中的实际元素类型必须要字节码规定的元素类型一致。比如iadd指令规定操作两个整形数据,那么在操作栈中的实际元素的时候,栈中的两个元素也必须是整形。
    3. Animal dog=new Dog();其中的Animal我们称之为静态类型,而Dog称之为动态类型。两者都可以发生变化,区别在于静态类型只在使用时发生变化,变量本身的静态类型不会被改变,最终的静态类型是在编译期间可知的,而实际类型则是在运行期才可确定。
    4. Animal dog=new Dog();其中的Animal我们称之为静态类型,而Dog称之为动态类型。两者都可以发生变化,区别在于静态类型只在使用时发生变化,变量本身的静态类型不会被改变,最终的静态类型是在编译期间可知的,而实际类型则是在运行期才可确定。
    5. 宗量:方法的接受者与方法的参数称为方法的宗量。
      举个例子:
      public void dispatcher(){
      int result=this.execute(8,9);
      }
      public void execute(int pointX,pointY){
      //TODO
      }

      在dispatcher()方法中调用了execute(8,9),那此时的方法接受者为当前this指向的对象,8、9为方法的参数,this对象和参数就是我们所说的宗量。
    展开全文
  • 三、字节码执行过程(虚拟机字节码执行引擎) 首先代码编译的结果是字节码而不是本地机器码了; 物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机执行引擎是由自己实现的,因此可以...

    三、字节码执行过程(虚拟机字节码执行引擎)

    • 首先代码编译的结果是字节码而不是本地机器码了;
    • 物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机执行引擎是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式
    • Java 虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,不同虚拟机可以有不同实现即执行引擎在执行 Java 代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种方式,也可能两者都有,甚至还可能会包含几个不同级别的编译器执行引擎。但从外观上来看,所有 Java 虚拟机的执行引擎是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

    (一)运行时栈帧(stack frame)结构

    • 是用于帮助虚拟机执行方法调用和方法执行的数据结构

    • 栈帧归属于一个一个的线程,每个线程只会拥有自己独有的栈帧的结构,因此对于栈帧不存在并发和同步调用问题;

    • 栈帧本身是一种数据结构,封装了方法的局部变量表,动态链接信息,方法的返回地址以及操作数栈等信息。

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

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

    • 一个线程中的方法调用链可能会很长,很多方法都处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法成为当前方法。执行引擎运行的所有字节码指令对当前栈帧进行操作,在概念模型上,典型的栈帧结构如下图:

    img

    局部变量表

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

    • 局部变量表的容量单位是:Slot(Variable Slot);32 位数据类型占用一个 Slot,64 位数据类型以高位对齐的方式分配两个连续的 Slot 空间。

    • Java 虚拟机的数据类型和 Java 数据类型存在本质差距,JVM 中 32 位数据类型:boolean/byte/char/short/int/float/reference(对一个对象实例的引用)/returnAddress (指向一条字节码指令的地址), 64 位数据类型:long/double

    • 局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续 Slot 是否为原子操作,都不会引起数据安全问题。

    操作数栈

    操作数栈(Operand Stack)是一个后进先出栈。同局部变量表一样,操作数栈的最大深度也在编译阶段写入到 Code 属性的 max_stacks 数据项中。操作数栈的每一个元素可以是任意的 Java 数据类型,包括 long 和 double。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。在方法执行的任何时候,操作数栈的深度都不会超过 max_stacks 数据项中设定的最大值。

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

    动态链接

    每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。Class 文件的常量池中存在大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数,这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化成为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

    方法返回地址

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

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

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

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

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

    附加信息

    虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,成为栈帧信息。

    二. 方法调用

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

    在程序运行时,进行方法调用是最为普遍、频繁的操作。前面说过 Class 文件的编译过程是不包含传统编译中的连接步骤的,一切方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在运行时内存布局中的入口地址(相当于之前说的直接引用)。这个特性给 Java 带来了更强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

    解析

    所有方法调用中的目标方法在 Class 文件里都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,这种解析能成立的前提是方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。话句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

    Java 语言中符合「编译器可知,运行期不可变」这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或者别的方式重写其它版本,因此它们都适合在类加载阶段解析。

    与之相应的是,在 Java 虚拟机里提供了 5 条方法调用字节码指令,分别是:

    • invokestatic:调用静态方法;
    • invokespecial:调用实例构造器 方法、私有方法和父类方法;
    • invokevirtual:调用所有虚方法;
    • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象;
    • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。

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

    Java 中的非虚方法除了使用 invokestatic、invokespecial 调用的方法之外还有一种,就是被 final 修饰的方法。虽然 final 方法是使用 invokevirtual 指令来调用的,但是由于它无法被覆盖,没有其它版本,所以也无需对方法接受者进行多态选择,又或者说多态选择的结果肯定是唯一的。在 Java 语言规范中明确说明了 final 方法是一种非虚方法。

    解析调用一定是个静态过程,在编译期间就能完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派 4 种分派组合情况,下面我们再看看虚拟机中的方法分派是如何进行的。

    分派

    面向对象有三个基本特征,封装、继承和多态。这里要说的分派将会揭示多态特征的一些最基本的体现,如「重载」和「重写」在 Java 虚拟机中是如何实现的?虚拟机是如何确定正确目标方法的?

    静态分派

    在开始介绍静态分派前我们先看一段代码。

    /**
     * 方法静态分派演示
     *
     * @author baronzhang
     */
    public class StaticDispatch {
    
        private static abstract class Human { }
    
        private static class Man extends Human { }
    
        private static class Woman extends Human { }
    
        private void sayHello(Human guy) {
            System.out.println("Hello, guy!");
        }
    
        private void sayHello(Man man) {
            System.out.println("Hello, man!");
        }
    
        private void sayHello(Woman woman) {
            System.out.println("Hello, woman!");
        }
    
        public static void main(String[] args) {
    
            Human man = new Man();
            Human woman = new Woman();
            StaticDispatch dispatch = new StaticDispatch();
            dispatch.sayHello(man);
            dispatch.sayHello(woman);
        }
    }
    复制代码
    

    运行后这段程序的输出结果如下:

    Hello, guy!
    Hello, guy!
    复制代码
    

    稍有经验的 Java 程序员都能得出上述结论,但为什么我们传递给 sayHello() 方法的实际参数类型是 Man 和 Woman,虚拟机在执行程序时选择的却是 Human 的重载呢?要理解这个问题,我们先弄清两个概念。

    Human man = new Man();
    复制代码
    

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

    弄清了这两个概念,再来看 StaticDispatch 类中 main() 方法里的两次 sayHello() 调用,在方法接受者已经确定是对象「dispatch」的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中定义了两个静态类型相同但是实际类型不同的变量,但是虚拟机(准确的说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此在编译阶段, Javac 编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello(Human) 作为调用目标,并把这个方法的符号引用写到 man() 方法里的两条 invokevirtual 指令的参数中。

    所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

    另外,编译器虽然能确定方法的重载版本,但是很多情况下这个重载版本并不是「唯一」的,因此往往只能确定一个「更加合适」的版本。产生这种情况的主要原因是字面量不需要定义,所以字面量没有显示的静态类型,它的静态类型只能通过语言上的规则去理解和推断。下面的代码展示了什么叫「更加合适」的版本。

    /**
     * @author baronzhang
     */
    public class Overlaod {
    
        static void sayHello(Object arg) {
            System.out.println("Hello, Object!");
        }
    
        static void sayHello(int arg) {
            System.out.println("Hello, int!");
        }
    
        static void sayHello(long arg) {
            System.out.println("Hello, long!");
        }
    
        static void sayHello(Character arg) {
            System.out.println("Hello, Character!");
        }
    
        static void sayHello(char arg) {
            System.out.println("Hello, char!");
        }
    
        static void sayHello(char... arg) {
            System.out.println("Hello, char...!");
        }
    
        static void sayHello(Serializable arg) {
            System.out.println("Hello, Serializable!");
        }
    
        public static void main(String[] args) {
            sayHello('a');
        }
    }
    复制代码
    

    上面代码的运行结果为:

    Hello, char!
    复制代码
    

    这很好理解,‘a’ 是一个 char 类型的数据,自然会寻找参数类型为 char 的重载方法,如果注释掉 sayHello(chat arg) 方法,那么输出结果将会变为:

    Hello, int!
    复制代码
    

    这时发生了一次类型转换, ‘a’ 除了可以代表一个字符,还可以代表数字 97,因为字符 ‘a’ 的 Unicode 数值为十进制数字 97,因此参数类型为 int 的重载方法也是合适的。我们继续注释掉 sayHello(int arg) 方法,输出变为:

    Hello, long!
    复制代码
    

    这时发生了两次类型转换,‘a’ 转型为整数 97 之后,进一步转型为长整型 97L,匹配了参数类型为 long 的重载方法。我们继续注释掉 sayHello(long arg) 方法,输出变为:

    Hello, Character!
    复制代码
    

    这时发生了一次自动装箱, ‘a’ 被包装为它的封装类型 java.lang.Character,所以匹配到了类型为 Character 的重载方法,继续注释掉 sayHello(Character arg) 方法,输出变为:

    Hello, Serializable!
    复制代码
    

    这里输出之所以为「Hello, Serializable!」,是因为 java.lang.Serializable 是 java.lang.Character 类实现的一个接口,当自动装箱后发现还是找不到装箱类,但是找到了装箱类实现了的接口类型,所以紧接着又发生了一次自动转换。char 可以转型为 int,但是 Character 是绝对不会转型为 Integer 的,他只能安全的转型为它实现的接口或父类。Character 还实现了另外一个接口 java.lang.Comparable,如果同时出现两个参数分别为 Serializable 和 Comparable 的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。程序必须在调用时显示的指定字面量的静态类型,如:sayHello((Comparable) ‘a’),才能编译通过。继续注释掉 sayHello(Serializable arg) 方法,输出变为:

    Hello, Object!
    复制代码
    

    这时是 char 装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低。即使方法调用的入参值为 null,这个规则依然适用。继续注释掉 sayHello(Serializable arg) 方法,输出变为:

    Hello, char...!
    复制代码
    

    7 个重载方法以及被注释得只剩一个了,可见变长参数的重载优先级是最低的,这时字符 ‘a’ 被当成了一个数组元素。

    前面介绍的这一系列过程演示了编译期间选择静态分派目标的过程,这个过程也是 Java 语言实现方法重载的本质。

    动态分派

    动态分派和多态性的另一个重要体现「重写(Override)」有着密切的关联,我们依旧通过代码来理解什么是动态分派。

    /**
     * 方法动态分派演示
     *
     * @author baronzhang
     */
    public class DynamicDispatch {
    
        static abstract class Human {
    
            abstract void sayHello();
        }
    
        static class Man extends Human {
    
            @Override
            void sayHello() {
                System.out.println("Man say hello!");
            }
        }
    
        static class Woman extends Human {
            @Override
            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!
    复制代码
    

    对于上面的代码,虚拟机是如何确定要调用哪个方法的呢?显然这里不再通过静态类型来决定了,因为静态类型同样都是 Human 的两个变量 man 和 woman 在调用 sayHello() 方法时执行了不同的行为,并且变量 man 在两次调用中执行了不同的方法。导致这个结果的原因是因为它们的实际类型不同。对于虚拟机是如何通过实际类型来分派方法执行版本的,这里我们就不做介绍了,有兴趣的可以去看看原著。

    我们把这种在运行期根据实际类型来确定方法执行版本的分派称为动态分派

    单分派和多分派

    方法的接收者和方法的参数统称为方法的宗量,这个定义最早来源于《Java 与模式》一书。根据分派基于多少宗量,可将分派划分为单分派多分派

    单分派是根据一个宗量来确定方法的执行版本;多分派则是根据多余一个宗量来确定方法的执行版本。

    我们依旧通过代码来理解(代码以著名的 3Q 大战作为背景):

    /**
     * 单分派、多分派演示
     *
     * @author baronzhang
     */
    public class Dispatch {
    
        static class QQ { }
    
        static class QiHu360 { }
    
        static class Father {
    
            public void hardChoice(QQ qq) {
                System.out.println("Father choice QQ!");
            }
    
            public void hardChoice(QiHu360 qiHu360) {
                System.out.println("Father choice 360!");
            }
        }
    
        static class Son extends Father {
    
            @Override
            public void hardChoice(QQ qq) {
                System.out.println("Son choice QQ!");
            }
    
            @Override
            public void hardChoice(QiHu360 qiHu360) {
                System.out.println("Son choice 360!");
            }
        }
    
        public static void main(String[] args) {
    
            Father father = new Father();
            Father son = new Son();
    
            father.hardChoice(new QQ());
            son.hardChoice(new QiHu360());
        }
    }
    复制代码
    

    代码输出结果:

    Father choice QQ!
    Son choice 360!
    复制代码
    

    我们先来看看编译阶段编译器的选择过程,也就是静态分派过程。这个时候选择目标方法的依据有两点:一是静态类型是 Father 还是 Son;二是方法入参是 QQ 还是 QiHu360。因为是根据两个宗量进行选择的,所以 Java 语言的静态分派属于多分派

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

    综上所述,Java 语言是一门静态多分派、动态单分派的语言。

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

    虚拟机如何调用方法已经介绍完了,下面我们来看看虚拟机是如何执行方法中的字节码指令的。

    解释执行

    Java 语言常被人们定义成「解释执行」的语言,但随着 JIT 以及可直接将 Java 代码编译成本地代码的编译器的出现,这种说法就不对了。只有确定了谈论对象是某种具体的 Java 实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。

    无论是解释执行还是编译执行,无论是物理机还是虚拟机,对于应用程序,机器都不可能像人一样阅读、理解,然后获得执行能力。大部分的程序代码到物理机的目标代码或者虚拟机执行的指令之前,都需要经过下图中的各个步骤。下图中最下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程;中间那条分支,则是解释执行的过程。

    img

    如今,基于物理机、Java 虚拟机或者非 Java 的其它高级语言虚拟机的语言,大多都会遵循这种基于现代编译原理的思路,在执行前先对程序源代码进行词法分析和语法分析处理,把源代码转化为抽象语法树。对于一门具体语言的实现来说,词法分析、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是 C/C++。也可以为一个半独立的编译器,这类代表是 Java。又或者把这些步骤和执行全部封装在一个封闭的黑匣子中,如大多数的 JavaScript 执行器。

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

    许多 Java 虚拟机的执行引擎在执行 Java 代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。而对于最新的 Android 版本的执行模式则是 AOT + JIT + 解释执行,关于这方面我们后面有机会再聊。

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

    Java 编译器输出的指令流,基本上是一种基于栈的指令集架构。基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免的要受到硬件约束。栈架构的指令集还有一些其他优点,比如相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译实现更加简单(不需要考虑空间分配的问题,所有空间都是在栈上操作)等。

    栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。

    虽然栈架构指令集的代码非常紧凑,但是完成相同功能需要的指令集数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存中,频繁的栈访问也意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。

    正是基于上述原因,Android 虚拟机中采用了基于寄存器的指令集架构。不过有一点不同的是,前面说的是物理机上的寄存器,而 Android 上指的是虚拟机上的寄存器。

    • 符号引用:符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,符号引用和虚拟机的布局无关。(在编译的时候一个每个java类都会被编译成一个class文件,但在编译的时候虚拟机并不知道所引用类的地址,所以就用符号引用来代替,而在这个解析阶段就是为了把这个符号引用转化成为真正的地址的阶段)。
    • 直接引用:(1)直接指向目标的指针(指向对象,类变量和类方法的指针)(2)相对偏移量。(指向实例的变量,方法的指针)(3)一个间接定位到对象的句柄。

    有些符号引用在加载阶段或者或是第一次使用时,转换为直接引用,这种转换叫做静态解析;另外一些符号引用则是在每次运行期转换为直接引用,这种转换叫做动态链接。这种动态链接体现了 Java 的多态性。

    • 助记符:

      • invokeinterface:调用接口的方法,在运行期决定调用该接口的哪个对象的特定方法。
      • invokestatic:调用静态方法
      • invokespecial:调用私有方法, 构造方法(使用 <init>标识),父类的方法
      • invokevirtual:调用虚方法(语言层面上不存在该概念,字节码中存在),运行期动态查找的过程
      • invokedynamic:动态调用方法
    • 测试4:测试 invokestatic

    public class MyTest4{
        public static void test(){
                System.out.println("static test");
        }
        public static void main(Stirng[] args){
            test();             //invokestatic
        }
    }
    

    对应的字节码文件

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Mm8Rku3h-1578190147539)(%E8%99%9A%E6%8B%9F%E6%9C%BA%E5%AD%97%E8%8A%82%E7%A0%81%E6%89%A7%E8%A1%8C%E5%BC%95%E6%93%8E.resource/image-20191207194449349.png)]

    能被 invokestatic、invokespecial 两个指令调用的方法都是能在解析阶段就能唯一确定该方法的调用过程;就是下面中的静态静态解析四种场景

    静态解析的四种场景:静态方法、父类方法、构造方法、私有方法(公有方法是可能被重写的,就存在多态的可能的)。以上四种方法称为非虚方法,在类加载阶段将符号引用转换为直接引用。

    • 测试5:方法重载
    package com.gjxaiou.bytecode;
    
    
    /**
     * 方法的静态分派。
     * Grandpa g1 = new Father();
     * 以上代码, g1的静态类型(声明的类型)是Grandpa,而g1的实际类型(真正指向的类型)是Father.
     * 我们可以得出这样一个结论:变量的静态类型是不会发生变化的,而变量的实际类型则是可以发生变化的(多态的一种体现)
     * 实际变量是在运行期方可确定
     */
    class Grandpa {
    }
    
    class Father extends Grandpa {
    }
    
    class Son extends Father {
    }
    
    public class MyTest5 {
        //方法重载,是一种静态的行为,在调用方法时候,JVM唯一判断依据就是根据该方法本身接收的参数(声明的参数类型)来决定调用哪一个方法,编译期就可以完全确定
        public void test(Grandpa grandpa) {
            System.out.println("Grandpa");
        }
    
        public void test(Father father) {
            System.out.println("father");
        }
    
        public void test(Son son) {
            System.out.println("Son");
        }
    
        public static void main(String[] args) {
            MyTest5 myTest5 = new MyTest5();
            Grandpa g1 = new Father();
            Grandpa g2 = new Son();
            myTest5.test(g1);
            myTest5.test(g2);
        }
    }
    /**
     * output:
     * Grandpa
     * Grandpa
     */
    
    
    
    

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-frJEdMIR-1578190147539)(%E8%99%9A%E6%8B%9F%E6%9C%BA%E5%AD%97%E8%8A%82%E7%A0%81%E6%89%A7%E8%A1%8C%E5%BC%95%E6%93%8E.resource/image-20191207201803199.png)]

    对于方法重载:其可以在编译期就可以完全确定,因为虽然方法本身(test)进行了重载,但是调用方法本身的永远都是方法所在类的实例(myTest5),唯一变化的就是向方法中传入什么类型的参数而已;

    对于方法重写:是一种动态信息,到底调用哪一个对象特定的方法是在运行期才能确定的;

    • 测试6:方法重写
    package com.gjxaiou.bytecode;
    
    /**
     * 方法的动态分派(运行期才能确定调用哪个方法)
     * 方法的动态分派涉及到一个重要概念:方法接收者(方法到底是由哪个对象来调用的)。
     *
     * invokevirtual 字节码指令的多态查找流程:
     * 首先到操作数的栈顶去寻找栈顶元素所指向的对象的实际类型(不是静态类型);(这里就是 apple 类型)
     * 如果寻找到了与常量池中描述符和名称都相符的方法(这里就是在 APPle 类中找到一个与<com/gjxaiou/bytecode/Fruit
     * .test>方法的描述符和名称一样的方法),并且具备访问权限,就返回目标方法的直接引用(这里就是返回 Apple 中 test 方法的直接引用),流程结束;
     * 如果找不到,就按照继承关系从子类到父类的一层一层的使用上面的查找流程,一直能找到为止,如果找不到报错;
     *
     * 比较方法重载(overload)与方法重写(overwrite) ,我们可以得到这样一个结论:
     * 方法重载是静态的,是编译期行为;
     * 方法重写是动态的,是运行期行为。
     *
     * 下面就是三个 test 方法的符号引用虽然相同(都是 <com/gjxaiou/bytecode/Fruit.test>),但是在运行期转换成了不同的直接引用
     */
    class Fruit {
        public void test() {
            System.out.println("Fruit");
        }
    }
    
    class Apple extends Fruit {
        @Override
        public void test() {
            System.out.println("Apple");
        }
    }
    
    class Orange extends Fruit {
        @Override
        public void test() {
            System.out.println("Orange");
        }
    }
    
    public class MyTest6 {
        public static void main(String[] args) {
            // new 的作用:首先为该对象在堆上开辟一个内存空间,然后执行其构造方法,最后将构造方法执行完后返回的针对在堆上所生成的对象的引用返回;
            /** new 关键字对应于字节码中的下面四个操作:
             *  0 new #2 <com/gjxaiou/bytecode/Apple> // 开辟内存空间并创建对象
             *  3 dup // 将引用的对象的值压入到栈顶
             *  4 invokespecial #3 <com/gjxaiou/bytecode/Apple.<init>>  // 调用对象的构造方法
             *  7 astore_1 // 将对象在堆上的引用返回赋给一个局部变量
             */
            Fruit apple = new Apple();
            Fruit orange = new Orange();
    
            // 16 aload_1:从局部变量中加载索引为 1 的引用,就是 apple
            apple.test();
            orange.test();
    
            apple = new Orange();
            apple.test();
            // 上面三个 test() 方法最终对应的字节码都是:invokevirtual #6 <com/gjxaiou/bytecode/Fruit.test>
        }
    }
    /** output:
     * Apple
     * Orange
     * Orange
     */
    

    对应的 Class 文件

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-a4nXMUD6-1578190147540)(%E8%99%9A%E6%8B%9F%E6%9C%BA%E5%AD%97%E8%8A%82%E7%A0%81%E6%89%A7%E8%A1%8C%E5%BC%95%E6%93%8E.resource/image-20191207210522248.png)]

    测试7:重载和重写

    package com.gjxaiou.bytecode;
    
    import java.util.Date;
    
    public class MyTest7 {
        public static void main(String[] args) {
            Animal animal = new Animal();
            Animal dog = new Dog();
            animal.test("hello");
            dog.test(new Date());
        }
    
    }
    
    class Animal {
        public void test(String str) {
            System.out.println("animal str");
        }
    
        public void test(Date date) {
            System.out.println("animal date");
        }
    }
    
    class Dog extends Animal {
        @Override
        public void test(String str) {
            System.out.println("dog str");
        }
    
        @Override
        public void test(Date date) {
            System.out.println("dog date");
        }
    }
    

    对应的 Class 结构

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cRTJZDb6-1578190147540)(%E8%99%9A%E6%8B%9F%E6%9C%BA%E5%AD%97%E8%8A%82%E7%A0%81%E6%89%A7%E8%A1%8C%E5%BC%95%E6%93%8E.resource/image-20191207211547115.png)]

    因为 JVM 要从堆栈数的最顶部进行搜索,所以如何优化:使用虚方法表的索引来代替查找过程

    针对方法调用调用动态分配过程,虚拟机会在类的方法区建立一个虚方法表的数据结构(virtual method table,简称:vtable)

    针对于接口的 invokeinterface 指令来说,虚拟机会建立一个叫做接口方法表的数据结构(interface method table,简称:itable)

    应为上述都是运行期的概念,所以虚方法表中存储的是每一个方法真正的实际入口的调用地址,虚方法表中每一项就标识着这个特定方法的实际入口的调用地址,如果子类没有重写父类的某个方法,那么子类和父类中的该方法就是一样的,则子类针对从父类继承并且没有重写的方法在子类中该方法的入口地址就直接指向了父类中的这个特定的方法,而不是在子类中又复制了一遍(可以增加查找效率和减少内存占用空间);

    虚方法表一般在类加载的连接阶段完成的;

    测试7:

    package com.poplar.bytecode;
    
    /**
     * Created BY poplar ON 2019/12/4
     * 基于栈的解释器的执行过程概念模型
     */
    public class BasicStackExecutionProcess {
    
        public int calc() {
            int a = 100;
            int b = 200;
            int c = 300;
            return (a + b) * c;
    
        /*
       public int calc();
        descriptor: ()I
        flags: ACC_PUBLIC
        Code:
          stack=2, locals=4, args_size=1
             0: bipush        100  执行地址偏移量为0 将100推送至栈顶
             2: istore_1          执行地址偏移量为2  将栈顶的100出栈并存放到第一个局部变量Slot中
             3: sipush        200
             6: istore_2
             7: sipush        300
            10: istore_3
            11: iload_1          执行地址偏移量为11 将局部变量中第一个Slot中的整型值复制到栈顶
            12: iload_2
            13: iadd            将栈顶的两个元素出栈并作整形加法,然后把结果重新入栈
            14: iload_3
            15: imul            将栈顶的两个元素出栈并作整形乘法,然后把结果重新入栈
            16: ireturn         结束方法并将栈顶的值返回给方法调用者
          LineNumberTable:
            line 10: 0
            line 11: 3
            line 12: 7
            line 13: 11
          LocalVariableTable:
            Start  Length  Slot  Name   Signature
                0      17     0  this   Lcom/poplar/bytecode/BasicStackExecutionProcess;
                3      14     1     a   I
                7      10     2     b   I
               11       6     3     c   I
         */
        }
    
        public static void main(String[] args) {
            BasicStackExecutionProcess process = new BasicStackExecutionProcess();
            int res = process.calc();
            System.out.println(res);
        }
    }
    

    动态分派:

    package com.poplar.bytecode;
    
    /**
     * 动态分派的演示与证明:
     * 在动态分派中虚拟机是如何知道要调用那个方法的?
     */
    public class DynamicDispatch {
    
        static abstract class Human {
            public abstract void hello();
        }
    
        static class Man extends Human {
            @Override
            public void hello() {
                System.out.println("Hello Man");
            }
        }
    
        static class Woman extends Human {
            @Override
            public void hello() {
                System.out.println("Hello Woman");
            }
        }
    
        public static void main(String[] args) {
            Human man = new Man();
            Human woMan = new Woman();
            man.hello();
            woMan.hello();
    
            man = new Woman();
            man.hello();
    
        /*public static void main(java.lang.String[]);
        descriptor: ([Ljava/lang/String;)V
        flags: ACC_PUBLIC, ACC_STATIC
        Code:
          stack=2, locals=3, args_size=1
             0: new           #2                  // class main/java/com/poplar/bytecode/DynamicDispatch$Man
             3: dup
             4: invokespecial #3                  // Method main/java/com/poplar/bytecode/DynamicDispatch$Man."<init>":()V
             7: astore_1
             8: new           #4                  // class main/java/com/poplar/bytecode/DynamicDispatch$Woman
            11: dup
            12: invokespecial #5                  // Method main/java/com/poplar/bytecode/DynamicDispatch$Woman."<init>":()V
            15: astore_2
            16: aload_1 从局部变量加载一个引用 aload1是加载索引为1的引用(man),局部变量有三个(0:args; 1 :man ; 2 :woMan)
            17: invokevirtual #6                  // Method main/java/com/poplar/bytecode/DynamicDispatch$Human.hello:()V
            20: aload_2 加载引用woMan
            21: invokevirtual #6                  // Method main/java/com/poplar/bytecode/DynamicDispatch$Human.hello:()V
            24: new           #4                  // class main/java/com/poplar/bytecode/DynamicDispatch$Woman
            27: dup
            28: invokespecial #5                  // Method main/java/com/poplar/bytecode/DynamicDispatch$Woman."<init>":()V
            31: astore_1
            32: aload_1
            33: invokevirtual #6                  // Method main/java/com/poplar/bytecode/DynamicDispatch$Human.hello:()V
            36: return
          LineNumberTable:
            line 28: 0
            line 29: 8
            line 30: 16
            line 31: 20
            line 33: 24
            line 34: 32
            line 36: 36
          LocalVariableTable:
            Start  Length  Slot  Name   Signature
                0      37     0  args   [Ljava/lang/String;
                8      29     1   man   Lmain/java/com/poplar/bytecode/DynamicDispatch$Human;
               16      21     2 woMan   Lmain/java/com/poplar/bytecode/DynamicDispatch$Human;
        }
        invokevirtual 运行期执行的时候首先:
        找到操作数栈顶的第一个元素它所指向对象的实际类型,在这个类型里边,然后查找和常量里边Human的方法描述符和方法名称都一致的
        方法,如果在这个类型下,常量池里边找到了就会返回实际对象方法的直接引用。
    
        如果找不到,就会按照继承体系由下往上(Man–>Human–>Object)查找,查找匹配的方式就是
        上面描述的方式,一直找到位为止。如果一直找不到就会抛出异常。
    
        比较方法重载(overload)和方法重写(overwrite),我们可以得出这样的结论:
        方法重载是静态的,是编译器行为;方法重写是动态的,是运行期行为。
           */
        }
    }
    

    静态分派:

    package com.poplar.bytecode;
    
    /**
     * Created BY poplar ON 2019/12/4
     * 静态分派的演示与证明:
     */
    public class StaticDispatch {
    
        static abstract class Human {
    
        }
    
        static class Man extends Human {
    
        }
    
        static class Woman extends Human {
    
        }
    
        public void hello(Human param) {
            System.out.println("Hello Human");
        }
    
        public void hello(Man param) {
            System.out.println("Hello Man");
        }
    
        public void hello(Woman param) {
            System.out.println("Hello Woman");
        }
    
        public static void main(String[] args) {
            StaticDispatch dispatch = new StaticDispatch();
            /*Human man = new Man();
            Human woMan = new Woman();
            dispatch.hello(man);
            dispatch.hello(woMan);*/
    
            Human human = new Woman();
            human = new Man();
            dispatch.hello((Woman) human);
            dispatch.hello((Man) human);
            //java.lang.ClassCastException: main.java.com.poplar.bytecode.WoMan cannot be cast to main.java.com.poplar.bytecode.Man
        }
    }
    
    • 现代 JVM 在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行.

      • 解释执行:就是通过解释器来读取字节码,遇到相应的指令就去执行该指令;
      • 编译执行:就是通过即时编译器 (Just in Time, JIT) 将字节码转换为本地机器码来执行;现代 JVM 会根据代码热点(执行频率)来生成目应的本地机器码。 在布尔德E马文项目
    • 基于栈的指令集和基于寄存器的指令集之间的关系

      • JVM 执行指令时所采取的方式是基于栈的指令集。
    • 基于栈的指令集主要有入栈和出栈两种;

      • 基于栈的指令集的优势在于它可以在不同平台之间的移植,而基于寄存器的指令集是与硬件架构紧密关联,无法做到可移植;

      • 基于栈的指令集的缺点在于完成相同的操作,指令集通常要比基于寄存器的指令集要多,基于栈的指令集是在内存中完成操作的,而基于寄存器的指令集是直接由CPU来执行的,它是在高速缓冲区中进行的,速度要快很多。虽然虚拟机可以采用一些优化手段(该一些高频的指令集映射到寄存器中),但总体来说,基于栈的指令集的执行速度要慢一些;

      • 基手栈的指令集的优势在于它可以在不同平台之间移植,而基于寄存器的指令集是与硬件架构累密关联的,无法做到可移植。

      示例:如果完后 2 -1 的操作,对应的基于栈的指令集操作

      iconst_1  // 首先将减数 1 压入到栈顶
      iconst_2  // 然后将被减数 2 压入到栈顶
      isub      // 将栈顶和栈顶下面两个值弹出,然后执行相减的操作,并将运算结果压入到栈顶
      istore_0  // 将结果放入局部变量表的第 0 个位置
      

      如果对应寄存器的操作

      首先使用 mov 将 2 放入寄存器
      然后使用 sub 调用减法操作,后面加上参数 1,最后将结果放入寄存器中
      
    • 示例8:基于栈的指令集

      package com.gjxaiou.bytecode;
      
      public class MyTest8 {
          public int myCalculate(){
              int a = 1;
              int b = 2;
              int c = 3;
              int d = 4;
              int result = (a + b - c) * d;
              return result;
          }
      }
      
      

      对上面的代码进行反编译之后,其中 myCalculate() 方法反编译结果为:

       public int myCalculate();
          descriptor: ()I
          flags: ACC_PUBLIC
          Code:
      // 栈的最大深度为 2,即栈中最多容纳 2 个元素;最大的局部变量为 6,参数数量为 1,即为 this
            stack=2, locals=6, args_size=1
               // 将常量数(-1 ~ 5)这里为 1 推送到操作数栈
               0: iconst_1
               // 将操作数栈顶元素弹出,同时将局部变量表中索引 istore 后面参数位置的值设置为弹出值
                   // 局部变量表 0 号位置为 this
               1: istore_1
               2: iconst_2
               3: istore_2
               4: iconst_3
               5: istore_3
               6: iconst_4
               // 含义同上,但是因为只有三个值,后面 4 即为局部变量表索引    
               7: istore        4
               // 将局部变量表中索引位置为 1 的变量值推送到栈顶    
               9: iload_1
              10: iload_2
              // 将操作数栈顶和栈顶下面两个数弹出相加并将结果压入栈中    
              11: iadd
              12: iload_3
              13: isub
              14: iload         4
              16: imul
              17: istore        5
              19: iload         5
              21: ireturn
      

    动态代理

    视频 57- 58,代码见下,分析么有看完

    package com.gjxaiou.bytecode;
    
    /**
     * 被代理的接口
     */
    public interface Subject {
        void request();
    }
    
    package com.gjxaiou.bytecode;
    
    public class RealSubject  implements Subject{
        @Override
        public void request() {
            System.out.println("From real subject");
        }
    }
    
    
    package com.gjxaiou.bytecode;
    
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Method;
    
    public class DynamicSubject implements InvocationHandler {
        // 创建一个真实对象
        private Object sub;
        // 将真实对象作为参数传入构造方法
        public DynamicSubject(Object obj){
            this.sub = obj;
        }
    
        // 对真实对象的调用都会通过动态代理中 invoke 方法来执行
        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            System.out.println("before calling: " + method);
            // 对于真正目标方法的调用
            method.invoke(this.sub, args);
            System.out.println("after calling: " + method);
            return null;
        }
    }
    
    
    package com.gjxaiou.bytecode;
    
    import java.lang.reflect.Proxy;
    
    public class Client {
        public static void main(String[] args) {
            RealSubject rs = new RealSubject();
            DynamicSubject ds = new DynamicSubject(rs);
            // 获取 class 对象,因为后面创建动态代理的类需要类加载器,然后通过 class 对象和类加载器创建对象
            Class<?> cls = rs.getClass();
    
            Subject subject = (Subject) Proxy.newProxyInstance(cls.getClassLoader(),
                    cls.getInterfaces(), ds);
    
            subject.request();
            System.out.println(subject.getClass());
            System.out.println(subject.getClass().getSuperclass());
        }
    }
    
    /** output:
     *  before calling: public abstract void com.gjxaiou.bytecode.Subject.request()
     *  From real subject
     *  after calling: public abstract void com.gjxaiou.bytecode.Subject.request()
     *  class com.sun.proxy.$Proxy0
     *  class java.lang.reflect.Proxy
     */
    
    
    展开全文
  • Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,成为各种虚拟机执行引擎的统一外观(Facade)。不同的虚拟机引擎会包含两种执行模式,解释执行和编译执行。 运行时帧栈结构 栈帧(Stack Frame)支持虚拟机...
  • JVM字节码执行引擎

    千次阅读 2018-06-16 21:08:50
    概述 执行引擎是Java虚拟机最核心的组成部分之一,“虚拟机”是一个相对于“物理机”的概念,这两种机器都具有执行代码的能力。其区别是物理机的执行引擎是直接建立... 在Java虚拟机规范中制定了虚拟机字节码执行...
  • 虚拟机字节码执行引擎

    千次阅读 2016-07-21 11:52:01
     代码编译的结果是从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。 概述  执行引擎是 Java 虚拟机最核心的组成部分之一。“虚拟机” 是一个相对于 “物理机” 的概念,这两种机器...

空空如也

1 2 3 4 5 ... 20
收藏数 2,689
精华内容 1,075
关键字:

字节码执行引擎