一、概述
在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。但从外观上看起来,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
二、运行时的栈帧结构
栈帧(Stack Frame) 是用于支持虚拟机方法调用和方法执行的数据结构,它是虚拟机运行时数据区中虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
对于执行引擎来说,在活动线程中,只有位与栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
栈帧概念结构如下图所示:
2.1 局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。
在Java程序编译为Class文件时,就在方法的
Code
属性的max_locals
数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Slot)为最小单位,JAVA虚拟机规范中并没有指明一个Slot应占用的内存空间大小,只是说明一个Slot都应该能存放一个32位以内的数据类型,有boolean、byte、char、short、int、float、reference(长度与实际使用的是32位还是64位虚拟机有关)和returnAddress类型的数据。
returnAddress
类型是为字节码指令jsr、jsr_w和ret服务的,它指向了一条字节码指令的地址,很古老的Java虚拟机曾经使用这几条指令来实现异常处理,现在已经由异常表代替。
对于64位的数据类型(Java语言中明确的64位数据类型只有long和double),虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。
long
和double
是java语言中明确规定的64位数据类型,虚拟机会为其分配两个连续的Slot空间。
虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始至局部变量表最大的Slot数量。访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型,就代表会同时使用n和n+1这两个Slot。
虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果是实例方法(非static),那么局部变量表的第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中通过this
访问。其余参数则按照参数表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕之后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
为了节省栈帧空间,局部变量Slot可以重用,方法体中定义的变量,其作用域并不一定会覆盖整个方法体。如果当前字节码PC计数器的值超出了某个变量的作用域,那么这个变量的Slot就可以交给其他变量使用。这样的设计会带来一些额外的副作用,比如:在某些情况下,Slot的复用会直接影响到系统的收集行为。
2.1.1 实例代码清单
1. 演示Slot
public int slot(int i,int j)
{
double d = 5.23;
int result;
result = i + j;
return result;
}
public int slot(int, int);
Code:
Stack=2, Locals=6, Args_size=3
0: ldc2_w #16; //double 5.23d
3: dstore_3
4: iload_1
5: iload_2
6: iadd
7: istore 5
9: iload 5
11: ireturn
LineNumberTable:
line 6: 0
line 8: 4
line 9: 9
LocalVariableTable:
Start Length Slot Name Signature
0 12 0 this Ljvm/executionengine/stackframe/StackFrame; //this占用slot0
0 12 1 i I //int型 i 占用slot1
0 12 2 j I //int型 j 占用slot2
4 8 3 d D // double型 d 占用slot3 slot4
9 3 5 result I //int型 result 占用slot5
2. slot可重用的例子
public void slotReuse()
{
{
int m = 100;
}
int n = 200;
}
public void slotReuse();
Code:
Stack=1, Locals=2, Args_size=1 //变量个数3个(this,m,n),但是局部变量表大小为2
0: bipush 100
2: istore_1 //把m的值100放入下标1的slot中
3: sipush 200
6: istore_1 //把n的值200放入下标1的slot中,超出了m作用域,复用了
7: return
LineNumberTable:
line 7: 0
line 9: 3
line 10: 7
LocalVariableTable:
Start Length Slot Name Signature
0 8 0 this Ljvm/executionengine/stackframe/StackFrame;
7 1 1 n I
3. slot复用影响到垃圾回收
/**
* 三段代码加上虚拟机的运行参数“-verbose:gc”来观察垃圾收集的过程
*/
/*代码一:gc的时候,变量placeHolder还处于作用域,没有回收掉*/
public static void main(String[] args)
{
byte[] placeHolder = new byte[64*1024*1024];
System.gc();
}
/*gc过程:
[GC 66167K->65824K(120576K), 0.0012810 secs]
[Full GC 65824K->65690K(120576K), 0.0060200 secs]
*/
/*代码二:gc的时候,变量placeHolder处于作用域之外了,但是还是没有回收掉*/
public static void main(String[] args)
{
{
byte[] placeHolder = new byte[64*1024*1024];
}
System.gc();
}
/*gc过程:
[GC 66167K->65760K(120576K), 0.0013770 secs]
[Full GC 65760K->65690K(120576K), 0.0055290 secs]
*/
/*代码三:gc的时候,变量placeHolder处于作用域之外了,通过一个新的变量i赋值,复用掉placeHolder原先的slot,垃圾回收了*/
public static void main(String[] args) {
{
byte[] placeHolder = new byte[64*1024*1024];
}
int i = 0;
System.gc();
}
/*gc过程:
[GC 66167K->65760K(120576K), 0.0017850 secs]
[Full GC 65760K->154K(120576K), 0.0064340 secs]
*/
上述代码中, placeHolder能否被回收的根本原因就是:局部变量表中的Slot是否还存在关于placeHolder数组对象的引用。代码二中虽然已经离开了placeHolder的作用域,但是此后没有局部变量表的读写操作,placeHolder原本占用的Slot还没有被其他变量复用,所以在GC的时候仍然保留有对数组对象的引用。代码三中,认为的定义的一个局部变量i,并且赋值以达到复用刚才placeHolder的Slot,消除了对数组对象的引用,然后GC就可以回收掉了。
2.1.2 局部变量和类变量(用static修饰的变量)的不同
类变量有两次赋初始值的过程:准备阶段(赋予系统初始值)和初始化阶段(赋予程序员定义的初始值)。所以即使在初始化阶段没有为类变量赋值也没关系,它仍然有一个确定的初始值。但局部变量不一样,如果定义了,但没有赋初始值,是不能使用的。
如下代码清单,这段代码是不能运行的
public static void main(String[] args)
{
int a;
System.out.println(a);
}
2.2 操作数栈
操作数栈(Operand Stack) 也常称为操作栈,它是一个后入先出栈。同局部变量表一样,操作数的最大深度在编译的时候就确定了,写在了方法的Code属性的max_stacks数据项中。当一个方法执行开始时,这个方法的操作数栈是空的,在方法执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是 出栈/入栈操作。
在概念模型中,一个活动线程中两个栈帧是相互独立的。但大多数虚拟机实现都会做一些优化处理:让下一个栈帧的部分操作数栈与上一个栈帧的部分局部变量表重叠在一起,这样的好处是方法调用时可以共享一部分数据,而无须进行额外的参数复制传递。
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。
2.3 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。我们知道字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。
- 静态解析:符号引用在类加载阶段或者第一次使用的时候化为直接引用
- 动态连接:符号引用将在每一次运行期间转化为直接引用。
2.4 方法返回地址
当一个方法被执行后,有两种方式退出这个方法:
1. 执行引擎遇到任意方法返回的字节码指令
这时可能会返回值传递给上层的方法调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令决定。这种退出方式为 正常完成出口。
2. 遇到异常并且没有在方法体内得到处理
无论是Java虚拟机内部产生异常还是使用athrow
字节码指令产生异常,只要在本方法的异常表中没有搜到匹配的异常处理器,就会导致方法退出,这种退出方式是不会给它的上层调用者产生任何返回值的。这种退出方式为 异常完成出口。
注意:这种退出方式不会给上层调用者产生任何返回值。
无论采用何种退出方式,在方法退出后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
2.5 附加信息
虚拟机规范允许虚拟机实现向栈帧中添加一些自定义的附加信息,例如与调试相关的信息等。
三、方法调用
方法调用阶段的目的:确定被调用方法的版本(哪一个方法),不涉及方法内部的具体运行过程,在程序运行时,进行方法调用是最普遍、最频繁的操作。
一切方法调用在Class文件里存储的都只是符号引用,这是需要在类加载期间或者是运行期间,才能确定为方法在实际 运行时内存布局中的入口地址(相当于之前说的直接引用)。
这个特点给Java带来了更强大的动态扩展能力,但是也带来了复杂,需要在类加载甚至运行期间才能确定目标方法的直接引用。
3.1 解析
在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,不会延迟到运行期去完成,这种方法的调用称为 解析(Resolution)。
解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且在运行期间不可变。
“编译期可知,运行期不可变”的方法:
- 静态方法:与类型直接关联
- 私有方法:在外部不可访问
这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
在Java虚拟机中提供了5条方法调用字节码指令:
invokestatic
: 调用静态方法invokespecial
:调用实例构造器< init >方法、私有方法、父类方法invokevirtual
:调用所有的虚方法invokeinterface
:调用接口方法,会在运行时再确定一个实现此接口的对象invokedynamic
:先在运行时动态解析出点限定符所引用的方法,然后再执行该方法,在此之前的4条调用命令的分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
只要能被 invokestatic
和 invokespecial
指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器和父类方法。这些方法称为 非虚方法,与之相反,其他方法称为 虚方法(除去final方法,final方法也是可以在解析阶段确定唯一调用版本)。
虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。
解析调用是一个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派,因此分派有四情况:静态单分派、静态多分派、动态单分派、动态多分派。
3.2 分派
分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在Java虚拟中是如何实现的。
3.2.1静态分派
所有依赖 静态类型 来定位方法执行版本的分派动作,都称为静态分派。静态分派发生在编译阶段。
静态分派最典型的应用就是 方法重载。
package org.fenixsoft.polymorphic;
/**
* 方法静态分派演示
* @author zzm
*/
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);
}
}
运行结果:
hello,guy!
hello,guy!
为什么会出现这样的结果呢?
Human man = new Man();
其中的Human
称为变量的 静态类型(Static Type),Man称为变量的 实际类型(Actual Type)。
两者的区别:静态类型在编译期可知,而实际类型到运行期才确定下来。
在重载时通过参数的静态类型而不是实际类型作为判定依据,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。所以选择了sayhello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。
3.2.2 动态分派
在运行期根据 实际类型 确定方法执行版本的分派过程称为动态分派。最典型的应用就是 方法重写。
public class DynamicDispatch {
static abstract class Human{
protected abstract void sayHello();
}
static class Man extends Human{
protected void sayHello(){
System.out.println("man say hello");
}
}
static class Woman extends Human{
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 say hello
woman say hello
public static void main(java.lang.String[]);
Code:
Stack=2, Locals=3, Args_size=1
0: new #16; //class jvm/executionengine/stackframe/DynamicDispatch$Man
3: dup
4: invokespecial #18; //Method jvm/executionengine/stackframe/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #19; //class jvm/executionengine/stackframe/DynamicDispatch$Women
11: dup
12: invokespecial #21; //Method jvm/executionengine/stackframe/DynamicDispatch$Women."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #22; //Method jvm/executionengine/stackframe/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #22; //Method jvm/executionengine/stackframe/DynamicDispatch$Human.sayHello:()V
24: return
LineNumberTable:
line 19: 0
line 20: 8
line 21: 16
line 22: 20
line 23: 24
LocalVariableTable:
Start Length Slot Name Signature
0 25 0 args [Ljava/lang/String;
8 17 1 man Ljvm/executionengine/stackframe/DynamicDispatch$Human;
16 9 2 women Ljvm/executionengine/stackframe/DynamicDispatch$Human;
字节码中0-15的字节码都是new了两个实例,并将实例放入第一个和第二个Slot中,接下来16-17是将Slot1中值压入栈(man实例的引用),并且调用方法,20-21是将Slot2中值压入栈(women实例的引用),并且调用方法。单从17和21两行来看,调用方法符号引用一模一样,但是这两条指令最终执行的目标方法确实不同。原因就跟invokevirtual指令的多态查找过程有关了,
invokevirtual指令的运行时解析过程大致分为以下步骤:
- 找到操作数栈顶的第一个元素所指对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,通过就直接引用,结束;不通过就返回
java.lang.IllegalAccessError
异常。 - 否则,按照继承关系从下往上对C的各个父类重复第2步的搜索和校验。
- 如果始终没找到合适的方法,则抛
java.lang.AbstractMethodError
异常。
从上面invokevirtual指令的运行时解析过程不难看出,代码中man和woman会找到实际类型中的方法调用。这个过程反映了java语言中方法重写的本质。
3.2.3 单分派和多分派
方法的接收者与方法的参数统称为方法的 宗量。根据分派基于多少种宗量,可以将分派分为 单分派和 多分派。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
/**
* 单分派、多分派演示
* @author zzm
*/
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());
}
}
1.Java在编译阶段进行静态分派时,选择目标方法要依据两点:
- 变量的静态类型是哪个类型
- 方法参数是什么类型
这次选择的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)
及Father.hardChoice(QQ)
方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于 多分派类型。
2.运行时阶段的动态分派过程
由于编译器已经确定了目标方法的签名(包括方法参数),运行时虚拟机只需要确定方法的接收者的实际类型,就可以分派。因为是根据一个宗量作为选择依据,所以Java语言的动态分派属于 单分派类型。
3.2.4 虚拟机动态分派的实现
由于动态分派是非常频繁的动作,而动态分派在方法版本选择过程中又需要在方法元数据中搜索合适的目标方法,虚拟机实现出于性能的考虑,通常不直接进行如此频繁的搜索,而是采用优化方法。
其中一种“稳定优化”手段是:在类的方法区中建立一个虚方法表(Virtual Method Table, 也称vtable, 与此对应,也存在接口方法表——Interface Method Table,也称itable)。使用虚方法表索引来代替元数据查找以提高性能。其原理与C++的虚函数表类似。
虚方法表中存放的是各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类中该方法相同,都指向父类的实现入口。如果子类重写了这个方法,那么子类虚方法表中地址将会被替换成指向子类实现版本的入口地址。虚方法表一般在类加载的连接阶段进行初始化。
上图中Son重写了来自Father的全部方法,因此Son方法表中这些方法的实际入口地址都指向了Son类型数据的方法。Son和Father都没有重写Object中的方法,所以方法表中的实际入口地址都指向了Object数据类型。
3.3 动态类型语言的支持
【呃。。。。本小节过于繁杂,难以理解,不再详细记录,可参考:JVM:方法调用之动态类型语言支持】
JDK新增加了invokedynamic指令来是实现“动态类型语言”。
静态语言和动态语言的区别:
-
静态语言(强类型语言):
静态语言是在编译时变量的数据类型即可确定的语言,多数静态类型语言要求在使用变量之前必须声明数据类型。
例如:C++、Java、Delphi、C#等。 -
动态语言(弱类型语言) :
动态语言是在运行时确定数据类型的语言。变量使用之前不需要类型声明,通常变量的类型是被赋值的那个值的类型。
例如PHP/ASP/Ruby/Python/Perl/ABAP/SQL/JavaScript/Unix Shell等等。 -
强类型定义语言 :
强制数据类型定义的语言。也就是说,一旦一个变量被指定了某个数据类型,如果不经过强制转换,那么它就永远是这个数据类型了。举个例子:如果你定义了一个整型变量a,那么程序根本不可能将a当作字符串类型处理。强类型定义语言是类型安全的语言。 -
弱类型定义语言 :
数据类型可以被忽略的语言。它与强类型定义语言相反, 一个变量可以赋不同数据类型的值。强类型定义语言在速度上可能略逊色于弱类型定义语言,但是强类型定义语言带来的严谨性能够有效的避免许多错误。
四、基于栈的字节码解释执行引擎
虚拟机如何调用方法的内容已经讲解完毕,现在我们来探讨虚拟机是如何执行方法中的字节码指令。
4.1 解释执行
Java语言经常被人们定位为 “解释执行”语言,在Java初生的JDK1.0时代,这种定义还比较准确的,但当主流的虚拟机中都包含了即时编译后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。再后来,Java也发展出来了直接生成本地代码的编译器[如GCJ(GNU Compiler for the Java)],而C/C++也出现了通过解释器执行的版本(如CINT),这时候再笼统的说“解释执行”,对于整个Java语言来说就成了几乎没有任何意义的概念,只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。
Java语言中,javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程,因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机内部,所以Java程序的编译就是半独立实现的,
4.2 基于栈的指令集和基于寄存器的指令集
Java编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,依赖操作数栈进行工作。与之相对应的另一套常用的指令集架构是基于寄存器的指令集, 依赖寄存器进行工作。
那么,基于栈的指令集和基于寄存器的指令集这两者有什么不同呢?
举个简单例子,分别使用这两种指令计算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寄存器里面。
基于栈的指令集主要的优点就是 可移植,寄存器是由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。
栈架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数),编译器实现更加简单(不需要考虑空间分配问题,所需空间都在栈上操作)等。
栈架构指令集的主要缺点是执行速度相对来说会稍微慢一些。
虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。
4.3 基于栈的解释器执行过程
public int calc()
{
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
public int clac();
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
javap提示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间,以下图描述执行过程中代码、操作数栈和局部变量表的变化情况:
第一步:将100入栈。
第二步:将操作栈中的100出栈并存放到局部变量中。后面的200,300同理。
第三步:将局部变量表中的100复制到操作数栈顶。
第四步:将局部变量表中的200复制到操作数栈顶。
第五步:将100和200出栈,做整型加法,最后将结果300重新入栈。
第六步:将第三个数300从局部变量表复制到栈顶。接下来就是将两个300出栈,进行整型乘法,将最后的结果90000入栈。
第七步:方法结束,将操作数栈顶的整型值返回给此方法的调用者。