概述
在之前"Java 运行时内存如何分配?"这篇文章中,曾经提到过 Java 在执行方法时,借助于 Java 虚拟机栈来实现方法的调用与执行,但具体是如何执行的呢? 本篇文章就主要来解决这个问题。
Java 虚拟机以方法为执行的基本单位,而方法在执行的过程中需要通过栈的方式来实现方法的调用与执行,因而在开始正式内容之前,我们必须先了解一下 Java虚拟机
在执行方法时所借助的栈模型---栈帧
。
运行时栈帧结构
栈帧
这一概念我们在前边介绍 Java 内存分配这篇文章中曾频繁提起,当时只是简单说 栈帧
是 Java 虚拟机栈的基本组成单位,一个个栈帧最终构成了虚拟机栈。
但本质上来讲,“栈帧” 就是一种数据结构,是用以支持虚拟机进行方法调用和方法执行的数据结构。它也是虚拟机运行时数据区中的虚拟机栈的栈元素。
栈帧
存储的内容如下所示:
那一个栈帧所占内存空间会有多大呢?
实际上一个 栈帧
的大小在编译 Java 程序源码的时候已经被计算出来了,并且被写到了 方法表
的"Code 属性" 之中。换言之,一个栈帧需要分配多少内存,并不会收到程序运行期数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
前边,我们也曾提到过 Java 虚拟机栈
是线程私有的,也就是说每个线程都拥有一个独立的虚拟机栈,但一个线程中的方法调用链可能会很长,虚拟机栈
中同时会拥有多个 栈帧
。此时从 Java 程序的角度看,同一时刻,同一条线程中,在调用堆栈中的所有方法都处于运行状态。而对于 JVM 执行引擎来说,在活动线程中,只有位于栈顶的方法才是运行的,只有位于栈顶的 栈帧
是有效的,被称为“当前栈帧”(Current Stack Frame),与这 个栈帧关联的方法被称为“当前方法”(Current Method)。在概念模型上具体的栈帧结构如下图所示:
局部变量表
局部变量表
是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。其最大容量由方法的 Code 属性中的 max_locals
数据项所决定。其容量以变量槽(Variable Slot) 最小单位。
那具体变量槽是多大呢?
实际上,在《Java 虚拟机规范》中并没有明确指出一个变量槽占用的内存的大小,只是很有导向性的说到每个变量槽都应该存放一个 boolean、byte、char、short、int、float、reference 或者 returnAddress 类型的数据,这 8 种数据类型都可以通过 32 位或者更小的物理内存来存储。它允许变量槽的长度可以随着处理器、操作系统或者虚拟机实现不同而发生不同的变化。
前边的 8 中数据类型中,其中六种我们都很熟悉,就是很常见的基本数据类型,而后边两种 reference 和 returnAddress 则不常见,下边我们进行简单解释:
- reference 类型表示对一个对象实例的引用,《Java 虚拟机规范》既没有说明它的长度,也没有明确指出其具体的结构,但要求虚拟机至少能通过引用做两件事:一、根据引用直接或者间接查找到对象在 Java 堆中的数据存放的起始地址或者索引,二是根据引用直接或者间接查找到对象所属数据类型在方法区中的存储的类型信息(简单来说就是 Class 类型)。
- returnAddress 类型目前已经很少见了,主要是为字节码执行 jsr、jsr_w、和 ret 服务的,指向了一条字节码指令的地址,某些老的虚拟机曾使用者几条指令来实现异常处理的跳转,现在已经全部使用异常表来替代。
操作数栈
操作数栈
也常被称为操作栈,它是一个后入先出(Last In First Out)栈。同局部变量表一样,操作数栈的最大深度在编译的时候也被写入到 max_stacks
数据项中。当一个方法刚开始执行的时候,这个方法的操作数栈是空的,在方法执行的过程中会有各种字节码执行往操作数栈中写入或者提取内容,也即出栈和入栈操作。具体操作过程请参考后边章节"方法执行",此处暂不详述。
动态连接
在《"Java 类文件结构的简单分析"》这篇文章中,我们曾说道 Class 文件的常量池中存在着大量的 符号引用
,而这些 符号引用
在 Java 虚拟机执行的过程中不能直接被使用,需要在类加载的"解析"阶段被转化成直接引用,这种转换称之为静态解析,另外一部分将在每一次运行期间都转换成动态连接。
可能到这里,有小伙伴会有疑问了:什么情况下 符号引用
会通过 直接引用
来进行转化?什么情况下会通过 动态连接
来进行转化呢?
简单来说,只要符合“编译期可知,运行期不可变”特点,比如静态方法和私有方法都可以通过 静态解析
来进行转化。而不符合该特点的方法则需要通过 动态连接
,更加详细的内容可以参考"方法调用"这一章节的内容。
方法返回地址
一个方法在调用另一个方法结束之后,需要返回调用处继续执行后续的方法体。那么调用其他方法的位置点就叫做「返回地址」,我们需要通过一定的手段保证,CPU 执行其他方法之后还能返回原来调用处,进而继续调用者的方法体。
上边的描述比较抽象,下边我们结合一个例子来理解:
public void A(){
B();
System.out.println("I am A!");
}
private void B(){
System.out.println("I am B!");
}
上边代码的含义非常容易理解,首先方法 A()
调用了方法 B()
打印出 I am B!
,然后打印出 I am A!
。但我们关注点在于方法 B 执行完成之后如何继续执行方法 A 的打印操作呢?此时就需要方法的返回地址了,需要在 B()
方法的返回地址上存放 System.out.println("I am A!")
对应指令的地址,进行保证 B 方法执行完成之后能够返回到原来的调用位置 😄 !!
当然上边举的例子是比较简单的情况,因为在实际执行过程中,方法退出的情况会有两种:
- 正常退出(称之为
正常调用完成
):此时主调方法的 PC 计数器的值可以作为方法返回地址
。 - 异常退出(称之为
异常调用完成
):返回地址要通过异常处理器表来确定,栈帧不会保存这部分信息。
方法退出过程实际上等同于把当前栈帧出栈,因而退出时可能的执行操作有:
- 回复上层方法的局部变量表和操作数栈
- 把返回值(如果有的话)压入调用者堆栈的操作数栈中
- 调整 PC 寄存器的值以指向方法调用指令后面的一条指令
方法调用
方法调用实际上解决的就是 “如何确定要调用的方法?” 即确定调用方法的版本。
此时可能会有小伙伴迷糊,怎么方法还有多个版本 ❓ ❓
答案是显而易见的,Java 支持多态和方法重写,因而同名的方法可能会有多个,也即存在多个版本的方法。
解析
在类加载解析阶段,会将一部分符号引用转化为直接引用,而这种解析方式成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期间是不可改变的。 也就是说调用目标在程序代码写好、编译器进行编译的那一刻就一定确定了。这类方法的调用称之为 解析(Reslution)
,好像跟编译有点相似。😸
而在 Java 中满足“编译器可知,运行期不可变”这一要求的方法主要有两类:
- 静态方法:与 Class 类绑定,类加载时便可确定
- 私有方法:外部不可访问,不可被修改
- 实例构造方法
- 父类方法
- final 修饰的方法
我们简单来看一个例子,来说明解析调用的过程:
/**
* 静态方法解析演示
* @author vcjmhg
*/
public class StaticResolution {
public static void sayHello() {
System.out.println("hello world");
}
public static void main(String[] args) {
StaticResolution.sayHello();
}
}
代码很容易理解,就是在主函数中调用静态方法 sayHello()
,使用 javap -verbose
命令查看这段代码对应的字节码,会发现 sayHello()
会通过一个叫做 invokestatic 的指令所调用,而且其调用的方法版本已经在编译时就明确以常量池项的形式固化在字节码指令参数中,具体代码如下:
当然为了底层支持调用不同类型的方法,字节码指令集中设计了不同的指令:
- invokestatic:用于调用静态方法
- invokespecial:用于调用实例构造器
<init>()
、私有方法和父类中的方法 - invokevirtual:用于调用所有虚方法
- invokeinterface:用于调用接口方法,会在运行期间再确定实现该接口的对象。
- invokedynamic:现在运行期间动态解析出调用点限定符所引用的方法,然后在调用该方法
其中前两条指令 invokestatic
、invokespecial
这两条指令主要用来调用支持解析的方法,也就是前边所说的五种方法类静态方法、私有方法、实例构造方法、父类方法、final 修饰的方法(尽管实际上是通过 invokevirtual 指令调用),这五类方法都会在类加载的时候就可以把符号引用解析为该方法的直接引用,这些方法称之为非-虚方法。
有了非-虚方法,那肯定也就有虚方法,那什么是虚方法呢?
其实简单来说,除了非-虚方法之外的其他方法都是虚方法。如果严格按照定义的话:可以被覆写的方法都可以称作虚方法,因此虚方法并不需要做特殊的声明,也可以理解为除了用 static、final、private 修饰之外的所有方法都是虚方法。
讲到这里那么第三条指令 invokevirtual
也就不用多说了,该指令主要就是用来调用 虚方法
的。
按理说,前边这三条指令既可以调用虚方法也可以调用非-虚方法应该足够了哈 🤔🤔,为什么还要增加后边 invokeinterface
和 invokedynamic
这两条指令呢 ❓ ❓
下边我们分成两个小章节来详细解释这两个小问题。
invokeinterface 指令存在的必要性
简单来说,这是由接口方法的特点所决定,因为接口方法本身并不要求实现,因而需要单独设置一个指令 invokeinterface
来对其进行访问。
详细的可参考 What is the point of invokeinterface?
invokedynamic 指令存在的必要性
这条指令实际上是 JDK7 时,为了更好地支持动态类型语言引入了该指令,但实际上如果 java 代码正常编译的情况下生成的 Class 文件根本不会用到该指令。玛尼 🙄🙄!!前边说了这么多,这指令竟然用不到,好像是在开玩笑!
但实际上确实如此,该指令虽然 JVM 支持,但 Java 并未使用,主要是给那些运行在 JVM 平台上的其他语言,比如 Scale、JRuby 等。
话虽如此,但它的基本原理我们还是有必要了解的哈:
该指令本质上就是为了解决原来 4 条"invoke*"分派规则完全固化到虚拟机之中,缺少灵活性的问题,而引入 invokedynamic 指令后,则可以将如何查找目标方法的决定权从虚拟机转嫁到具体用户代码中,让用户(广义的用户,包含语言的设计者)有更高的自由度。
分派
前边,我们说了解析调用是一个静态过程,在编译期间就完全确定,在类加载阶段就会把涉及到的 符号引用
全部转变为明确的 直接引用
,不必延迟到运行期再去完成。而另一种主要调用形式--分派
则要复杂的多,它可能是静态的也可能是动态的,按照分派依据的宗量数可以分成 单分派
和 多分派
。具体分类情况如下图所示:
静态分派
静态分派(Method Overload Resolution
)实际上与其说是属于 分派
其实更像是 解析
(Resolution),因为静态分派实际上在编译阶段已经确定了方法的版本。
具体来说怎么回事呢?下边结合一个简单的例子,展开来讲:
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();
Woman readWoman = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
sr.sayHello(readWoman);
}
}
同样代码逻辑非常简单,实际上就是验证 sayHello()
方法的三种重载,运行结果也很容易能看出:
hello,guy!
hello,guy!
hello,lady!
但虚拟机内部是如何选择需要执行的方法类型呢?
在解决上述问题之前,需要先搞懂两个关键概念:静态类型(Static Type) 和实际类型(Actual Type)。
结合上边的例子,两种类型的简单差别可以通过一张图来简单解释:
静态类型
和 实际类型
在程序的运行期间都可能会发生变化,但区别是静态类型在编译期间便是可知的,而实际类型的变化结果在运行期间才可确定,编译器在编译程序时并不知道一个对象的实际类型是什么。
听起来好像很迷糊,因为上边给的例子好像 woman
变量的实际类型在编译期间也是可以确定的,我们可以结合如下代码来理解这句话。
//实际类型
Human human = (new Random().nextBoolean() ? new Man() : new Woman());
//静态类型变化
sr.sayHello((Man) human);
sr.sayHello((Woman) human);
上边的代码,明显可以看到对象 human 的 实际类型
是可变的,编译期间完全是一个“薛定谔的猫”,到底是 Man 还是 Woman 还是必须要等到程序运行到该位置的时候才能确定。但是 human 的 实际类型
则一直都是 Human。
明白了 实际类型
和 静态类型
这两个概念之后,书接上回,解决“"虚拟机内部是如何选择需要执行的方法类型呢?"”这个问题。
main 方法里边两次 sayHello()
方法的调用,在方法接受者已经确认是对象“sr”的前提下,使用哪个重载版本,完全取决于传入参数的数量和数据类型,虽然说变量 man
和 woman
的实际类型不同,但它们静态类型相同,而且虚拟机(或者说编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据。由于静态类型在编译期可知,所以在编译期间,Javac 编译器就根据参数的静态类型决定了会使用哪个重载版本,因此最终就会选择 sayHello(Human)
作为调用目标,并把这个方法的符号引用写到 main()方法里的两条 invokevirutal 参数,完成 静态分派
的过程。
动态分派
前边我们了解的与 重写
联系紧密的 静态分配
,接下来我们介绍一下与 重写
有着紧密相连的 动态分派
。同样的,我们还是通过一个例子来说明动态分派问题:
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
运行结果相信不会出乎任何人意料,但我们现在需要思考的和前边的问题一样,Java 虚拟机是如何判断应该调用哪个方法的呢?
显然这里选择调用方法的版本不能根据静态类型来决定了,因为静态类型相同的两个 man 和 woman 在调用 sayHello()
方法之后却产生了不同的行为,甚至 man 在两次调用的过程中执行了两个不同的方法。导致这个问题的原因也很简单,因为变量的实际类型不同。但此时会有另一个问题,Java 虚拟机是如何根据实际类型来确定分派方法执行版本呢?
我们通过 javap 命令输出这段代码的字节码如下(只包含了 main 方法部分):
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 DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method DynamicDispatch$Human.sayHello:()V
24: new #4 // class DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method DynamicDispatch$Human.sayHello:()V
36: return
LineNumberTable:
line 22: 0
line 23: 8
line 24: 16
line 25: 20
line 26: 24
line 27: 32
line 28: 36
}
首先是 0~15 行的字节码是准备动作,作用是建立 man 和 woman 的内存空间、调用 Man 和 Woman 类型的实例构造方器,将这两个实例的引用存放在第 1、2 个局部变量表的变量槽中,对应 Java 代码中下边两行:
Human man = new Man();
Human woman = new Woman();
执行完成后局部变量表:
接下来 16~21 行是关键部分,16 行和 20 行把刚刚创建的的引用压入 操作栈
栈顶;17 和 21 行是方法的调用指令,这两条指令从字节码的角度来看,无论指令还是参数都完全一样,但这两句话最终执行的目标函数却不相同,因而问题的关键可能就在于 invokevirtual
本身。
根据《Java 虚拟机规范》,invokevirtual
指令在运行时解析过程分为如下几步:
- 找到
操作数栈
栈顶的第一个元素所指向对象的实际类型,记做 C。 - 如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法,进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回
java.lang.IllegalAccessError
异常。 - 否则,按照继承关系从上往下依次对 C 的各个父类进行第 2 步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出
java.lang.AbstractMethodError
异常。
正是因为 invokevirtual
指令执行的第一步就是在运行期间确定接受者的实际类型,所以两次调用 invokevirtual
指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,而是会根据方法接受者的实际类型来选择方法的版本。
具体来说,当在 17 行执行 invokevirtual
指令时,栈帧中的内存情况如下图所示:
因而在执行到 17 行时,匹配到的方法是 Man.sayHello()
方法因而第一行打印出来的结果为 man say hello
。
当执行到 21 行时,栈帧中的内存情况变成如下情况:
因而执行到 21 行时,匹配到的方法为 Woman.sayHello()
因而第二行打印出来的结果为 woman say hello
。
第三行的执行结果与上述过程相似,此处不再赘述。
既然这种 多态性
的根源在于虚方法调用指令 invokevirtual
,对字段是无效的,因为字段永不会使用这条指令,实际上 Java 中只有虚方法存在,字段永远不可能是虚的,如果出现子类的变量名与父类变量名相同时,子类遮蔽同名的父类字段。
单分派与多分派
前边我们讲到“基于宗量数量的不同,可以将分派分成单分派和多分派”,但什么是宗量呢?
方法的接受者和方法的参数称为方法的 宗量
。结合一个小例子:
从图中很容易可以看到,具有三个宗量:方法接受者 man
、方法参数 1
和 "string"
因而针对单分派和多分派了说,单分派就是根据一个宗量来对目标方法进行选择,多分派则是根据多于一个宗量来对目标方法进行选择。
两者具体的一个含义,我们还是结合一个例子来进行说明:
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());
son.hardChoice(new QQ());
}
}
代码逻辑很简单,在 main 方法中,调用了两次 hardChoice()
方法。执行运行结果如下:
father choose 360
son choose qq
当然,我们实际的关注点仍然是方法选择的过程。
首先是 动态分派
过程,在编译阶段,这时候选择目标方法的依据有两点:
静态类型
是 Father 还是 Son方法参数
是 QQ 还是_360
这次选择结果的最终产物是产生了两条 invokevirtual
指令,两条指令的参数分别指向 Father::hardChoice(360)
以及 Father::hardChoice(QQ)
方法的符号引用。因为在静态分派的过程中,是根据两个宗量进行选择的,因而 Java 语言的静态分派属于多分派类型。
在运行阶段,也就是 动态分派
的过程。在执行 son.hardChoice(new QQ())
这行代码时,更准确的说是在执行对应的 invokevirtual
指令时,由于编译期已经决定目标方法的签名必须为 hardChoice(QQ),虚拟机此时不会关心传递过来的参数"QQ"到底是腾讯 QQ 还是奇瑞 QQ,因为参数的 静态类型
和 实际类型
此时对方法的选择都不会构成任何影响,唯一可以影响虚拟机选择的因素只有该方法的接受者是 Father 还是 Son。因为只有一个宗量作为选择依据,因而 Java 语言的动态分派属于单分派类型。
综上,可以得出一个简单结论(截止到 JDK12)Java 语言是一门静态多分派、动态单分派的语言。
方法执行
好了,前面已经详细讲了 Java 选择一个方法的过程,也就是说它会通过 解析
和 分派
来找到具体要执行的方法版本,找到之后虚拟机是如何进行执行的呢 ❓ 😁
在 Java 语言中,Javac 编译器对 java 源代码进行编译后会生成对应的 Class 文件,也就是编译器输出的是字节码指令流。该指令流是一种基于栈的指令集架构,它依赖操作数栈来进行工作。与之相对应的是我们在汇编语言中常见的基于寄存器的指令集。
两种指令集的区别
这两种指令集有什么区别吗?
举一个例子,我们分别用两种指令集区计算 1+1 的结果,基于栈的指令集回事这样的:
iconst_1
iconst_1
iadd
istore_0
两条 iconst_1
指令连续把两个常量 1 压入栈中,iadd
指令把栈顶的两个值出栈、相加然后把结果放回栈顶,最后 istore_0
把栈顶的值放到局部变量表的第 0 个变量槽中。
这种指令流一般是不带参数的,使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存于操作数栈中。
如果使用基于寄存器的指令集,那程序的运行结果会是这样:
mov eax,1
add eax,1
mov 指令把 EAX 寄存器的值设置为 1,然后 add 指令再把这个值加 1,结果保存在 EAX 寄存器里面。
了解了这两种类型的指令集之后,可能有的小伙伴会有疑问,这两种指令哪种更好呢?或者为何会出现这两种类型的指令呢?
基于栈的指令集主要优点是:可移植,因为寄存器是由硬件直接提供的,与硬件绑定的,因而基于寄存器的指令集不可避免的收到硬件的制约,而基于栈的指令集则与硬件无关,因而具有灵活的可移植性。
当然,基于栈的指令集也有缺点::它理论上执行速度相对于基于栈的指令集会稍慢一些。同时栈架构的指令集虽然代码紧凑,但完成相同功能所需要的指令数量较之于基于寄存器的指令集会来的更多。
基于栈的解释器执行过程
前边铺垫了辣么多,实际上现在才真正进入正题 😂😂。
同样的,我们借助于一个简单的例子,来解释方法执行的全过程:
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
2: istore_1
3: sipush 200
6: istore_2
7: sipush 300
10: istore_3
11: iload_1
12: iload_2
13: iadd
14: iload_3
15: imul
16: ireturn
LineNumberTable:
line 22: 0
line 23: 3
line 24: 7
line 25: 11
从这段字节码中可以看到,这段代码需要深度为 2 的操作数栈(stack = 2)和 4 个变量槽的局部变量空间(locals = 4)。整个代码的执行过程如下所示:
首先,执行偏移地址为 0 的指令,bipush 100
,其中 bipush 指令的作用是将单字节的整形常量值(-128~127)推入操作数栈顶,此处跟随一个参数 100,证明推送的常量值是 100。执行完成后结果如下图所示:
执行偏移量为 2 的指令,istore_1 指令作用就是将操作数栈的整形值出栈并存放在第 1 个局部变量槽中,后续四条指令(3,6,7,10)做的事情与前两条指令做的事情类似。此时不再详述。第 2 条指令,执行完成之后内存情况如下图所示:
执行偏移地址为 11 的执行,iload_1
指令的作用是将局部变量的第 1 个变量槽中的整形值复制到操作数栈顶,执行完成之后结果如下图所示:
执行偏移地址为 12 的指令,iload_2 指令执行过程与 iload_1 类似,把变量槽第 2 个变量入栈。此处不再详述。
执行偏移地址为 13 的指令,iadd 指令的作用是将操作数栈中头两个栈顶元素出栈,做整数加法操作之后,结果重新入栈。在 iadd 指令,执行完成之后,栈中原有的 100 和 200 被出栈,它们的和 300 被重新入栈。
执行偏移地址为 14 的指令,iload_3 指令把存放在第 3 个局部变量槽中的 300 入栈到操作数栈中。这时操作数栈为两个整数 300。下一条指令 imul 是将操作数栈中前两个数出栈,做整形乘法操作后,再重新入栈。执行结果如下:
执行偏移地址为 16 的指令,ireturn 指令是方法返回指令之一,它将结束方法执行并将操作数栈栈顶的整形值返回给给方法的调用者。
至此,方法调用完成,但需要注意的是,上边执行过程实际上仅仅是一个概念模型,虚拟机最终会对执行过程做出一系列优化来提高性能,实际运行过程并不会完全按照概念模型的描述来。
总结
Java 虚拟机在执行一个方法时,首先需要对该方法分配空间,也就是对应的一个栈帧。然后需要确定实际要执行方法的版本,可以有解析和分派两种方法去做选择,确定完方法版本之后,Java 虚拟机在实际执行对应方法时会基于栈解析器来进行执行(此处也简单讲了基于栈指令集和基于寄存器指令的区别),执行完成后返回对应结果。这个方法的执行过程完成。
参考
- 《深入理解 JVM 虚拟机》
- What is the point of invokeinterface?
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于