程序计数器

程序中每个线程都有自己的程序计数器,在线程启动时创建。程序计数器的大小为一个word,所以它可以持有一个native指针或者一个returnAddress。当一个线程执行一个java方法时,程序计数器包含当前执行指令的地址,一个“地址”可以是native指针或者是一个从方法字节码开头的偏移量。如果一个线程执行一个native方法,程序计数器的值为undefined。


Java栈

当一个线程启动时,java虚拟机为这个线程创建一个新的Java栈。虚拟机只在java栈中直接执行两个操作,push帧和pop帧。

一个线程当前执行的方法叫做线程的当前方法,当前方法的栈帧称为当前帧,定义当前方法的类称为当前类,当前类的常量池称为当前常量池。当java虚拟机执行一个方法时,它记录了当前类和当前常量池。当虚拟机遇到了要操作存储在栈帧里的数据的指令,会当前帧里执行这些操作。

当一个线程调用一个java方法时,虚拟机创建并push一个新的帧到线程的java栈中。这个新的帧成为了当前帧。随着方法的执行,它使用这个帧来存储参数,本地变量,中间计算值和其他数据。

一个方法可以以两种方式结束。如果一个通过return结束,称为正常结束,如果方法通过抛出异常结束,则称为突然结束。当一个方法结束时,无论是正常还是突然,java虚拟机都会pop出这个方法的栈帧并将其销毁。前一个方法的栈帧就称为了当前帧。

java栈中的所有数据都是线程似有的。一个线程无法访问或者改变另一个线程的java栈,因此,你无需担心多线程访问本地变量的同步问题。当一个线程调用一个方法时,方法的本地变量被存储在调用线程java栈的帧中,只有这个线程可以访问这些本地变量。


栈帧

栈帧由三部分组成:本地变量,操作数栈和帧数据。本地变量和操作数栈的大小以word来测量。它们的大小是在编译期确定的,并且包含在每个方法的class文件数据中。当java虚拟机调用一个java方法时,它会检查类数据决定本地变量和操作数栈的words数量,然后为该方法创建一个合适大小的栈帧,并将其push到java栈中。


本地变量

本地变量部分是java帧中以以0开始的word数组形式组织的。使用本地变量值的指令提供一个索引给这个数组。类型为int,float,reference和returnAddress的值占用本地变量数组的一个条目,类型byte,short和char的值在存储到本地变量前被转换成int类型,类型long和double的值占用数组中连续的两个条目。

要引用一个long或者double的本地变量,指令提供的是第一个连续条目的索引。例如,如果一个long占据数组的条目3和4,则指令通过索引3来引用这个long。本地变量部分包含方法参数和本地变量,编译器先把参数放到本地变量数组中,按照它们的声明顺序放置。
class 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;
    }
}
下图描述了上面两个方法的本地变量部分:

上图runInstanceMethod()方法的本地变量的第一个参数是引用类型,即使在源代码中没有这样的参数。这是传递给每个实例方法的隐藏this引用,实例方法使用这个引用来访问对象的实例数据。在runClassMethod()方法中可以看到,类方法不会接收隐藏的this,类方法不是对象调用的。你不能够从类方法中直接访问一个类实例变量,因为类方法调用不与实例关联。

还要注意源代码中的byte,short,char和boolean类型在本地变量中变成了int型,这也同样发生在操作栈中。如前所述,java虚拟机不直接支持boolena类型,java编译器在本地变量或者操作数栈中用int来表示boolean的值。byte,short和char类型在java虚拟机中是支持的,这些值可以在堆中以实例变量或者数组元素的形式存储,或者以类变量的行处存储在方法区中。然而,当把它们放到本地变量或者操作数栈时,byte,short和char类型的值被转换成int。它们在栈帧中以int类型被操作,当把它们存储堆或者方法区时,又把它们转换回byte,short和char。

同样需要注意的是传递给runClassMethod()方法的Object o是一个引用,在java中,所有对象都是通过引用来传递。因为所有对象都是存储在堆中,你永远不会在本地变量或者操作数栈中找到一个对象,只有对象的引用。

除了方法的参数需要按照声明的顺序被编译器放置到本地变量中,java编译器可以随意地排列本地变量数组,它可以把方法的本地变量以任意的顺序放置到本地变量数组中,它们还能够使用同个数组条目来存放多个本地变量。例如,如果两个本地变量具有有限的且不会重叠的范围,如下面的代码,对本地变量i和j,在方法的前半部分,数组条目0存储的是i,而在方法的后半部分,i离开了它的范围,条目0则用于存储的是j。
class 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);
        }
    }
}


操作数栈

跟本地变量一样,操作数栈是以word数组的形式组织的。但跟本地变量不同的是,它不是通过数组索引来访问,操作数栈是通过push和pop值来访问的。如果一条指令push了一个值到操作数栈中,稍后的一条指令可以pop出这个值并使用它。

虚拟机在操作数栈中存储的数据类型跟在本地变量上存储的数据类型是一样的:int,long,float,reference和returnType。byte,short和char类型的值会在push到操作栈之前被转化成int类型。

Java虚拟机是基于栈的而不是基于寄存器的,因为它的指令从操作数栈中获取操作数,并不是从寄存器中获取。指令也可以从其他地方获取操作数,例如直接跟随在字节码流的操作码(代表指令的字节)后面,或者从常量池中获取,但是java虚拟机指令集主要的关注焦点是在操作数栈上。

