详解Java虚拟机栈

本文详细介绍了Java虚拟机栈帧的结构,包括局部变量表、操作数栈、动态连接和方法返回地址。局部变量表用于存储方法参数和局部变量,操作数栈则用于方法调用和执行时的数据运算。动态连接用于方法调用的符号引用转换,方法返回地址则标记方法调用结束后的返回点。文章还讨论了栈帧内存的复用和垃圾收集的影响。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

简介

运行时栈帧结构

局部变量表

操作数栈

动态连接

方法返回地址


简介

由于跨平台性的设计,Java 的指令都是根据栈来设计的。不同平台 CPU 架构不同,所以不能设计为基于寄存器的。 优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。

虚拟机栈是线程私有的,每个线程在创建时会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的 Java 方法调用。

运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元,“栈帧”是用于支持虚拟机进行方法调用和方法执行的数据结构,它也是虚拟机栈的栈元素。每一个方法的调用开始到执行结束的过程,对应着虚拟机栈中栈帧的入栈到出栈的过程。每一个栈帧包括局部变量表操作数栈动态连接方法返回地址。一个线程中的方法调用链可能会很长,以Java程序的角度来看,同一时刻、同一条线程里面,在 调用堆栈的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”(Current Stack Frame),与 这个栈帧所关联的方法被称为“当前方法”(Current Method)。执行引擎所运行的所有字节码指令都只 针对当前栈帧进行操作。一个栈帧的结构如下:

 

接下来谈一谈栈帧是如何分配内存的。其实早在编译阶段一个栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,也就是说一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。

局部变量表

局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义 的局部变量。局部变量表的容量以变量槽(Variable Slot)为最小单位。一个变量槽大小一般为32位,其实《Java虚拟机规范》中并没有明确指出 一个变量槽应占用的内存空间大小,如在64位虚拟机中使用了64位的物理内存空间去实现一个变量槽,但是虚拟机仍要使用对齐和补白的手段 让变量槽在外观上看起来与32位虚拟机中的一致,也就是说64位的变量槽实际上只用到了前32位来存储数据,其余32位是不能使用的。对于boolean、byte、char、short、int、 float、reference 和returnAddress这8种数据类型,可以使用32位或更小的物理内存来存储,而64位的数据类型(double、long),Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间(64位的虚拟机分配的也是两个变量槽)。

 

参数与变量在局部变量表中的存储

当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程, 即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。

 

变量槽复用

为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。

如下例子:

public class Demon{
    public static void main(String[] args){
        {
            int a=1;
        }
        int b=2;
    }
}

在代码中变量a使用了一个代码块包裹,当程序运行到int b=2时,变量a已经离开了它的作用域,那么之前存放变量a的变量槽会被变量b复用。

这样的设计除了节省栈帧空间以外,还会伴随有少量额外的副作用,例如在某些情况下变量槽的复用会直接影响到系统的垃圾收集行为。

看如下代码:运行时使用“-verbose:gc”参数,查看JVM垃圾收集的过程。

public class Demon{
    public static void main(String[] args){
        byte [] placeholder = new byte[64*1024*1024];
        System.gc();
    }
}

执行结果:

[GC (System.gc())  69501K->66384K(123904K), 0.0013217 secs]
[Full GC (System.gc())  66384K->66192K(123904K), 0.0043950 secs]

上面的代码向内存填充了64MB的数据,然后通知虚拟机进行垃圾收集。在System.gc()运行后结果发现虚拟机并没有回收掉这64MB的内存,这是因为在执行System.gc()时,placeholder变量没有离开它的作用域,变量槽中还存有placeholder的引用,这时候的placeholder是无法回收bt的。

那我们再将它改动一下:将placeholder放入代码块中,从代码逻辑上讲,placeholder的作用域被限制在花括号以内,在执行 System.gc()的时候,placeholder已经不可能再被访问了。

public class Demon2{
    public static void main(String[] args){
        {
            byte [] placeholder = new byte[64*1024*1024];
        }
        System.gc();
    }
}

执行结果:

[GC (System.gc())  69501K->66384K(123904K), 0.0007657 secs]
[Full GC (System.gc())  66384K->66192K(123904K), 0.0043796 secs]

执行这段程序,会发现运行结果还是有64MB的内存没有被回收掉,这又是为什么呢?

虽然执行到gc()时,代码虽然已经离开了placeholder的作用域,但在此之后,再没有发生过任何对局部变量表的读写操作,placeholder原本所占用的变量槽还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联.

这种关联没有被及时打断,绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存但实际上已经不会再使用的变量,会导致内存的长时间占用。

解决办法:将placeholder设置为null;或后面定义一个变量,复用这个变量槽。

public class Demon3{
    public static void main(String[] args){
        {
            byte [] placeholder = new byte[64*1024*1024];
            //placeholder=null;
        }
        int a=0;
        System.gc();
    }
}

执行结果:

[GC (System.gc())  69501K->66416K(123904K), 0.0008174 secs]
[Full GC (System.gc())  66416K->656K(123904K), 0.0041344 secs]

gc终于将这64M的内存回收了。

操作数栈

操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项 之中。操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。

Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。

举个例子,例如整数加法的字节码指令iadd,这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会把这两个int 值出栈并相加,然后将相加的结果重新入栈。

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。 另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。说白了就是这个动态连接保存的是一个方法的符号引用,每次运行期间都会进行一次转换,将它转换成内存中方法的物理地址。

扩展:在Class文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。当虚拟机做类加载时,将会从常量池(Class文件中的常量池)获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中(放入运行时常量池中)。

方法返回地址

方法返回地址也就是该方法最初被调时的地址。在一个方法执行完毕之后,必须返回到最初方法被调用时的位置,程序才能继续执行。当一个方法开始执行后,只有两种方式退出这个方法:

  1. 执行引擎遇到任意一个方法返回的字节码指令。这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用 者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种 退出方法的方式称为“正常调用完成”。

  2. 在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为“异常调用完成”。

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

(创作不易,点个赞再走...)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值