一文理解虚拟机栈

15 篇文章 2 订阅
10 篇文章 1 订阅

目录

1 初识虚拟机栈

2 栈帧的内部结构

2.1 局部变量表

2.2 操作数栈

2.3 链接简介

2.4 方法返回地址

1 初识虚拟机栈

虚拟机栈更适合叫线程栈,主要管理线程里的方法调用的,每个线程都有一个对应的线程栈,每个方法都有一个对应的栈桢空间。

当我们写一个方法的时候,例如:

public void function(){
   int a=0;
   function1(a);
}

我们经常说方法要执行方法调用时会先将调用方法压栈,完成后再返回。例如上面要执行function1(a)时会先将function()的信息保存到栈中,完成之后再继续执行function(),那JVM具体是怎么保存的呢?就是以栈桢为单位保存的,每执行一次方法调用就会先为要调用的方法创建一个栈桢。

栈是一个快速有效的分配存储方式,访问速度仅次于程序计数器,但是JVM对Java栈的操作只有两个:执行方法时入栈,完成方法后出栈。对于一个线程,在同一个时刻,只会有一个栈桢在活动,也即只有当前正在执行的方法对应的栈桢是有效的。这个帧也成为当前帧(current Frame),与当前栈桢对应的就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。我们后面要介绍的执行引擎运行的所有字节码指令都是只针对当前栈桢进行操作的。

如果在某个方法中又调用了其他的方法,此时就会为新的方法创建新的栈桢放在栈顶,成为新的当前帧,而之前的帧就会将信息保存一下等待当前帧执行完之后再继续执行。

 如果当前方法调用了其他方法,方法返回时会就将执行结果一起回传,然后虚拟机会丢弃当前栈帧,使得栈中的后序帧成为新的当前栈桢。该过程与栈的操作是过程是一致的。

思考1 栈是线程共享的还是私有的?

虚拟机栈是在创建线程时同步创建的,是线程私有的,生命周期也与线程一致,主要作用是管理某个线程的运行,包括保存方法的局部变量、部分结果,并且参与方法的调用与返回。其内部保存了一个个的栈桢,对应的就是该线程每次进行的方法调用,因此栈桢可以理解为方法调用的操作单位。

线程私有可以简化栈桢的操作,并且提高执行效率。

思考2 栈中可能出现什么异常

从上面的分析可以看到,栈只有进栈和出栈两个操作,因此能天然地自动进行空间的管理,不存在垃圾回收问题,那常见的"StackOverflowError"又是怎么回事呢?

JVM规范允许Java栈的大小是动态的或者是固定不变的,如果是固定大小的,那么一个线程的Java虚拟机容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,就会抛出StackOverflowError异常。

那栈一定不会出现内存溢出的问题吗?不是的,如果Java虚拟机栈可以动态扩展,并且扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机就会抛出OutOfMemoryError异常。

例如下面的代码就会抛出“StackOverflowError”:

public class StackOverFlowTest {
    public static void main(String[] args) {
        test();
    }
​
    public static void test() {
        test();
    }
}

2 栈帧的内部结构

栈帧的内部包括局部变量表、操作数栈、动态链接和方法返回地址等结构。

2.1 局部变量表

局部变量表(Local variables)也称为局部变量表或者本地变量表,主要用于存储方法的参数和定义在方法体中的局部变量。这些数据类型包括各类型基础数据类型、对象引用(reference),以及returnAddress类型。

局部变量表其实就是一个数组数组,假如其长度为length,参数以索引为0的位置Index0开始存放,到length-1的位置结束。这length个单元有一个专门的名字:槽 slot,因此槽的个数就是局部变量表的长度。

局部变量表中存放的是编译期可知的各种基本数据类型(Integer、Char等8大包装类),引用类型(reference),returnAddress类型的变量。局部变量表所需的容量大小是在编译器确定下来的,并保存在方法的Code属性的maximum local variables数据项中,因此,在方法运行期间不会改变局部变量表的大小。

在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。其中byte、short、char和boolean都会先转成int,因此也会占用一个槽。

为了提高访问效率,JVM会为局部变量表中每个slot分配一个索引,通过这个索引即可成功访问到局部变量表中的局部变量值,而不用从头开始查找,如下图:

静态变量和局部变量的对比 参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。

我们知道类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。

和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。

public void test(){
    int i;
    System. out. println(i);
}