Java虚拟机使用操作数栈作为一个工作空间,很多指令从操作数栈中pop值,对他们进行操作,然后将结果push回去。例如,iadd指令通过pop出操作数栈顶上的两个int,将它们相加,然后将结果push回去。下面是java虚拟机如何将两个包含int的本地变量相加,然后将int结果存储到第三个本地变量里:
iload_0    // push the int in local variable 0
iload_1    // push the int in local variable 1
iadd       // pop two ints, add them, push result
istore_2   // pop int, store into local variable 2
在这串字节码序列中,前两个指令iload_0和iload_1将存储在本地变量位置0和1的两个int值push到操作数栈中。iadd指令从操作数栈中pop出这两个int值,将它们相加,然后将int结果push回操作数栈中。第四个指令istore_2,将操作数栈顶上增加的结果pop出来,并将其存储到本地变量的位置2中。下图是这个过程中,本地变量和操作数栈的状态:


帧数据

除了本地变量和操作数栈,java虚拟机栈帧还包含支持常量池解析,正常方法返回和异常分发的数据。这些数据存储在java栈帧中的帧数据部分。

java虚拟机指令集中的很多指令都涉及到常量池中的条目,一些指令仅仅是把常量池中的int,long,float,double或者String类型的常量值push到操作数栈中,还有一些指令使用常量池的条目来引用类或者数组实例,域访问或者方法调用。其他指令决定一个特定的 对象是否属于常量池条目指定的特定类的子类或者接口。

当java虚拟机遇到任何与常量池条目相关的指令时,它会使用帧数据中指向常量池的指针来访问这些信息。如前所述,常量池中类型,域和方法的引用最初只是符号的引用,虚拟机必须对这些引用进行解析。

除了常量池解析以外,帧数据必须协助虚拟机处理正常的和突然的方法结束。如果一个方法正常的结束,虚拟机必须恢复到调用方法的栈帧,然后设置程序计数器指向调用方法中的下一条指令。如果完成的方法返回了一个值,虚拟机需要将这个值push到调用方法的操作数栈中。

帧数据还需要包含方法异常表的引用,供虚拟机处理方法执行期间抛出的异常。“异常”定义了在一个方法的字节码中由catch语句保护的范围,异常表的每一个条目定义了一个catch语句保护区域的开始和结束位置,一个给予被获取异常类的常量池的索引,和catch语句代码的开始位置。

当方法抛出一个异常时,虚拟机使用由帧数据引用的异常表来决定如何处理这个异常。如果虚拟机在方法的异常表中找到了一个匹配的catch语句,就会把控制权移交到catch语句的起始位置。如果虚拟机没有找到匹配的catch语句,方法就会突然结束。虚拟机使用在帧数据中的信息将调用方法的帧恢复,然后在调用方法的上下文中重新抛出同一个异常。

除了支持常量池解析,正常方法返回和异常分发的数据外,栈帧可能还包含支持调试的数据。


java栈的实现

实现的设计者可以以他们想要的方式来表示java栈,一种可能的java栈的实现是在堆中为每个帧独立分配内存,以下面的类将作为例子来讲解这种方法。
class 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;
    }
}
下图描绘了线程调用addAndPrint()方法时的java栈快照,每个帧都是从堆中独立分配内存的。为了调用addTwoTypes()方法,addAndPrint()方法首先将值为1的int和值为88.88的double push到它的操作栈中,然后调用addTwoTypes()方法。

调用addTwoTypes()的指令引用了一个常量池条目,java虚拟机寻找这个条目并按需要来解析它。注意addAndPrint()方法使用常量池来辨识addTwoTypes()方法,尽管他们属于同一个类,跟对其他方法的域和方法引用一样,同一个类中的域和方法的引用最初也是符号的,必须在使用前进行解析。

解析后的常量池条目指向方法区关于addTwoTypes()方法的信息,虚拟机使用这些信息来决定addTwoTypes()中本地变量和操作数栈需要的大小。由sun的jdk1.1javac编译器产生的class文件中,addTwoTypes()的本地变量需要3个words,操作数栈需要4个words。虚拟机从堆中为addTwoTypes()方法的帧分配足够的内存,然后将double和int参数(88.88和1)从addAndPrint()的操作数栈中pop出来,并把它们放置到addTwoType()本地变量的第1和第2个位置槽。

当addTwoTypes()方法返回时,它首先把返回的double值push到它的操作数栈中。虚拟机使用帧数据的信息来定位调用方法(addAndPrint())的栈帧,将返回的double值push到addAndPrint()的操作数栈中,并释放掉addTwoType()帧所占的内存,这时addAndPrint()的帧就成为了当前帧。

下图是另一种java虚拟机栈帧的实现,它不是从堆中独立的为每个帧分配内存,这种实现从一个连续的栈中分配帧的内存。这种方法允许相邻方法的帧共享内存,调用方法的操作数栈中如果包含被调用方法的参数,调用方法操作数栈的这部分数据就会变成被调用方法的本地变量。

这种方法通过共享内存的方式节省了内存使用,java虚拟机也不需要将参数从一个帧复制到另一个帧,从而也减少了时间消耗。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