05类加载机制篇(D5_虚拟机字节码执行引擎)

目录

学习前言

一、基本介绍

二、运行时栈帧结构

1. 局部变量表

2. 操作数栈

3. 动态连接

4. 方法返回地址

5. 附加信息

三、方法调用

1. 简介

2. 解析

3. 分派

静态分派

动态分派

单分派与多分派

四、动态类型语言支持

1. 简介

2. 动态类型语言

3. Java与动态类型

4. java.lang.invoke 包

5. invokedynamic 指令

6. 掌控方法分派规则

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

1. 简介

2. 解释执行

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

4. 基于栈的解释器执行过程

bipush

istore_n

iload_n

iadd

iload_3

imul

ireturn


学习前言

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一 大步。

一、基本介绍

执行引擎是Java虚拟机核心的组成部分之一。

“虚拟机”是一个相对于“物理机”的概念,这两种机 器都有代码执行能力,其区别是物理机的执行

引擎是直接建立在处理器、缓存、指令集和操作系统层 面上的,而虚拟机的执行引擎则是由软件自行实现

的,因此可以不受物理条件制约地定制指令集与执 行引擎的结构体系,能够执行那些不被硬件直接支持的

指令集格式。

在《Java虚拟机规范》中制定了Java虚拟机字节码执行引擎的概念模型,这个概念模型成为各大发 行

商的Java虚拟机执行引擎的统一外观(Facade)。在不同的虚拟机实现中,执行引擎在执行字节码的 时

候,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择

,也可能两者兼备,还可能会有同时包含几个不同级别的即时编译器一起工作的执行引擎。但 从外观上

来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处 理过程是字节

码解析执行的等效过程,输出的是执行结果,本章将主要从概念模型的角度来讲解虚拟 机的方法调用和字

节码执行。

有一些虚拟机(如Sun Classic VM)的内部只存在解释器,只能解释执行,另外一些虚拟机(如BEA

JRockit)的内部只存在即时编译器,只能编译执行。

二、运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法

调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的

栈元素。

栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息,每 一个方法从调用开始

至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算

出来,并且写入到方法表的Code属性之中。

换言之,一个栈帧需要分配多少内存,并不会受到程序 运行期变量数据的影响,而仅仅取决于程序源

码和具体的虚拟机实现的栈内存布局形式。

一个线程中的方法调用链可能会很长,以Java程序的角度来看,同一时刻、同一条线程里面,在调用

堆栈的所有方法都同时处于执行状态。

而对于执行引擎来讲,在活动线程中,只有位于栈顶的方 法才是在运行的,只有位于栈顶的栈帧才是

生效的,其被称为“当=前栈帧”(Current Stack Frame),与 这个栈帧所关联的方法被称为“当前方

法”(Current Method)。

执行引擎所运行的所有字节码指令都只 针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如图

所示。

1. 局部变量表

局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义

的局部变量。

在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方 法所需分

配的局部变量表的最大容量。

局部变量表的容量以变量槽(Variable Slot)为最小单位,《Java虚拟机规范》中并没有明确指出 一

个变量槽应占用的内存空间大小,只是很有导向性地说到每个变量槽都应该能存放一个boolean、byte、

char、short、int、float、reference或returnAddress类型的数据,这8种数据类型,都可以使用32位 或

更小的物理内存来存储,但这种描述与明确指出“每个变量槽应占用32位长度的内存空间”是有本质 差

别的,它允许变量槽的长度可以随着处理器、操作系统或虚拟机实现的不同而发生变化,保证了即 使在64

位虚拟机中使用了64位的物理内存空间去实现一个变量槽,虚拟机仍要使用对齐和补白的手段 让变量槽在

外观上看起来与32位虚拟机中的一致。

一个变量槽可以存放一个32位以内的数据类型,Java中占用不超过32位存储空间的数据类型有

boolean、byte、char、short、int、float、reference和returnAddress这8种类型。

前面6种不需要多加解释,可以按照Java语言中对应数据类型的概念去理解它们,(仅是这样理解而

已,Java语言和Java虚拟机中的基本数据类型是存在本质差别的),而第7种reference类型表示对一个对

