Java堆栈jvm为每个新创建的线程都分配一个堆栈。堆栈以帧为单位保存线程的状态。jvm对堆栈只进行两种操作:以帧为单位的压栈和出栈操作。
某个线程正在执行的方法称为此线程的当前方法。当前方法使用的帧称为当前帧。当前方法所属的类称为当前类。当前类的常量池称为当前常量池。当线程执行一个方法时,它会跟踪当前的类和常量池。当jvm会在当前帧内执行帧内数据的操作。
当线程激活一个java方法,jvm就会在线程的java堆栈里新压入一个帧。这个帧自然成为了当前帧。在此方法执行期间,这个帧将用来保存参数,局部变量,中间计算过程和其他数据。
一个方法可以以两种方法结束。一种是正常返回结束。一种是通过异常抛出而异常结束(abrupt completion)。不管以那种方式返回,jvm都会将当前帧弹出堆栈然后释放掉,这样上一个方法的帧就成为当前帧了。(译者:可能可以这样理解,位于堆栈顶部的帧为当前帧)
java堆栈上的所有数据都为此线程私有。一个线程不能访问另一个线程的堆栈数据,所以在多线程的情况下也不需要对堆栈数据的访问进行同步。
象方法区和堆一样(见以前的译文),java堆栈和帧在内存中也不必是连续的。帧可以分布在连续的内存区,也可以不是。帧的数据结构由jvm的实现者来决定,他们可以允许用户指定java堆栈的初始大小或最大最小尺寸。
堆栈帧( The Stack Frame)堆栈帧有三部分:局部变量区,操作数堆栈和帧数据区。局部变量区和操作数堆栈的大小要视对应的方法而定。编译器在编译的时候就对每个方法进行了计算并放在了类文件(class file)中了。帧数据区的大小对一种jvm实现来说是一定的。当jvm激活一个方法时,它从类信息数据得到此方法的局部变量区和操作数堆栈的大小,并据此分配大小合适堆栈帧压入java堆栈中。
局部变量区java堆栈帧的局部变量区是一个基为零类型为word的数组。指令通过索引来使用这些数据。类型为int,float,reference和returnAddress的值在数组中占据一项,类型为byte,short,和char的值在存入数组前都转为了int值而占据一项。类型为long和double的值在数组中占据连续的两项,在访问他们的时候,指令提供第一项的索引。例如一个long值占据3,4项,指令会取索引为3的long值。局部变量区的所有值都是字对齐的,long和doubles的起始索引值没有限定。
局部变量区包含此方法的参数和局部变量。编译器首先以声明的顺序把参数放入局部数据区。图5-9显示了下面两个方法的变量区。// On CD-ROM in file jvm/ex3/Example3a.javaclass Example3a {
public static int runClassMethod(int i, long l, float f, double d, Object o, byte b) {
return 0; }
public int runInstanceMethod(char c, double d, short s, boolean b) {
return 0; }}
图5-9. 局部变量区中的方法参数
注意在方法runInstanceMethod()的帧中,第一个参数是一个类型为reference的值,尽管方法没有显示的声明这个参数,但这是个对每个实例方法(instance method)都隐含加入的一个参数值,用来代表调用的对象。(译者:与c++中的this指针一样)我们看方法runClassMethod()就没有这个变量,这是因为这是一个类方法(class method),类方法与类相关,而不与对象相关。
我们注意到在源码中的byte,short,char和boolean在局部变量区都成了ints。在操作数堆栈也是同样的情况。如前所述,jvm不直接支持boolean类型,java编译器总是用ints来表示boolean。但java对byte,short和char是支持的,这些类型的值可以作为实例变量存储在局部变量区中,也可以作为类变量存储在方法区中。但在局部变量区和操作数堆栈中都被转成了ints类型的值,期间的运算也是以int来的,只当存回堆或方法区中,才会转回原来的类型。
同样需要注意的是runClassMethod()的对象o。在java中,所以的对象都以引用(reference)传递。所有的对象都存储在堆中,你永远都不会在局部变量区或操作数堆栈中发现对象的拷贝,只会有对象引用。
编译器对局部变量的放置方法可以多种多样,它可以任意决定放置顺序,甚至可以用一个索引指代两个局部变量。例如,当两个局部变量的作用域不重叠时,如Example3b的局部变量i和j。
// On CD-ROM in file jvm/ex3/Example3b.javaclass Example3b {
public static void runtwoLoops() {
for (int i = 0; i < 10; ++i) { System.out.println(i); }
for (int j = 9; j >= 0; --j) { System.out.println(j); } }}
jvm的实现者对局部变量区的设计仍然有象其他数据区一样的灵活性。关于long和double数据如何分布在数组中,jvm规范没有指定。假如一个jvm实现的字长为64位,可以把long或double数据放在数组中的低项内,而使高项为空。(在字长为32位的时候,需要两项才能放下一个long或double)。
操作数堆栈操作数堆栈象局部变量区一样是用一个类型为word的数组存储数据,但它不是通过索引来访问的,而是以堆栈的方式压入和弹出。假如一个指令压入了一个值,另一个指令就可以弹出这个值并使用之。
jvm在操作数堆栈中的处理数据类型的方式和局部变量区是一样的,同样有数据类型的转换。jvm没有寄存器,jvm是基于堆栈的而不是基于寄存器的,因为jvm的指令从堆栈中获得操作数,而不是寄存器。虽然操作数还可以从另外一些地方获得,如字节码中,或常量池内,但主要是从堆栈获得的。
jvm把操作数堆栈当作一个工作区使用。许多指令从此堆栈中弹出数据,进行运算,然后压入结果。例如,iadd指令从堆栈中弹出两个数,相加,然后压入结果。下面显示了jvm是如何进行这项操作的:iload_0 // push the int in local variable 0iload_1 // push the int in local variable 1iadd // pop two ints, add them, push resultistore_2 // pop int, store into local variable 2
在这个字节码的序列里,前两个指令iload_0和iload_1将存储在局部变量区中索引为0和1的整数压入操作数据区中,然后相加,将结果压入操作数据区中。第四条指令istore_2从操作数据区中弹出结果并存储到局部数据区索引为2的地方。在图5-10中,详细的表述了这个过程,图中,没有使用的区域以空白表示。
图5-10. 两个局部变量的相加.
帧数据区除了局部变量区和操作数据堆栈外,java栈帧还需要数据来支持常量池解析(constant pool resolution),方法的正常返回(normal method return)和异常分派(exception dispatch)。这些信息保存在帧数据区中。
jvm中的许多指令都涉及到常量池的数据。一些指令仅仅是取出常量池中的数据并压入操作数堆栈中。一些指令使用常量池中的数据来指示需要实例化的类或数组,需要访问的域,或需要激活的方法。还有一些指令来判断某个对象是否是常量池指定的某个类或接口的子孙实例。
每当jvm要执行需要常量区数据的指令,它都会通过帧数据区中指向常量区的指针来访问常量区。以前讲过,常量区中对类型,域和方法的引用在开始时都是符号。如果当指令执行的时候仍然是符号,jvm就会进行解析。
除了常量区解析外,帧数据区还要帮助jvm处理方法的正常和异常结束。正常结束,jvm必须恢复方法调用者的环境,包括恢复pc指针。假如方法有返回值,jvm必须将值压入调用者的操作数堆栈。
为了处理方法的异常退出,帧数据区必须保存对此方法异常表的引用。一个异常表定义了这个方法受catch子句保护的区域,每项都有一个catch子句的起始和开始位置(position),和用来表示异常类在常量池中的索引,以及catch子句代码的起始位置。
当一个方法抛出异常时,jvm使用帧数组区指定的异常表来决定如何处理。如果找到了匹配的catch子句,就会转交控制权。如果没有发现,方法会立即结束。jvm使用帧数据区的信息恢复调用者的帧,然后重新抛出同样的异常。
除了上述信息外,jvm的实现者也可以将其他信息放入帧数据区,如调试数据。
java堆栈的一种实现实现者可以按自己的想法设计java堆栈。如以前所讲,一个方法是从堆中单独的分配帧。我以此为例,看下面的类:// On CD-ROM in file jvm/ex3/Example3c.javaclass Example3c {
public static void addAndPrint() { double result = addTwoTypes(1, 88.88); System.out.println(result); }
public static double addTwoTypes(int i, double d) { return i + d; }}
图5-11显示了一个线程执行这个方法的三个快照。在这个jvm的实现中,每个帧都单独的从堆中分配。为了激活方法addTwoTypes(),方法addAndPrint()首先压入int 1和double88.88到操作数堆栈中,然后激活addTwoTypes()方法。
图5-11. 帧的分配
激活addTwoTypes()的指令使用了常量池的数据,jvm在常量池中查找这些数据如果有必要则解析之。
注意addAndPrint()方法使用常量池引用方法addTwoTypes(),尽管这两个方法是属于一个类的。象引用其他类一样,对同一个类的方法和域的引用在初始的时候也是符号,在使用之前需要解析。
解析后的常量池数据项将指向存储在方法区中有关方法addTwoTypes()的信息。jvm将使用这些信息决定方法addTwoTypes()局部变量区和操作数堆栈的大小。如果使用Sun的javac编译器(JDK1.1)的话,方法addTwoTypes()的局部变量区需要三个words,操作数堆栈需要四个words。(帧数据区的大小对某个jvm实现来说是定的)jvm为这个方法分配了足够大小的一个堆栈帧。然后从方法addAndPrint()的操作数堆栈中弹出double参数和int参数(88.88和 1)并把他们分别放在了方法addTwoType()的局部变量区索引为1和0的地方。
当addTwoTypes()返回时,它首先把类型为double的返回值(这里是89.88)压入自己的操作数堆栈里。jvm使用帧数据区中的信息找到调用者(为addAndPrint())的堆栈帧,然后将返回值压入addAndPrint()的操作数堆栈中并释放方法addTwoType()的堆栈帧。然后jvm使addTwoType()的堆栈帧为当前帧并继续执行方法addAndPrint()。
图5-12显示了相同的方法在不同的jvm实现里的执行情况。这里的堆栈帧是在一个连续的空间里的。这种方法允许相邻方法的堆栈帧可以重叠。这里调用者的操作数堆栈就成了被调者的局部变量区。
图5-12. 从一个连续的堆栈中分配帧
这种方法不仅节省了空间,而且节省了时间,因为jvm不必把参数从一个堆栈帧拷贝到另一个堆栈帧中了。
注意当前帧的操作数堆栈总是在java堆栈的顶部。尽管这样可能可以更好的说明图5-12的实现。但不管java堆栈是如何实现的,对操作数堆栈的操作总是在当前帧执行的。这样,在当前帧的操作数堆栈压入一个数也就是在java堆栈压入一个值。
java堆栈还有一些其他的实现,基本上是上述两种的结合。一个jvm可以在线程初期时从堆栈分出一段空间。在这段连续的空间里,jvm可以采用5-12的重叠方法。但在与其他段空间的结合上,就要使用如图5-11的方法。