JVM栈

本文深入探讨Java栈的工作原理,介绍栈帧的结构及其在方法调用中的作用。涵盖局部变量表、操作数栈、动态链接等核心概念。
Java中的栈
每当启用一个线程时,JVM就为他分配一个Java栈,栈是以帧为单位保存当前线程的运行状态。某个线程正在执行的方法称为当前方法,当前方法使用的栈帧称为当前帧,当前方法所属的类称为当前类,当前类的常量池称为当前常量池。当线程执行一个方法时,它会跟踪当前常量池。
每当线程调用一个Java方法时,JVM就会在该线程对应的栈中压入一个帧,这个帧自然就成了当前帧。当执行这个方法时,它使用这个帧来存储参数、局部变量、中间运算结果等等。
Java栈上的所有数据都是私有的。任何线程都不能访问另一个线程的栈数据。所以我们不用考虑多线程情况下栈数据访问同步的情况。
像方法区和堆一样,Java栈和帧在内存中也不必是连续的,帧可以分布在连续的栈里,也可以分布在堆里
Java栈的组成元素——栈帧
栈帧由三部分组成:局部变量区、操作数栈、帧数据区。局部变量区和操作数栈的大小要视对应的方法而定,他们是按字长计算的。但调用一个方法时,它从类型信息中得到此方法局部变量区和操作数栈大小,并据此分配栈内存,然后压入Java栈。
栈帧(Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接(Dynamic Linking)、方法返回值和异常分派(Dispatch Exception)。
栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。栈帧的存储空间分配在 Java 虚拟机栈之中,每一个栈帧都有自己的局部变量表(Local Variables)、操作数栈(Operand Stack)和指向当前方法所属的类的运行时常量池的引用。局部变量表和操作数栈的容量是在编译期确定,并通过方法的 Code 属性保存及提供给栈帧使用。因此,栈帧容量的大小仅仅取决于 Java 虚拟机的实现和方法调用时可被分配的内存。
在一条线程之中,只有目前正在执行的那个方法的栈帧是活动的。这个栈帧就被称为是当前栈帧(Current Frame),这个栈帧对应的方法就被称为是当前方法(Current Method),定义这个方法的类就称作当前类(Current Class)。对局部变量表和操作数栈的各种操作,通常都指的是对当前栈帧的对局部变量表和操作数栈进行的操作。
如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。当一个新的方法被调用,一个新的栈帧也会随之而创建,并且随着程序控制权移交到新的方法而成为新的当前栈帧。当方法返回的之际,当前栈帧会传回此方法的执行结果给前一个栈帧,在方法返回之后,当前栈帧就随之被丢弃,前一个栈帧就重新成为当前栈帧了。
请读者特别注意,栈帧是线程本地私有的数据,不可能在一个栈帧之中引用另外一条线程的栈帧。
局部变量表
每个栈帧内部都包含一组称为局部变量表(Local Variables)的变量列表。栈帧中局部变量表的长度由编译期决定,并且存储于类和接口的二进制表示之中,既通过方法的Code 属性保存及提供给栈帧使用。
一个局部变量可以保存一个类型为 boolean、byte、char、short、float、reference 和 returnAddress 的数据,两个局部变量可以保存一个类型为 long 和 double 的数据。
局部变量使用索引来进行定位访问,第一个局部变量的索引值为零,局部变量的索引值是从零至小于局部变量表最大容量的所有整数。

long 和 double 类型的数据占用两个连续的局部变量,这两种类型的数据值采用两个局部变量之中较小的索引值来定位。例如我们讲一个 double 类型的值存储在索引值为 n 的局部变量中,实际上的意思是索引值为 n 和 n+1 的两个局部变量都用来存储这个值。索引值为 n+1 的局部变量是无法直接读取的,但是可能会被写入,不过如果进行了这种操作,就将会导致局部变量 n 的内容失效掉。

上文中提及的局部变量 n 的 n 值并不要求一定是偶数,Java 虚拟机也不要求 double 和 long 类型数据采用 64 位对其的方式存放在连续的局部变量中。虚拟机实现者可以自由地选择适当的方式,通过两个局部变量来存储一个 double 或 long 类型的值。

Java 虚拟机使用局部变量表来完成方法调用时的参数传递,当一个方法被调用的时候,它的参数将会传递至从 0 开始的连续的局部变量表位置上。特别地,当一个实例方法被调用的时候,第 0 个局部变量一定是用来存储被调用的实例方法所在的对象的引用(即 Java 语言中的“this”关键字)。后续的其他参数将会传递至从 1 开始的连续的局部变量表位置上。
操作数栈
每一个栈帧(§2.6)内部都包含一个称为操作数栈(Operand Stack)的后进先出(Last-In-First-Out,LIFO)栈。栈帧中操作数栈的长度由编译期决定,并且存储于类和接口的二进制表示之中,既通过方法的 Code 属性保存及提供给栈帧使用。
在上下文明确,不会产生误解的前提下,我们经常把“当前栈帧的操作数栈”直接简称为“操作数栈”。
操作数栈所属的栈帧在刚刚被创建的时候,操作数栈是空的。Java 虚拟机提供一些字节码指令来从局部变量表或者对象实例的字段中复制常量或变量值到操作数栈中,也提供了一些指令用于从操作数栈取走数据、操作数据和把操作结果重新入栈。在方法调用的时候,操作数栈也用来准备调用方法的参数以及接收方法返回结果。

举个例子,iadd 字节码指令的作用是将两个 int 类型的数值相加,它要求在执行的之前操作数栈的栈顶已经存在两个由前面其他指令放入的 int 型数值。在 iadd 指令执行时,2 个 int 值从操作栈中出栈,相加求和,然后将求和结果重新入栈。在操作数栈中,一项运算常由多个子运算(Subcomputations)嵌套进行,一个子运算过程的结果可以被其他外围运算所使用。

每一个操作数栈的成员(Entry)可以保存一个 Java 虚拟机中定义的任意数据类型的值,包括 long 和 double 类型。在操作数栈中的数据必须被正确地操作,这里正确操作是指对操作数栈的操作必须与操作数栈栈顶的数据类型相匹配,例如不可以入栈两个 int 类型的数据,然后当作 long 类型去操作他们,或者入栈两个 float 类型的数据,然后使用 iadd 指令去对它们进行求和。有一小部分 Java 虚拟机指令(例如 dup 和 swap 指令)可以不关注操作数的具体数据类型,把所有在运行时数据区中的数据当作裸类型(Raw Type)数据来操作,这些指令不可以用来修改数据,也不可以拆散那些原本不可拆分的数据,这些操作的正确性将会通过 Class 文件的校验过程(§4.10)来强制保障。

在任意时刻,操作数栈都会有一个确定的栈深度,一个 long 或者 double 类型的数据会占用两个单位的栈深度,其他数据类型则会占用一个单位深度。
动态链接
每一个栈帧内部都包含一个指向运行时常量池(§2.5.5)的引用来支持当前方法的代码实现动态链接(Dynamic Linking)。在 Class 文件里面,描述一个方法调用了其他方法,或者访问其成员变量是通过符号引用(Symbolic Reference)来表示的,动态链接的作用就是将这些符号引用所表示的方法转换为实际方法的直接引用。类加载的过程中将要解析掉尚未被解析的符号引用,并且将变量访问转化为访问这些变量的存储结构所在的运行时内存位置的正确偏移量。

由于动态链接的存在,通过晚期绑定(Late Binding)使用的其他类的方法和变量在发生变化时,将不会对调用它们的方法构成影响。
方法正常调用完成
方法正常调用完成是指在方法的执行过程中,没有任何异常被抛出——包括直接从 Java 虚拟机之中抛出的异常以及在执行时通过 throw 语句显式抛出的异常。如果当前方法调用正常完成的话,它很可能会返回一个值给调用它的方法,方法正常完成发生在一个方法执行过程中遇到了方法返回的字节码指令的时候,使用哪种返回指令取决于方法返回值的数据类型(如果有返回值的话)。

在这种场景下,当前栈帧承担着回复调用者状态的责任,其状态包括调用者的局部变量表、操作数栈和被正确增加过来表示执行了该方法调用指令的程序计数器等。使得调用者的代码能在被调用的方法返回并且返回值被推入调用者栈帧的操作数栈后继续正常地执行。
方法异常调用完成
方法异常调用完成是指在方法的执行过程中,某些指令导致了 Java 虚拟机抛出异常,并且虚拟机抛出的异常在该方法中没有办法处理,或者在执行过程中遇到了 athrow 字节码指令显式地抛出异常,并且在该方法内部没有把异常捕获住。如果方法异常调用完成,那一定不会有方法返回值返回给它的调用者。

### JVM 的类型及其特点 JVM 是 Java 虚拟机中用于管理程序运行时数据的重要结构,主要包括以下几种类型:**虚拟机(Java Virtual Machine Stack)**、**本地方法(Native Method Stack)** 和 **操作数(Operand Stack)**。它们各自承担不同的职责,并具有不同的特点。 #### 虚拟机 虚拟机用于管理 Java 方法的调用,每个线程在创建时都会创建一个虚拟机,其内部由多个**帧(Stack Frame)**组成,每个帧对应一个方法的调用。帧中存储了局部变量表、操作数、动态链接、方法出口等信息。虚拟机是线程私有的,生命周期与线程一致。它主要负责程序的运行控制,包括方法的调用与返回[^2]。 虚拟机的访问速度非常快,仅次于程序计数器(PC Register),是 JVM 中最常用的运行时内存结构之一。它支持基本数据类型的局部变量和对象引用的存储。由于基于的设计,Java 的指令集较小,编译器容易实现,但相比基于寄存器的架构,性能会有所下降[^2]。 #### 本地方法 本地方法用于管理本地方法(Native Method)的调用,这些方法通常使用 C 或 C++ 实现。与虚拟机类似,本地方法也是线程私有的,允许被实现为固定大小或动态扩展[^1]。在某些 JVM 实现中,本地方法和虚拟机可能合并为一个。 当 Java 程序调用本地方法时,JVM 会通过执行引擎(Execution Engine)加载本地方法库,并在本地方法中执行相关操作[^1]。 #### 操作数 操作数是虚拟机帧的一部分,用于在方法执行过程中进行计算和数据传递。它是基于的指令集架构的核心部分,所有的计算操作都需要通过入和出完成。例如,在执行 `i++` 和 `++i` 时,操作数会临时存储操作数和中间结果。 操作数的大小在编译期确定,并在运行时固定。由于频繁的入和出操作,基于的架构虽然指令紧凑,但需要更多的指令分派和内存读写操作,这会影响执行效率[^4]。 #### 区别与作用总结 | 类型 | 作用 | 特点 | 是否存在垃圾回收 | |--------------|----------------------------------|--------------------------------------------------------------|------------------| | 虚拟机 | 管理 Java 方法调用 | 线程私有,生命周期与线程一致,存储局部变量、帧等信息 | 否 | | 本地方法 | 管理本地方法调用 | 线程私有,可动态扩展,通常与虚拟机分开 | 否 | | 操作数 | 方法执行过程中的数据计算与传递 | 帧的一部分,用于临时存储操作数和中间结果 | 否 | ```java // 示例:简单方法调用时帧的变化 public class StackExample { public static void main(String[] args) { int result = add(2, 3); System.out.println(result); } public static int add(int a, int b) { return a + b; } } ``` 在上述代码中,`main` 方法调用 `add` 方法时,会在虚拟机中创建一个新的帧,用于存储 `a` 和 `b` 的值,并在方法返回后释放该帧。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值