Java虚拟机之栈帧的组成结构

本文来源于《Java虚拟机精讲 》第八章的读书笔记。作者:高翔龙

栈帧的组成结构

在Java虚拟机规范中,Java栈(Java Stack)也可以被称之为Java虚拟机栈(Java Virtual Machine Stack),它同PC寄存器一样都是线程私有的,并且生命周期与线程的生命周期保持一致。Java栈主要用于存储栈帧(Stack Frame),而栈帧中则负责存储局部变量表、操作数栈、动态链接和方法返回值等信息。

在面向对象(Object Oriented)的世界中,类(Class)与对象(Object)是最基本的概念,字段和方法是一个类的主要构成元素。当实例化对象后,字段便可以理解为一个对象的各种“器官”,而方法则可以被理解为一个对象的一系列“行为”,因此方法就是程序用于执行命令的关键,那么方法和栈帧之间又存在什么样的关系呢?简单来说,栈帧是一种用于支持JVM调用/执行程序方法的数据结构,它是方法的执行环境,每一个方法被调用时都会创建一个独立的栈帧以便维系所需的各种数据信息,栈帧伴随着方法的调用而创建,伴随着方法的执行结束而销毁,那么每一个方法从调用到执行结束的过程,就对应着Java栈中一个栈帧从入栈到出栈的过程,并且无论方法的调用状态是否正常都算作方法结束。在此大家需要注意,不同线程中所包含的栈帧是不允许存在相互引用的。

在栈帧中,局部变量表和操作数栈所需的容量大小在编译期就可以完全被确定下来,并保存在方法的Code属性中,也就是说,栈帧究竟需要分配多大的内存空间完全取决于具体的JVM实现和方法调用时分配的实际内存。在一条活动线程中,只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧也被称之为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。如图8-1所示。

大家思考一下,既然一个线程中只有当前正在执行的方法的栈帧才是当前栈帧,那么如果当前方法在执行过程中调用了另外一个新的方法时,当前栈帧会发生变化吗?如下所示:
 

   /**  
     * 谁是当前栈帧?  
     *   
     * @author JohnGao  
     */  
    public class CurrentFrameTest {  
        public void methodA() {  
            System.out.println("当前栈帧对应的方法->methodA");  
            methodB();  
            System.out.println("当前栈帧对应的方法->methodA");  
        }  
        public void methodB() {  
            System.out.println("当前栈帧对应的方法->methodB");  
        }  
    } 

在上述程序示例中,如果与methodA()方法相对应的栈帧是当前栈帧,那么当methodA()方法内部调用了methodB()方法时,则会有一个与methodB()方法相对应的新栈帧作为当前栈帧被创建,也就是说,程序的控制权将会移交给methodB()方法。不过当methodB()方法执行完成并返回后,当前栈帧随之被丢弃,前一个栈帧又重新变为当前栈帧。

8.1.1  局部变量表

局部变量表(Local Variables Table)也可以称之为本地变量表,它包含在一个独立的栈帧中。顾名思义,局部变量表主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类原始数据类型、对象引用(reference),以及returnAddress类型。局部变量表所需的容量大小在编译期就可以被完全确定下来,并保存在方法的Code属性中。大家思考一下,既然方法体内定义的局部变量是存储在栈帧中的局部变量表里的,那么原始数据类型的成员变量的值是否也存储在局部变量表中呢?其实如果是定义在方法体外的成员变量,不止是作用域发生了变化,更重要的是,其值也并非还是存储在局部变量表里,而是存储在对象内存空间的实例数据中,整体来看即存储在Java堆区内。简单来说,与线程上下文相关的数据存储在Java栈中,反之则存储在Java堆区内。