象实例的引用,《Java虚拟机规范》既没有说明它的长 度,也没有明确指出这种引用应有怎样的结构。

但是一般来说,虚拟机实现至少都应当能通过这个引 用做到两件事情,一是从根据引用直接或间接地

查找到对象在Java堆中的数据存放的起始地址或索 引,二是根据引用直接或间接地查找到对象所属数据类

型在方法区中的存储的类型信息,否则将无法 实现《Java语言规范》中定义的语法约定。

第8种returnAddress类型目前已经很少见了,它是为字节 码指令jsr、jsr_w和ret服务的,指向了一条

字节码指令的地址,某些很古老的Java虚拟机曾经使用这几条指令来实现异常处理时的跳转,但现在也已

经全部改为采用异常表来代替了。

对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。Java语言中

明确的64位的数据类型只有long和double两种。这里把long和double数据类型分割存储的做法与“long

和double的非原子性协定”中允许把一次long和double数据类型读写分割为两次32位读写的做法有些类

似,读者阅读到本书关于Java内存模型的内容时可以进行对比。

不过,由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是

否为原子操作,都不会引起数据竞 争和线程安全问题。Java虚拟机通过索引定位的方式使用局部变量表,

索引值的范围是从0开始至局部变量表最大的变 量槽数量。如果访问的是32位数据类型的变量,索引N就代

表了使用第N个变量槽,如果访问的是64位数据类型的变量,则说明会同时使用第N和N+1两个变量槽。

对于两个相邻的共同存放一个64位数据 的两个变量槽,虚拟机不允许采用任何方式单独访问其中的某

一个,《Java虚拟机规范》中明确要求 了如果遇到进行这种操作的字节码序列,虚拟机就应该在类加载的

校验阶段中抛出异常。当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表

的传递过程, 即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中

第0位索 引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到

这个隐 含的参数。

其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据 方法体内

部定义的变量顺序和作用域分配其余的变量槽。

为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变 量,

其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用 域,那这

个变量对应的变量槽就可以交给其他变量来重用。

2. 操作数栈

操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)

栈。同局部变量表一样,操作栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项 之

中。

操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。

32位数据类型所占 的栈容量为1,64位数据类型所占的栈容量为2。

Javac编译器的数据流分析工作保证了在方法执行的任 何时候,操作数栈的深度都不会超过在

max_stacks数据项中设定的最大值。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种 字节

码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。譬如在做算术运算的时候是通过 将运算涉

及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作 数栈来进行方

法参数的传递。

举个例子,例如整数加法的字节码指令iadd,这条指令在运行的时候要 求操作数栈中最接近栈顶的两

个元素已经存入了两个int型的数值,当执行这个指令时,会把这两个int值出栈并相加,然后将相加的结果

重新入栈。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器 必须

要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。

再以上面的iadd指令为 例,这个指令只能用于整型数的加法,它在执行时,最接近栈顶的两个元素的

数据类型必须为int型, 不能出现一个long和一个float使用iadd命令相加的情况。 另外在概念模型中,两

个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。

但是在 大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。这样做不仅节约了

一些空间,更重要的是在进行方法调 用时就可以直接共用一部分数据,无须进行额外的参数复制传递了。

Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈。

两个栈帧之间的数据共享

3. 动态连接

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

过程中的动态连接(Dynamic Linking),我们知道Class文件的常量池中存 有大量的符号引用,字节码中

的方法调用指令就以常量池里指向方法的符号引用作为参数,这些符号 引用一部分会在类加载阶段或者第

一次使用的时候就被转化为直接引用,这种转化被称为静态解析。

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

4. 方法返回地址

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

第一种方式是执行引擎遇到任意一个方法 返回的字节码指令,这时候可能会有返回值传递给上层的方

法调用者(调用当前方法的方法称为调用 者或者主调方法),方法是否有返回值以及返回值的类型将根据

遇到何种方法返回指令来决定,这种退出方法的方式称为“正常调用完成”(Normal Method

Invocation Completion),

另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处 理。

无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方 法的异常

表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为“异常调用 完成