这样的代码是错误的,没有赋值不能够使用。

另外,局部变量表中的变量也是重要的垃圾回收的根节点 ,只要被局部变量表中直接或者简介引用的对象都不会被回收。

2.2 操作数栈

每一个独立的栈桢中出了包含局部变量表以外,还包含一个后进先出的操作数栈,也可以称为表达式栈,其功能说白了就是为了计算表达式了,例如要计算8+5=13。操作数栈在方法执行过程中,

根据字节码指令,往栈中写入数据或弹出数据。例如:

public class TestAddOperation {
    public static void main(String[] args) {
        byte i = 15;
        int j = 8;
        int k = i + j;
    }
}

编译之后,通过javap命令,我们可以见到如下的字节码执行信息,其中我们可以看到bipush等字样,这就是操作表达式栈。

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=1
         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

表达式栈主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧会随之创建。如果被调用的方法带有返回值,其返回值将会被压入当前栈桢的操作数栈中,并更新PC寄存器中下一条所需要执行的字节码指令。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配。这有编译器再编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。

我们有时候会看到说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。

2.3 链接简介

在JVM中,将符号转换为调用方法的直接引用与方法的绑定机制相关。

绑定时参考的一个重要信息来源就是常量池,根据常量池的信息将这些内容转换为实际的地址。

例如,我们创建的Math类,内容如下:

public class Math {
    public static final int initData = 666;
    //一个方法对应一块栈帧内存区域
    public int compute() {
        int a = 1;
        int b = 2;
        int c = (a + b) * 10;
        return c;
    }
​
    public static void main(String[] args) {
        Math math = new Math();
        math.compute();
    }
​
}

我们通过javap -v Math.class 查看其字节码中main()方法的内容

 根据上图的信息我们可以推断出,new对应的就是new Math(),invokespecial对应的是初始化对象,而invokevirtual对应的是调用方法,我们还注意到上面几个指令后面跟着的是#2,#3,#4这表示什么呢?我们再看该类对应的信息在常量池中:

   #1 = Methodref          #5.#29         // java/lang/Object."<init>":()V
   #2 = Class              #30            // ch3_jmm/topic2_stack/Math
   #3 = Methodref          #2.#29         // ch3_jmm/topic2_stack/Math."<init>":()V
   #4 = Methodref          #2.#31         // ch3_jmm/topic2_stack/Math.compute:()I
   #5 = Class              #32            // java/lang/Object

可以看到这里的#2,#3和#4分别对应的是方法操作,后面的#5,#29等也都是更具体的操作。

上面看到的是字节码文件里的常量池,都称之为符号,在经过上一章的装载子系统处理之后,这些信息会在方法区的运行时常量池统一管理。在链接阶段会使用这些符号信息一步步细化,直到JVM可以通过该信息直接定位到对象Math在堆空间的具体地址(当然这个地址不会记录在字节码常量池里)。

链接主要有静态链接和动态链接两种方式,我们简单介绍一下。

静态链接就是当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译器可知,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称为静态链接。

动态链接就是如果被调用的方法在编译器无法被确定下来,也就是说,只能再程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称为动态链接。

为什么会有这两种方式呢?因为有些场景是无法将所有方法都确定下来的。我们同样做个类比。我们知道汽车都有油箱,没油就去加,这就是动态链接。而火箭发射时必须将所有油料全部准备好,这就是静态链接。我们知道汽车比较小巧,一直加油可以用很多年,但是上千吨的火箭往往只能运送几吨的载荷,这是因为大部分空间都被油料和氧气占满了。事实上静态链接的操作执行速度快,但是比较笨重,而动态链接则恰好相反。

与静态链接和动态链接对应的就是早起绑定和晚期绑定。 早起绑定,就是被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。晚期绑定,如果被调用的方法无法再编译期确定下来,只能再程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就称为晚期绑定。Java语言支持封装、继承和多态特征,这就需要早期绑定和晚期绑定两种方式。

2.4 方法返回地址

方法返回地址returnAddress存放调用该方法的PC寄存器的值,正常执行完成或者出现异常都将导致一个方法的结束,无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈桢中一般不会保存这部分信息。

本质上,方法的退出就是当前栈桢出栈的过程,此时需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈桢的操作数栈、设置PC寄存器,让调用者继续执行下去。如果抛异常的话,就不会抛异常给上层调用者。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

纵横千里,捭阖四方

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值