局部变量表可以看做是专门用于存储局部变量值的一种类似于线性表(Linear List)的数据结构。参考《Java虚拟机规范 Java SE7版》的描述来看,局部变量表中最小的存储单元是Slot(变量槽),一个Slot可以存储一个类型为boolean、byte、char、short、float、reference以及returnAddress小于或等于32bit的数值,2个Slot可以存储一个类型为long或double的64bit数值。JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值,访问索引从0开始到小于局部变量表最大的Slot长度,如图8-2所示。在此大家需要注意,由于long和double类型的二进制位数是64bit,那么当使用这2个类型存储数据时,理论上占用的是2个连续的Slot,如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。这就好比一个double类型的值存储在局部变量表中其Slot的访问索引为n,当我们需要取出这个局部变量值时,只需要根据索引n便可以成功取出n和n+1的值,也就是一个完整的64bit的数据值。当然关于是否一定需要使用2个连续的Slot来存储一个64bit的值,Java虚拟机规范其实并没有明确要求,这主要还需要根据JVM的具体实现而定。除此之外,一个Slot究竟应该占用多大的内存空间Java虚拟机规范同样也没有明确的要求,但最好使用32bit的内存空间用于存储boolean、byte、char、short、float、reference及returnAddress等类型的值,当然这并不会意味着Slot的内存大小就一定会固定为32bit,因为Slot的内存大小允许根据处理器、操作系统或JVM实现的不同而产生相应的变化。

JVM使用局部变量表来完成方法调用时的参数传递,当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上。访问索引为0的Slot一定存储的是与被调用实例方法相对应的对象引用(通过Java语法层面的“this”关键字便可访问到这个参数),而后续的其他方法参数和方法体内定义的成员变量则会按照顺序从局部变量表中索引为1的Slot位置处展开复制。

8.1.2  操作数栈

每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称之为表达式栈(Expression Stack)。操作数栈和局部变量表在访问方式上存在着较大差异,操作数栈并非采用访问索引的方式来进行数据访问的,而是通过标准的入栈和出栈操作来完成一次数据访问。每一个操作数栈都会拥有一个明确的栈深度用于存储数值,一个32bit的数值可以用一个单位的栈深度来存储,而2个单位的栈深度则可以保存一个64bit的数值,当然操作数栈所需的容量大小在编译期就可以被完全确定下来,并保存在方法的Code属性中。

在HotSpot中,除了PC寄存器之外,再也没有包含其他任何的寄存器,并且之前曾经提及过,HotSpot中任何的操作都需要经过入栈和出栈来完成,那么由此可见,HotSpot的执行引擎架构必然就是基于栈式架构,而非传统的寄存器架构。简单来说,操作数栈就是JVM执行引擎的一个工作区,当一个方法被调用的时候,一个新的栈帧也会随之被创建出来,但这个时候栈帧中的操作数栈却是空的,只有方法在执行的过程中,才会有各种各样的字节码指令往操作数栈中执行入栈和出栈操作。比如在一个方法内部需要执行一个简单的加法运算时,首先需要从操作数栈中将需要执行运算的两个数值出栈,待运算执行完成后,再将运算结果入栈。如下所示:

代码8-2  执行加法运算的字节码指令
 

    public void testAddOperation();  
          Code:  
         0: bipush        15  
         2: istore_1  
         3: bipush        8  
         5: istore_2  
         6: iload_1  
         7: iload_2  
         8: iadd  
         9: istore_3  
         10: return 

在上述字节码指令示例中,首先会由“bipush”指令将数值15从byte类型转换为int类型后压入操作数栈的栈顶(对于byte、short和char类型的值在入栈之前,会被转换为int类型),当成功入栈之后,“istore_1”指令便会负责将栈顶元素出栈并存储在局部变量表中访问索引为1的Slot上。接下来再次执行“bipush”指令将数值8压入栈顶后,通过“istore_2”指令将栈顶元素出栈并存储在局部变量表中访问索引为2的Slot上。“iload_1”和“iload_2”指令会负责将局部变量表中访问索引为1和2的Slot上的数值15和8重新压入操作数栈的栈顶,紧接着“iadd”指令便会将这2个数值出栈执行加法运算后再将运算结果重新压入栈顶,“istore_3”指令会将运算结果出栈并存储在局部变量表中访问索引为3的Slot上。最后“return”指令的作用就是方法执行完成之后的返回操作。在操作数栈中,一项运算通常由多个子运算(subcomputation)嵌套进行,一个子运算过程的结果可以被其他外围运算所使用。

在此大家需要注意,在操作数栈中的数据必须进行正确的操作。比如不能在入栈2个int类型的数值后,却把它们当做long类型的数值去操作,或者入栈2个double类型的数值后,使用iadd指令对它们执行加法运算等情况出现。

8.1.3  动态链接