(Abrupt Method Invocation Completion)”,一个方法使用异常完成出口的方式退出,是不会给它

的上层调用者提供任何返回值的。

无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继 续执

行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。

一般来说,方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这

个计数器值。

而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存 这部分信息。

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的 局部变量

表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值 以指向方法调

用指令后面的一条指令等。

5. 附加信息

《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如:与调试、 性能收

集相关的信息,这部分信息完全取决于具体的虚拟机实现,在讨论概念时,我们一 般会把动态连接、方法

返回地址与其他附加信息全部归为一类,称为栈帧信息。

三、方法调用

1. 简介

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本 (即

调用哪一个方法),在程序运行时,进行方法调用是最 普遍、最频繁的操作之一,Class文件的编译过程中

不包含传统程序语言编译的 连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法

在实际运行时内存布局 中的入口地址(也就是之前说的直接引用),这个特性给Java带来了更强大的动态

扩展能力,但也使 得Java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能

确定目标方法 的直接引用。

2. 解析

承接前面关于方法调用的话题,所有方法调用的目标方法在Class文件里面都是一个常量池中的符 号

引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前 提是:

方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不 可改变的。

换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称

为解析(Resolution)。

在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要有静态方法和私有方法两 大

类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通 过继承

或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。

调用不同类型的方法,字节码指令集里设计了不同的指令。在Java虚拟机支持以下5条方法调用字 节

码指令,分别是:

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

前面4条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户

设定的引 导方法来决定的。

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,

Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上被final修饰

的方法(尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引 用解析

为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方法就被

称为“虚方法”(Virtual Method)。

Java中的非虚方法除了使用invokestatic、invokespecial调用的方法之外还有一种,就是被final修饰

的实例方法。虽然由于历史设计的原因,final方法是使用invokevirtual指令来调用的,但是因为它也无 法

被覆盖,没有其他版本的可能,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果 肯定是

唯一的。

在《Java语言规范》中明确定义了被final修饰的方法是一种非虚方法。 解析调用一定是个静态的过

程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号 引用全部转变为明确的直接引用,不

必延迟到运行期再去完成。

而另一种主要的方法调用形式:分派 (Dispatch)调用则要复杂许多,它可能是静态的也可能是动态

的,按照分派依据的宗量数可分为单 分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态

多分派、动态单分派、动态多分派4种分派组合情况,下面我们来看看虚拟机中的方法分派是如何进行的。

3. 分派

众所周知,Java是一门面向对象的程序语言,因为Java具备面向对象的3个基本特征:继承、封装 和

多态。分派调用过程就是多态性特征的一些最基本的体现,如“重载”和“重写”在Java虚拟机之中是如

何实现的,这里的实现当然不是语法上该如何写,我们关心的依然是虚拟机如何确定正确的目标方法。

静态分派

“分派”(Dispatch)这个词本身就具有动态性,一 般不应用在静态语境之中,所有依赖静态类型来

决定方法执行版本的分派动作,都称为静态分派。

静态分派的最典型应用表 现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际

上不是由虚拟机来执行 的,这点也是为何一些资料选择把它归入“解析”而不是“分派”的原因。

动态分派

了解了静态分派,我们接下来看一下Java语言里动态分派的实现过程,动态分派的实现过程,它与

Java语言多态性的另外 一个重要体现—重写(Override)有着很密切的关联。动态分派是执行非常频繁的

动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的 方法元数据中搜索合适的目标方法,

因此,Java虚拟机实现基于执行性能的考虑,真正运行时一般不 会如此频繁地去反复搜索类型元数据。

面对这种情况,一种基础而且常见的优化手段是为类型在方法 区中建立一个虚方法表(Virtual

Method Table,也称为vtable,与此对应的,在invokeinterface执行时也 会用到接口方法表,Interface

Method Table,简称itable),使用虚方法表索引来代替元数据查找以 提高性能。

虚方法表中存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那子类的虚方 法表

中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口,如果子类中重写了 这个方

法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。

方法表结构

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方 法表

中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了 这个方

法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。

比如,Son重写 了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是

Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了

Object的数据类型。

为了程序实现方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序 号,

这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需 的入口地

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

表也一同初始化完毕。

单分派与多分派

方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多

分派两种。

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

宗量(argument),可以理解为自变量,在计算机科学中可能当成形式参数更好理解一些。

四、动态类型语言支持

1. 简介

Java虚拟机的字节码指令集的数量自从Sun公司的第一款Java虚拟机问世至今,二十余年间只新增 过

一条指令,它就是随着JDK 7的发布的字节码首位新成员invokedynamic指令,这条新增加的指令是JDK

7的项目目标:实现动态类型语言(Dynamically Typed Language)支持而进行的改进, 也是为JDK 8里

可以顺利实现Lambda表达式而做的技术储备。

2. 动态类型语言

首先,我们要明白什么是动态类型语言,据我所知,动态类型语言的关键特征是它的类型检查的主体

过程是在运行期而不是编 译期进行的,满足这个特征的语言有很多,常用的包括:APL、Clojure、

Erlang、Groovy、JavaScript、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk、Tcl,等等。

那相对地,在编译期就 进行类型检查过程的语言,譬如C++和Java等就是最常用的静态类型语言。

动静态类型语言它们都有自己的优点,选择哪种语言是需要 权衡的事情。静态类型语言能够在编译期

确定变量类型,最显著的好处是编译器可以提供全面严谨的 类型检查,这样与数据类型相关的潜在问题就

能在编码时被及时发现,利于稳定性及让项目容易达到 更大的规模。

而动态类型语言在运行期才确定类型,这可以为开发人员提供极大的灵活性,某些在静 态类型语言中

要花大量臃肿代码来实现的功能,由动态类型语言去做可能会很清晰简洁,清晰简洁通 常也就意味着开发

效率的提升。

3. Java与动态类型

Java虚拟机与动态类型语言之间有什么关系?Java虚 拟机毫无疑问是Java语言的运行平台,但它的使

命并不限于此,由于Java虚拟机层面对动态类型语言的支持一直都还有所欠缺,主要表现在方法调用方

面:JDK 7以前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、

invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者

CONSTANT_InterfaceMethodref_info常量),而方法的符号引用会在编译期产生,但动态类型语言只能

在运行期确认方法的接收者;这样,在Java虚拟机上实现的动态类型语言就不得不使用“曲线救国”的方

式来实现(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符 类型的适配),若采

用占位符类型实现动态类型,会增加其实现复杂度,并带来额外的性能(方法内联无法进行)和内存开销

(内联缓存压力大),因此JDK 7新增invokedynamic指令及java.lang.invoke包来从JVM层面实现动态类

型;

4. java.lang.invoke 包

JDK 7时新加入的java.lang.invoke包,没有这个包之前,单纯依靠符号引用来确定调用的目标方法,

这个包出现之后,提供一种新的动态确定目标方法的机制,称 为“方法句柄”(Method Handle),仅站

在Java语言的角度看,MethodHandle在使用方法和效果上与Reflection有众多相似之 处。不过,它们也

有以下这些区别:

  1. Reflection和MethodHandle机制本质上都是在模拟方法调用,但是Reflection是在模拟Java代码层次 的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在MethodHandles.Lookup上的3个方法findStatic()、findVirtual()、findSpecial()正是为了对应于invokestatic、invokevirtual(以及invokeinterface)和invokespecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用Reflection API时是不需要关心的。
  2. Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息来得多。前者是方法在Java端的全面映像,包含了方法 的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。而 后者仅包含执行该方法的相关信息。用开发人员通俗的话来讲,Reflection是重量级,而MethodHandle是轻量级。
  3. 由于MethodHandle是对字节码的方法指令调用的模拟,那理论上虚拟机在这方面做的各种优化 (如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前实现还在继续完善 中),而通过反射去调用方法则几乎不可能直接去实施各类调用点优化措施

MethodHandle与Reflection除了上面列举的区别外,MethodHandle与Reflection最关键的一点在

于:Reflection API的设计目标是只为Java语言服务的,而MethodHandle则设计为可服务于所有Java虚

拟机之上的语言,其中也包括了Java语言而已,而且Java在这里并不是主 角。