每一个栈帧内部除了包含局部变量表和操作数栈之外,还包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。在6.2.3节中,笔者曾经提及过运行时常量池,一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息,那就是常量池表(Constant Pool Table),那么运行时常量池就是字节码文件中常量池表的运行时表示形式。在一个字节码文件中,描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用(Symbolic Reference)来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,那么在这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。相反如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。既然引用的转换方式与方法的绑定机制相关,那么究竟什么是方法绑定呢?在Java中一共拥有两种方法绑定方式,分别是早期绑定(Early Binding)和晚期绑定(Late Binding)。顾名思义,早期绑定就是指被调用的目标方法如果在编译器可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。相反如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。在早期基于过程式的编程语言中,由于本身并不具备面向对象的一些特性,自然也就无法在语法层面上使用多态(Polymorphism)特性来实现方法的重写(Override)操作,也就是说,基于过程式的编程语言从严格意义上来说仅仅只存在一种绑定方式,那就是早期绑定。但随着高级语言的横空出世,类似于Java一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式。

在Java中,开发人员并不需要在程序中显式指定某一个方法需要在运行期支持晚期绑定,因为除了final方法外,几乎所有的方法都是默认基于晚期绑定的。如下所示:

代码8-3  晚期绑定示例
 

     /**  
         * 晚期绑定(Late Binding)示例  
         *   
         * @author JohnGao  
         */  
        public class LateBinding {  
            public static void getName(Animal animal) {  
                animal.name();  
            }  
          
            public static void main(String[] args) {  
                getName(new Tiger());  
                getName(new Pig());  
            }  
        }  
          
        interface Animal {  
            public void name();  
        }  
          
        class Tiger implements Animal {  
            @Override  
            public void name() {  
                System.out.println("我是Tiger,我派生于Animal");  
            }  
        }  
          
        class Pig implements Animal {  
            @Override  
            public void name() {  
                System.out.println("我是Pig,我派生于Animal");  
            }  
        } 

在上述程序示例中,接口Animal包含Tiger和Pig两个派生类,并且这两个派生类还重写了它的name()方法。由于在编译期并不明确LateBinding类中的getName()方法究竟需要调用哪一个name()方法,也就无法使用静态链接的方式将符号引用转换为直接引用,因此这一类的方法就是基于晚期绑定的虚函数,其实虚函数的存在就是为了支持多态特性。与动态链接相反,如果程序中能够使用静态链接的方式将符号引用转换为直接引用的话,这一类的方法就是基于早期绑定的非虚函数。在此大家需要注意,从严格意义上来说,在Java中其实并不存在虚函数的概念,因为开发人员并不需要显式使用任何关键字去标示Java中的一个虚函数。简单来说,Java中任何一个普通的方法其实都具备虚函数的特征,它们相当于C++语言中的虚函数(C++中则需要使用关键字virtual来显式定义)。如果在程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法。

8.1.4  方法返回值

一个方法在执行的过程中将会产生两种调用结果:一种是方法正常调用完成,而另外一种则是方法异常调用完成。如果是方法正常调用完成,那么这就意味着,被调用的当前方法在执行的过程中将不会有任何的异常被抛出,并且方法在执行的过程中一旦遇见字节码返回指令时,将会把方法的返回值返回给它的调用者,不过一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。在字节码指令中,返回指令包含ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。

与方法正常调用完成相反的就是方法异常调用完成。方法异常调用完成意味着当前方法在执行的过程中可能会因为某些错误的指令导致JVM抛出了异常,并且这些异常在当前方法中没有办法进行处理,或者方法在执行的过程中遇见了athrow指令显式抛出的异常,并且在当前方法内部没有捕获这个异常。总之,如果一个方法在执行的过程中抛出了异常,那么这个方法在调用完成之后将不会再有任何的返回值返回给它的调用者。

无论当前方法的调用结果是正常还是异常,都需要在执行完成之后返回到之前被调用的位置上,那么这个时候当前栈帧就承担着恢复调用者状态的责任。之前曾经提及过,在方法内部调用了另外一个方法时,将会有一个与当前方法相对应的新栈帧被创建出来,当方法调用完成之后,当前栈帧随之被丢弃,前一个栈帧又重新变为了当前栈帧,而被调用的方法如果带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值