5. invokedynamic 指令

JDK 7为了更好地支持动态类型语言,引入了第五条方法调用的字节码指令invokedynamic,某种意

义上可以说invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4

条“invoke*”指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机

转嫁到具体用户代码之中,让用户(广义的用户,包含其他程序语言的设计者)有更高的自由度。

而 且,它们两者的思路也是可类比的,都是为了达成同一个目的,只是一个用上层代码和API来实

现, 另一个用字节码和Class中其他属性、常量来完成。

每一处含有invokedynamic指令的位置都被称作“动态调用点(Dynamically-Computed Call

Site)”, 这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变

为JDK 7时新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方

法 (Bootstrap Method,该方法存放在新增的BootstrapMethods属性中)、方法类(MethodType)

和 名称。引导方法是有固定的参数,并且返回值规定是java.lang.invoke.CallSite对象,这个对象代表了

真正要执行的目标方法调用。

根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到 并且执行引导方法,从

而获得一个CallSite对象,最终调用到要执行的目标方法上。

6. 掌控方法分派规则

invokedynamic指令与此前4条传统的“invoke*”指令的最大区别就是它的分派逻辑不是由虚拟机决

定的,而是由程序员决定。

我们可以通过以下说法解释法分派规则。在Java程序中,可以通过“super”关键字很方便地调用到

父类中的方法,但如果要访问祖类的方法这个问题该怎么解决?

在JDK 7之前,也就是在拥有invokedynamic和java.lang.invoke包之前,使用纯粹的Java语言很难处

理这个问题(使用ASM等字节码工具直接生成字节码当然还是可以处理的,但这已经是在字节码而不是

Java语言层面来解决 问题了), 而invokevirtual指令的分派逻辑是固定的,只能按照方法接收者的实际类

型进行分派,这个逻辑完全固化在虚拟机中,程序员无法改变,之后就出现了invokedynamic指令解决这

种问题,invokedynamic指令与此前4条传统的“invoke*”指令的最大区别就是它的分派逻辑不是由虚拟

机决定的,而是由程序员决定。

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

1. 简介

关于Java虚拟机是如何调用方法、进行版本选择的内容我们已经了解,接下里,我们来探讨虚拟机是

如何执行方法里面的字节码指令的,我们知道许多Java虚拟机的执行引擎在执 行Java代码的时候都有解释

执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执 行)两种选择,这里,我们将会分

析在概念模型下的Java虚拟机解释执行字节码时,其执行引擎 是如何工作的。

2. 解释执行

Java语言经常被人们定位为“解释执行”的语言,在Java初生的JDK 1.0时代,这种定义还算是比较

准确的,但当主流的虚拟机中都包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译 执

行,就成了只有虚拟机自己才能准确判断的事。

再后来,Java也发展出来了直接生成本地代码的编译器[如何GCJ(GNU Compiler for the Java)],

而C/C++也出现了通过解释器执行的版本(如CINT),这时候再笼统的说“解释执行”,对于整个Java语

言来说就成了几乎没有任何意义的概念。无论是解释还是编译,也无论是物理机还是虚拟机,对于应用程

序,机器都不可能如人那样阅 读、理解,然后获得执行能力。

大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集 之前,都需要经过图中的各个步

骤。

编译过程:

如今,基于物理机、Java虚拟机,或者是非Java的其他高级语言虚拟机(HLLVM)的代码执行过

程,大体上都会遵循这种符合现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法 分析

处理,把源码转化为抽象语法树(Abstract Syntax Tree,AST)。

对于一门具体语言的实现来说, 词法、语法分析以至后面的优化器和目标代码生成器都可以选择独立

于执行引擎,形成一个完整意义 的编译器去实现,这类代表是C/C++语言。也可以选择把其中一部分步骤

(如生成抽象语法树之前的 步骤)实现为一个半独立的编译器,这类代表是Java语言。

又或者把这些步骤和执行引擎全部集中封 装在一个封闭的黑匣子之中,如大多数的JavaScript执行引

擎。

在Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法 树

生成线性的字节码指令流的过程。

因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟 机的内部,所以Java程序的编译就是

半独立的实现。

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

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

ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。

与之相对应的另一套常用的指令集架构是基于寄存器的指令集,最典型的就是X86的地址指令集,说的

通俗一下,就是现在我们主流的PC机中直接支持的指令集架构,这些指令集依赖寄存器工作。

那么,基于栈的指令集和基于寄存器的指令集这两者有什么不同呢?

举个简单例子,分别使用这两种指令计算1+1的结果,基于栈的指令集会是这个样子:

iconst_1
iconst_1
iadd
istore_0

两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后将结果放回

栈顶,最后istore_0把栈顶的值放到局部变量表中的第0个Slot中。

如果基于寄存器的指令集,那程序可能会是这个样子:

mov eax, 1
add eax, 1

mov指令把EAX寄存器的值设置为1,然后add指令再把这个值加1,将结果就保存在EAX寄存器里

面。这种二地址指令是x86指令集中的主流,每个指令都包含两个单独的输入参数,依赖于寄存器来访问

和存储数据。

了解了基于栈的指令集与基于寄存器的指令集的区别后,读者可能会有个进一步的疑问,这两套指令

集谁更好一些呢?

基于栈的指令集主要的优点就是可移植,寄存器是由硬件直接提供,程序直接依赖这些硬件寄存器则

不可避免地要受到硬件的约束。

例如,现在32位80x86体系的处理器中提供了8个32位的寄存器,而ARM体系的CPU则提供了16个32

位的通用寄存器。如果使用栈架构的指令集,用户程序不会直接使用寄存器,就可以由虚拟机实现来自行

决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获得最好的性能,这样实现起

来也更加简单一些。

栈架构的指令集还有一些其他的优点,如代码相对更加紧凑,编译器实现更加简单等。栈架构指令集

的主要缺点是执行速度相对来说会稍微慢一些。虽然栈架构指令集的代码非常紧凑,但是完成相同功能所

需要的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的操作指令数量。

更重要的是,栈实现在内存之中,频繁的栈操作意味着频繁的内存访问,相对于处理器来说,内存始

终是执行速度的瓶颈。尽管虚拟机采取栈顶缓存的优化手段,把最常用的操作映射到寄存器中避免直接内

存访问,但这也只能是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致

栈架构指令集的执行速度会相对较慢。

4. 基于栈的解释器执行过程

public class Main {
  public int  calc()
  {
    int a=100;
    int b=200;
    int c=300;
    return (a+b)*c;
  }
  public static void main(String[] args) {

    Main m=new Main();
    int a=m.calc();



  }
}

下面对calc函数指令执行过程进行描述

bipush

push the int constant <i> onto the operand stack 将常量值i压入操作数栈中
-1~5 用iconst_<i>中
-128~127用bipush
32768~32767采用sipush指令
取值-2147483648~2147483647采用 ldc 指令。

因为此方法是非静态方法,局部变量表slot0必为this

istore_n

istore_<n>
Description:
The <n> must be an index into the local variable array of the current frame
<n>必须是局部变量表的索引
The value on the top of the operand stack must be of type
It is popped from the operand stack,and the value of the local variable at <n> is set to val
将栈顶元素(int类型)弹出,放入局部变量表索引n处

偏移3-10均是重复的赋值操作,图示略过。

iload_n

iload_<n>
Description:
The <n> must be an index into the local variable array of the current frame
The local variable at <n>  must contain an int
The value of the local variable at <n> is pushed onto the operand stack
将局部变量表索引<n>位置的值(int类型)放入操作数栈中

偏移11、12是相同命令,执行完结果如下:

iadd

Both value1 and value2 must be of type int.The values are popped from the operand stack.The int result is value1+value2.The result is pushed onto the operand stack.
将操作数栈顶取元素(2次),将结果放入操作数栈中

iload_3

imul

imul与iadd类似,imul计算乘法,省略

ireturn

If no exception is thrown,value is popped from the operand stack of the current frame 
and pushed onto the operand stack ofhe frame of the invoker.
And other values on the operand stack of the current method are discard.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

CodingW丨编程之路

你的鼓励是我创作最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值