Java虚拟机栈

Java虚拟机栈

java栈

Java 虚拟机栈(Java Virtual Machine Stack,简称 JVM 栈)是 Java 虚拟机运行时数据区的核心组成部分,JVM 栈是线程私有的内存空间,完全服务于线程的方法执行流程。从线程生命周期来看,每个 Java 线程在创建时都会为其分配一块独立的 JVM 栈空间,该栈的生命周期与所属线程严格绑定 ------ 线程启动时栈被创建,线程终止时栈的内存被释放,不存在跨线程的 JVM 栈共享。

栈帧

JVM 栈的核心操作对象是栈帧(Stack Frame):栈帧是方法执行的基本数据单元,一个方法从被调用到执行完成的全过程,对应一个栈帧在 JVM 栈中入栈(压栈/Push)到出栈(弹栈/Pop)的完整过程。因此,JVM 栈仅支持以栈帧为单位的入栈和出栈两种核心操作,整体遵循后进先出(LIFO,Last-In-First-Out)的访问规则。

线程执行的核心行为是方法调用,方法执行所需的所有上下文信息(包括局部变量、操作数、方法返回地址、动态链接等)均存储在对应栈帧中,方法执行的全量上下文都由 JVM 栈中的栈帧承载,这正是 JVM 栈与线程执行过程深度绑定的核心原因。

JVM 栈中函数调用与栈帧的对应关系

如图:

函数 1 至函数 4 分别对应独立的栈帧 1 至栈帧 4,且存在 "函数 1 调用函数 2、函数 2 调用函数 3、函数 3 调用函数 4" 的递进调用链。随着函数调用的推进,栈帧按后进先出规则依次入栈:当函数 1 被触发调用时,其对应的栈帧 1 首先压入 JVM 栈;函数 1 执行过程中调用函数 2,栈帧 2 随之入栈;函数 2 调用函数 3,栈帧 3 入栈;最终函数 3 调用函数 4,栈帧 4 压入栈顶。

此时,正在执行的函数 4 对应的栈帧 4 处于 JVM 栈的栈顶(即 "当前帧"),它包含了图右侧所示的局部变量表、操作数栈、帧数据区,用于承载函数 4 的局部变量、中间运算结果等执行上下文数据。(这块内容在下文有进行详细介绍)

当函数完成执行后,对应的栈帧会从 JVM 栈中弹出,Java 方法有两种返回方式:一是通过return指令的正常返回,二是通过抛出异常的异常返回;无论哪种方式,都会触发当前栈帧的弹出操作。例如函数 4 执行完毕后,栈帧 4 弹出,函数 3 对应的栈帧 3 回到栈顶并恢复执行;后续函数 3、函数 2、函数 1 依次完成返回,各自的栈帧也会按顺序弹出,直至 JVM 栈回到初始状态。

栈的内存空间

由于每次方法调用都会生成对应的栈帧,栈帧会占用 JVM 栈的内存空间,因此当 JVM 栈剩余空间不足以容纳新栈帧时,方法调用无法继续。当方法调用产生的栈帧累积深度超过 JVM 栈的最大容量时,JVM 会抛出StackOverflowError(栈溢出错误)。

Java 虚拟机(以 HotSpot 为例)提供非标准启动参数 -Xss 来指定每个线程的栈内存大小,该参数直接决定了方法调用的最大深度 ------ 栈空间越大,可容纳的栈帧越多,调用深度越大;单个栈帧占用空间越大,相同栈空间下的调用深度越小。

下面的代码通过无终止条件的递归调用模拟栈溢出场景,递归方法中定义了大量局部变量以增大栈帧体积,程序捕获 StackOverflowError 后打印出递归的最大调用深度:

java 复制代码
public class TestStackDeep {

    private static int count = 0;

    public static void recursion(long a, long b, long c) {
        long e = 1, f = 2, g = 3, h = 4, i = 5, k = 6, q = 7, x = 8, y = 9, z = 10;
        count++;
        recursion(a, b, c);
    }

    public static void recursion() {
        count++;
        recursion();
    }

    public static void main(String args[]) {
        try {
            //recursion(1, 2, 3);
            recursion();
        } catch (Throwable e) {
            System.out.println("deep of calling = " + count);
            e.printStackTrace();
        }
    }

使用 -Xss128k 参数运行程序并调用 recursion(),-Xss128k 将每个 Java 线程的栈内存大小设置为 128 千字节(单位支持k/K、m/M),执行结果如下:

当将栈空间调大,使用 -Xss256k 参数运行程序并调用同一个recursion()方法时,执行结果如下:

可以看到,通过增大 -Xss 参数的值,JVM 栈的总可用空间提升,可容纳的栈帧数量相应增加,因此该递归方法的调用深度明显提升;反之,若减小 -Xss 参数的值,JVM 栈可容纳的栈帧数量减少,递归调用深度也会随之降低。

下面详细介绍栈帧的核心组成部分,包含局部变量表、操作数栈和帧数据区。

局部变量表

局部变量表是 JVM 栈帧的核心组成部分,用于存储当前方法的方法参数和方法内定义的局部变量。局部变量表的生命周期与所属栈帧完全绑定:仅在当前方法调用期间有效,当方法执行完成、栈帧从 JVM 栈中弹出销毁时,局部变量表也会随之销毁,其存储的变量数据也会被释放。

局部变量表的大小在编译期就已确定(以变量槽为基本存储单位),方法的参数数量、局部变量数量直接决定了局部变量表所需占用的变量槽数量 ------ 变量越多,局部变量表占用的内存空间越大,单个栈帧的体积也就越大。而 JVM 栈的总容量(由-Xss参数指定)是固定的,因此单个栈帧体积越大,JVM 栈可容纳的栈帧数量就越少,最终导致方法的嵌套调用深度降低;反之,栈帧体积越小,可容纳的栈帧数量越多,方法调用深度则越深。

还是使用 TestStackDeep 类的代码,在相同的 JVM 栈容量限制下,通过两个递归方法的对比,直观验证这一规律:

  • 第一个 recursion(long a, long b, long c) 方法:包含 3 个方法参数和 10 个局部变量,其局部变量表含有13个变量,单个栈帧体积较大;
  • 第二个无参 recursion() 方法:无任何参数和局部变量,局部变量表或无额外占用,单个栈帧体积极小。

使用 -Xss128k 运行无参 recursion() 方法,执行结果示例:

使用 -Xss128k 运行带参 recursion(long a, long b, long c) 方法,执行结果示例:

可见,在 JVM 栈总容量相同的前提下,局部变量表更小(参数和局部变量更少)的方法,其单个栈帧占用的内存更少,JVM 栈可容纳的栈帧数量更多,因此能支持更深的嵌套调用;反之,参数和局部变量多的方法,会因栈帧体积过大,导致嵌套调用深度大幅降低。

局部变量表的变量槽

使用 jclasslib 工具可以更进一步查看方法的局部变量信息,IDEA 中下载 jclasslib 工具。

点击 view 再点击 Show Bytecode With Jclasslib,查看 recursion(long a, long b, long c) 方法的内容,如图:

图中展示了 recursion(long a, long b, long c) 方法的局部变量表大小为 26 个变量槽。

根据 JVM 规范,局部变量表的基本存储单位是变量槽(Slot):long 和 double 类型的变量每个占用 2 个变量槽,int、short、byte、char、boolean、对象引用等类型的变量每个仅占用 1 个变量槽。

recursion() 方法内容如下:

图中展示了 recursion() 方法无方法参数、无局部变量,其局部变量表的变量槽数量为 0。

在 recursion(long a, long b, long c) 方法对应的 Class 文件 Code 属性的 LocalVariableTable 属性中,清晰展示了每个局部变量的关键信息,如下图:

若 jclasslib 工具是英文版的,这里解释一下每一列的内容:

Start PC 列:该变量在字节码指令序列中有效的 PC 地址区间;

Index 列:对应局部变量在局部变量表中占用的槽位索引;

Name 列:对应局部变量的名称;

Descriptor 列:是 Java 字节码中 long 类型的标准描述符(JVM 规范中,J代表 long,D代表 double,I代表 int 等)。

局部变量表槽位的复用逻辑

栈帧中局部变量表的槽位支持作用域驱动的重用:

当一个局部变量的作用域(由起始PC定义)结束后,后续在新作用域中声明的局部变量,会复用已过期变量的槽位(只要槽位类型兼容,如 long/double 占用的 2 个槽位可被其他 long/double 复用,或拆分为 2 个占用 1 个槽位的变量复用)。

这种复用机制的核心目的是压缩局部变量表的总槽位数量,从而减小单个栈帧的体积,在有限的 JVM 栈空间内容纳更多栈帧。

示例代码:

java 复制代码
public class TestStackDeep1 {

    public void localvar1(){
        int a = 0;
        System.out.println(a);
        int b= 0;
    }

    public void localvar2(){
        {
            int a = 0;
            System.out.println(a);
        }
        int b= 0;
    }


    public static void main(String args[]) {
        TestStackDeep1 t = new TestStackDeep1();
        t.localvar1();
        t.localvar2();
    }
}

从 Class 文件的 LocalVariableTable 可以看到 localvar1() 的局部变量表包含 this(槽位0)、a(槽位1)、b(槽位2),共 3 个槽位。

从 Class 文件的 LocalVariableTable 可以看到 localvar2()的局部变量表中,a 和 b 共用槽位 1(序号均为 1),最终仅占用 this(槽位0)、a/b(槽位1)共 2 个槽位,实现了槽位复用。

在 localvar1() 方法中,局部变量 a 的作用域覆盖整个方法,后续定义的 b 作用域也延续到方法末尾,因此 b 无法复用 a 的槽位;

而在 localvar2() 方法中,局部变量 a 被限制在代码块内(作用域随代码块结束而终止),后续定义的 b 可以复用 a 占用的1个变量槽。

局部变量的回收

局部变量表与垃圾回收的关联

局部变量表中的引用类型变量是 JVM 垃圾回收的核心根节点(GC Root)------ 只要对象被局部变量表直接或间接引用,就会被判定为存活对象,无法被 GC 回收;只有当引用关系断开(如引用置 null、槽位被复用、栈帧销毁),对象才会失去 GC Root 可达性,进而被 GC 回收。

java 复制代码
public class LocalVarGCTest {

    public void localvarGc1() {
        byte[] a = new byte[6 * 1024 * 1024];
        System.gc();
    }

    public void localvarGc2() {
        byte[] a = new byte[6 * 1024 * 1024];
        a = null;
        System.gc();
    }

    public void localvarGc3() {
        {
            byte[] a = new byte[6 * 1024 * 1024];
        }
        System.gc();
    }

    public void localvarGc4() {
        {
            byte[] a = new byte[6 * 1024 * 1024];
        }
        int c = 10;
        System.gc();
    }

    public void localvarGc5() {
        localvarGc1();
        System.gc();
    }

    public static void main(String[] args) {
        LocalVarGCTest ins = new LocalVarGCTest();
        ins.localvarGc5();
    }
}

上述代码中,每个 localvarGc 方法均在堆中分配 6MB 字节数组,并通过局部变量引用该数组,不同场景下数组的回收结果差异如下:

localvarGc1():在申请空间后,立即进行垃圾回收,字节数组仍被局部变量a(GC Root)引用,GC 判定该数组为存活对象,因此无法回收;

localvarGc2():在垃圾回收前将a置为null,断开了局部变量对数组的强引用,数组失去 GC Root 可达性,因此能被 GC 正常回收;

localvarGc3():代码块结束后a的作用域终止,但局部变量表中a对应的槽位未被复用,槽位中存储的数组引用仍未清空,数组仍被 GC Root 关联,因此无法被回收;

localvarGc4():代码块结束后a的作用域终止,且后续声明的c复用了a的变量槽(Slot),槽位被复用后,原指向数组的引用被覆盖,数组失去 GC Root 可达性,因此能被 GC 回收;

localvarGc5():调用 localvarGc1() 时,方法内的数组虽未被释放,但 localvarGc1() 执行完成后,其栈帧从 JVM 栈中弹出销毁,栈帧内的局部变量表(含a的引用)也随之销毁,数组失去 GC Root 引用;因此在 localvarGc5() 调用垃圾回收时,该数组能被正常回收。

可以通过添加 JVM 参数 -XX:+PrintGC 执行上述方法,该参数会打印每次 GC 的前后堆内存占用,从而推断 6MB 字节数组是否被回收。

以 localvarGc4 的运行日志为例:

日志中 "GC 前已使用堆内存→GC 后已使用堆内存" 的变化,反映了本次 GC 的整个堆的内存占用变化。

  • 从日志 [GC (System.gc()) 11141K->880K(239616K)] 可分析:本次GC前堆已使用内存为11141KB,GC后降至880KB,共释放约10261KB(约10MB)的内存空间;结合 localvarGc4 的代码逻辑(变量c复用了a的槽位,6MB字节数组失去GC Root可达性),可判定释放的内存空间主要包含该字节数组,即该数组已被GC回收释放。
  • Full GC (System.gc()) 880K-\>616K(239616K), 0.0035919 secs\]:本次Full GC前堆已使用内存为880KB,GC后降至616KB,额外释放了264KB的内存;Full GC会回收年轻代和老年代的不可达对象,因此本次回收的是年轻代剩余的少量存活对象及老年代中无引用的对象,进一步压缩了堆内存的实际占用量。

在localvarGc5的结果中观察到 GC 日志中的内存变化不明显,具体原因分析如下:

  • GC 日志与分代收集的特性:-XX:+PrintGC 会打印所有类型 GC(包括年轻代 Minor GC 和 Full GC)的回收前后堆内存大小,分代收集机制下,若 6MB 数组分配在年轻代,即使 Minor GC 回收了该数组,也可能因年轻代内其他存活对象占用内存,导致堆总内存变化不明显;此外,System.gc() 在 HotSpot 默认配置下会触发 Full GC,但 Full GC 会同时回收年轻代和老年代,若数组已被晋升到老年代,仅年轻代的回收变化会被稀释。
  • 大对象的分配与回收时机:6MB 数组属于大对象,其分配位置取决于 -XX:PretenureSizeThreshold(HotSpot 虚拟机该参数默认值为 0,即不启用 "大对象直接进入老年代" 策略),分配与回收行为需分场景分析:
    • 默认情况下,数组会先分配在年轻代 Eden 区;第一次 GC(localvarGc1 内)因数组被引用无法回收;第二次 GC(localvarGc5 内)数组已不可达,但如果 Eden 区在 Minor GC 前已被占满,该数组会被提前晋升至老年代,而老年代对象仅能通过 Full GC 回收,若此时 JVM 仅执行了 Minor GC,则无法回收该数组,导致堆内存变化不明显。
    • 若显式设置 -XX:PretenureSizeThreshold < 6MB,数组会直接分配在老年代,仅 Full GC 能回收,进一步延迟回收可见性。
  • System.gc () 的"建议性"本质与回收器的差异化响应:System.gc()仅向 JVM 发送 "建议执行 GC" 的信号,不保证立即触发回收;不同垃圾回收器对该信号的响应不同(如 ParallelGC 通常触发 Full GC,G1 可能延迟触发或仅执行轻量回收),若 JVM 可通过-XX:+DisableExplicitGC 禁用System.gc()的触发效果,最终导致回收行为与预期不一致。

操作数栈

操作数栈(Operand Stack)是 JVM 为每个执行方法创建的栈帧核心组成部分之一,本质是遵循后进先出(LIFO)原则的有序存储结构,核心定位是临时存储方法执行过程中的操作数、中间计算结果的线程私有缓冲区,其最大深度在编译期确定,运行时不可动态调整。

操作数栈的核心特性:

  • 线程私有:操作数栈随栈帧绑定线程与方法,每个线程的每个方法调用都会创建独立的操作数栈实例,不同线程的操作数栈物理隔离、互不干扰,天然规避线程安全风险。
  • 容量约束:其最大深度(栈容量)由字节码编译器在编译阶段确定,记录于 Class 文件的方法 Code 属性中;JVM 创建栈帧时会按该深度分配固定大小的内存空间,运行时无法动态扩容,超出最大深度会抛出StackOverflowError。
  • 操作限制:仅支持入栈(push)、出栈(pop)两类原子操作,所有操作均围绕栈顶进行,不支持对栈中任意位置元素的随机访问,完全遵循栈结构的访问约束。
  • 存储类型:仅存储 JVM 规范定义的 8 种基本数据类型及对象引用;对象实例本身始终分配于堆内存,操作数栈中仅保留指向堆对象的引用地址,不直接持有对象数据。
  • 深度单位:为保证类型对齐与指令执行一致性,JVM 规范明确:long 和 double 类型因占用 8 字节存储空间,入栈后占用 2 个操作数栈深度单位;其余所有类型(含基本类型、对象引用)均占用 1 个单位,入栈与出栈需以整单位原子性执行,不可拆分。

操作数栈的核心用处

  1. 承载计算过程的临时数据(最核心用处)

    JVM 执行算术 / 逻辑运算时,无法直接对局部变量表中的数据做计算,必须先把数据压入操作数栈,运算指令从栈顶弹出数据计算,再把结果压回栈。

  2. 支撑 JVM 字节码指令的执行

    JVM 采用栈式指令集,几乎所有字节码指令的执行都以操作数栈为载体。

  3. 实现方法调用的参数传递

    调用方法时,实参的传递完全依赖操作数栈:

    调用方先把所有实参按顺序压入自己的操作数栈;JVM 把这些实参从调用方栈弹出,按顺序存入被调用方法的局部变量表(作为形参);被调用方法执行完后,返回值会压入调用方的操作数栈,供调用方后续使用。

  4. 临时存储变量值(补充局部变量表的不足)

    局部变量表是索引式存储(按索引访问),适合长期存储局部变量,但不适合临时交换 / 复用数据;操作数栈的栈式存储可灵活临时存储。

  5. 承载计算过程的临时数据(最核心用处)

    JVM 执行算术 / 逻辑运算时,无法直接操作局部变量表中的数据,必须先将参与计算的操作数压入操作数栈;运算指令(如iadd、imul)从栈顶弹出对应数量的操作数完成计算,再将结果压回栈顶。

  6. 支撑 JVM 字节码指令的执行

    JVM 采用栈式指令集(而非寄存器指令集),几乎所有字节码指令的执行都以操作数栈为载体。

  7. 实现方法调用的参数传递

    方法调用时的实参传递完全依赖操作数栈完成,核心流程:

    调用方按形参顺序,将所有实参(非静态方法需先压入this引用)依次压入自身的操作数栈;

    JVM 将这些实参从调用方操作数栈弹出,按顺序存入被调用方法的局部变量表(作为形参);

    被调用方法执行完毕后,返回值会被压入调用方的操作数栈,供调用方后续使用。

  8. 临时存储变量值(补充局部变量表的不足)

    局部变量表是索引式存储(按固定索引访问),适合长期存储局部变量,但临时交换 / 复用数据效率低,操作数栈的栈式存储可灵活完成临时数据处理:对于交换两个变量值来说,无需额外定义临时变量,通过多次 push/pop 操作即可完成;对于复用中间结果来说,计算产生的中间结果可暂存于栈顶,直接被后续指令取用,无需写入局部变量表,提升执行效率。

操作数栈的工作原理

代码案例:

java 复制代码
public class Test1 {

    public static int add(){

        int i = 10;
        int j = 20;
        int z = i +j;
        return z;
    }

    public static void test(){
        int i = 0;
        i = i++;
        System.out.println(i);
    }

    public static void test2(){
        int c = 0;
        for (int i = 0; i < 100; i++) {
            c = c++;
        }
        System.out.println(c);
    }

    public static void test1(){
        int i = 0;
        i = ++i;
        System.out.println(i);
    }

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

找到 Class 文件的方法 Code 属性,add() 函数的操作数栈信息如下:

bipush:把带符号单字节常量 valuebyte 值扩展成 int 值推入操作数栈

istore_0:弹出操作数栈顶的 int 类型值,存入局部变量表第 0 位

iload_0:把局部变量表第 0 位的 int 值加载到操作数栈顶

iadd:弹出操作数栈顶的两个 int 类型值,执行加法,把结果压回栈顶

步骤 指令 局部变量表 操作数栈 说明
0 - [] [] 初始状态
1 bipush 10 [] [10]
2 istore_0 [a=10] [] 存储到变量 a
3 bipush 20 [a=10] [20] 压入常量 20
4 istore_1 [a=10, b=20] [] 存储到变量 b
5 iload_0 [a=10, b=20] [10] 加载变量 a
6 iload_1 [a=10, b=20] [10, 20] 加载变量 b
7 iadd [a=10, b=20] [30] 执行加法
8 istore_2 [a=10, b=20, c=30] [] 存储结果到 c
9 iload_2 [a=10, b=20, c=30] [30] 加载返回值
10 ireturn 销毁 清空 返回
java 复制代码
public class TestUser {

    private int count;

    public void test(int a) {

        count = count + a;

    }

    public User initUser(int age, String name) {

        User user = new User();

        user.setAge(age);

        user.setName(name);

        return user;

    }

    public static void testAdd(){
        int i = 0;
        i = i++;
        System.out.println(i);
    }

    public static void main(String[] args) {
        new TestUser().test(1);
    }
}


class User {
    private String name;
    private int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

}

test() 函数:

aload_0:从局部变量0中装载引用类型值入栈

getfield:操作数栈顶的对象引用出栈,通过该引用找到对应对象的目标字段,读取字段值,将字段值压入操作数栈

putfield:操作数栈顶的新值先出栈,接着栈顶的对象引用出栈,通过引用找到对应对象的目标字段,将新值赋值给该字段

步骤 字节码指令 局部变量表 操作数栈 核心说明
初始 - [0:this, 1:a] [] 方法执行前初始化
1 aload_0 [0:this, 1:a] [this] 加载this引用推入操作数栈(为 count 赋值准备)
2 aload_0 [0:this, 1:a] [this, this] 再次加载this引用推入栈(为读取 count 字段准备)
3 getfield #2 <cn/tx/test/TestUser.count : I> [0:this, 1:a] [this, count 当前值] 弹出this,读取堆中count值并压回栈
4 iload_1 [0:this, 1:a] [this, count 当前值,a] 加载入参a的值并推入操作数栈
5 iadd [0:this, 1:a] [this, count+a 结果] 弹出栈顶两个int值,相加后将结果压栈
6 putfield #2 <cn/tx/test/TestUser.count : I> [0:this, 1:a] [] 弹出结果和this,将结果赋值给堆中this的count字段
7 return 销毁 清空 无返回值,方法执行结束,局部变量表销毁,操作数栈清空

initUser() 方法:

new:堆中创建指定类的对象实例,仅分配内存与初始化对象头(未执行构造器)

dup:复制操作数栈顶的单个元素,将复制结果压入栈顶

invokespecial:编译期静态绑定,调用方法(构造器、私有方法、父类方法,不支持方法重写)

invokevirtual:运行期动态绑定,调用普通实例方法,根据对象实际类型确定调用版本(支持方法重写)

astore_0:将栈顶的引用类型值存入局部变量表索引 0 位

aload_0:从局部变量表索引 0 位加载引用类型值到操作数栈顶

areturn:弹出操作数栈顶的引用类型值,作为方法返回值

步骤 字节码指令 局部变量表 操作数栈 核心说明
初始 - [0:this, 1:age, 2:name] [] 方法初始化
1 new #3 <cn/tx/test/User> [0:this, 1:age, 2:name] [User 对象的内存引用] 在堆中创建 User 对象,将对象引用压栈
2 dup [0:this, 1:age, 2:name] [User 引用,User 引用] 复制栈顶的 User 引用(为后续给构造器和赋值用)
3 invokespecial #4 <User. : ()V> [0:this, 1:age, 2:name] [User 引用] 弹出User引用,调用无参构造器(初始化对象)
4 astore_3 [0:this, 1:age, 2:name, 3:user] [] 弹出栈顶的 User 引用,存入局部变量表
5 aload_3 [0:this, 1:age, 2:name, 3:user] [user 引用] 加载局部变量表user引用,推入操作数栈(为调用 setAge 准备)
6 iload_1 [0:this, 1:age, 2:name, 3:user] [user 引用,age 值] 加载局部变量表的 age 值入栈
7 invokevirtual #5 <User.setAge : (I)V> [0:this, 1:age, 2:name, 3:user] [] 弹出 age 值 + user 引用,调用 user 的 setAge 方法(实例方法)
8 aload_3 [0:this, 1:age, 2:name, 3:user] [user 引用] 再次加载 user 引用入栈(为调用 setName 准备)
9 aload_2 [0:this, 1:age, 2:name, 3:user] [user 引用,name 引用] 加载name引用入栈(setName 的入参)
10 invokevirtual #6 <User.setName : (Ljava/lang/String;)V> [0:this, 1:age, 2:name, 3:user] [] 弹出name引用和user引用,调用setName方法
11 aload_3 [0:this, 1:age, 2:name, 3:user] [user 引用] 加载 user 引用入栈(为返回做准备)
12 areturn 销毁 清空 弹出栈顶user引用,作为方法返回值结束方法

testAdd() 方法:

步骤 字节码指令 局部变量表 操作数栈 核心说明
初始 - [] [] 方法初始化,无数据
1 iconst_0 [] [0] 压入常量 0 入栈
2 istore_0 [0:i=0] [] 弹 0 存入局部变量 i(索引 0)
3 iload_0 [0:i=0] [0] 加载 i 的旧值 0 入栈
4 iinc 0 by 1 [0:i=1] [0] 局部变量 i 直接 + 1(不操作栈)
5 istore_0 [0:i=0] [] 弹栈中旧值 0,覆盖 i(变回 0)
6 getstatic #7 <System.out> [0:i=0] [out 引用] 获取 System.out 引用压栈
7 iload_0 [0:i=0] [out 引用,0] 加载 i 值 0 入栈(println 入参)
8 invokevirtual #8 <println(I)V> [0:i=0] [] 调用 println,输出 0
9 return 销毁 清空 方法结束

帧数据区

帧数据区作为 JVM 栈帧三大核心组成部分(局部变量表、操作数栈、帧数据区)之一,是支撑方法完整执行的非计算型辅助管理中枢,它不直接存储数值计算类数据,核心通过承载三类关键信息实现核心职责:

  • 一是保存运行时常量池指针以完成动态链接,将字节码中的符号引用解析为实际内存地址,支撑方法调用、字段访问等操作;
  • 二是记录方法返回地址,确保方法执行完毕后能回到调用者的下一条指令位置,保障方法调用链闭环;
  • 三是维护异常处理表,通过指令地址与异常类型的匹配机制实现 JVM 层面的异常捕获与处理,此外还可存储调试、监控类辅助信息;

该区域与局部变量表、操作数栈协同工作,为二者的正常运行提供上下文保障,是确保字节码指令正确流转、方法全生命周期可控执行的关键底层基础设施。

帧数据区与栈帧其他部分的协同关系

栈帧的三大组成部分(局部变量表、操作数栈、帧数据区)并非独立工作,而是紧密协同支撑方法执行:

  • 局部变量表存储方法的局部变量,操作数栈承载计算过程,帧数据区通过保存运行时常量池指针实现动态链接,将字节码中的符号引用解析为目标方法 / 字段的直接内存地址,确保方法调用、字段访问等指令能正确执行;
  • 方法执行中抛出异常时,帧数据区的异常处理表指导 JVM 匹配异常类型和指令地址,定位对应的 catch 块地址;此时当前栈帧的操作数栈会被清空,局部变量表数据虽可能保留,但 catch 块会基于新的执行上下文使用这些数据(而非直接沿用原计算过程的操作数栈数据);
  • 方法正常返回时,帧数据区的返回地址指导 JVM 将程序计数器重置为调用方的下一条指令地址,恢复调用方执行;同时当前操作数栈的顶部元素(返回值)会被弹出并传递到调用方的操作数栈中。

栈上分配

栈上分配是 Java 虚拟机(JVM)针对内存分配的核心优化技术之一,核心目标是规避堆内存分配的性能开销。

默认情况下,Java 代码中通过 new 关键字创建的对象会被分配到堆内存(Heap)中:堆是所有线程共享的内存区域,对象的内存分配需要经过内存布局、指针碰撞 / 空闲列表等复杂逻辑,且对象的销毁完全依赖垃圾回收器(GC)完成。频繁的堆对象创建与销毁会触发高频 GC,带来显著的性能开销(如 STW 停顿、内存碎片)。

栈上分配的核心思想是:针对经逃逸分析判定为 "无逃逸" 的方法内局部小对象(即对象仅在当前方法内创建和使用,不会被外部方法、线程或全局变量引用),JVM 不会将其完整分配到堆中,而是通过标量替换机制,将对象拆解为基本类型或引用类型的标量(栈可直接存储的最小不可拆分单元),并将这些标量分配到当前线程私有的虚拟机栈的栈帧中。

这种分配方式的核心优势在于:当方法执行完毕、对应的栈帧出栈时,栈上的标量化对象数据会被自动销毁,完全无需 GC 介入。这不仅减少了堆内存的分配压力,还大幅降低了 GC 触发频次,能显著提升高频创建临时小对象场景下的系统性能。

逃逸分析

栈上分配的前提条件是进行逃逸分析。

逃逸分析(Escape Analysis)是 JVM 即时编译器(JIT)在编译阶段执行的一种数据流分析技术,其核心目标是分析方法内创建对象的作用域与生命周期,精准判断对象是否会 "逃逸" 出当前方法或当前线程的控制范围。

对象的逃逸判定

对象的 "逃逸" 本质是作用域的向外扩散,具体分为两个层级,满足任意一个即判定为 "逃逸":

  • 方法逃逸:方法内创建的对象,被传递到当前方法之外的区域;
    典型场景:对象作为方法返回值返回、对象被赋值给类的成员变量 / 静态变量、对象作为参数传入其他方法的参数并被外部存储。
  • 线程逃逸:方法内创建的对象,被当前线程之外的其他线程访问;
    典型场景:对象被放入多线程共享的集合、对象被传递给线程池的 Runnable/Callable 任务、对象被其他线程的引用变量持有。

只有被判定为 "无逃逸" 的对象,才具备栈上分配的资格。

如下代码显示了一个逃逸的对象

java 复制代码
private static User u;

public static void alloc() {
    u=new User();
    u.id=5;
    u.name="geym";
}

上述代码中,u 是类的静态成员变量,其生命周期脱离了 alloc() 方法的栈帧(可被当前类的任意方法、甚至其他线程访问),因此该 User 对象发生了逃逸,最终必然被分配在堆上。

java 复制代码
public static void alloc1() {
    User u=new User ();
    u.id=5;
    u.name="geym";
}

此代码中,User 对象仅以局部变量形式存在于 alloc1() 方法内,既未被方法返回,也未通过任何方式暴露到方法外部(如赋值给全局变量、传递给其他方法),因此该对象未发生逃逸。

标量替换

标量替换是指:JVM 编译器在逃逸分析判定对象为无逃逸后,JIT 编译器会将该对象拆解为与成员变量一一对应的若干标量(不可再分的基本数据类型),并将原本在堆上的对象内存分配转换为在栈上的标量变量分配,方法执行结束后栈帧弹出,这些标量变量会被自动销毁。

标量替换优化的验证示例

java 复制代码
public class OnStackTest {

    public static class User {
        public int id = 0;
        public String name = "";
    }

    /**
     * 逃逸分析
     */
    public static void alloc() {
        User u = new User() ;
        u.id=5;
        u.name="geym";

    }

    public static void main(String[] args) throws InterruptedException {
        long b = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            alloc();
        }
        long e = System.currentTimeMillis();
        System.out.println(e - b);
    }
}

上述代码在 main 方法中循环 1 亿次调用 alloc() 方法创建 User 对象:单个 User 对象的内存占用约 16 字节,若每个对象都在堆上分配,累计需占用约 1.5 GB 内存;而我们仅为 JVM 配置 10 MB 堆空间,若对象真的在堆上分配,必然会触发频繁的垃圾回收(GC)。

为验证优化效果,使用以下 JVM 参数运行上述代码:

-server -Xmx10m -Xms10m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-UseTLAB -XX:+EliminateAllocations

各参数的核心作用:

  • -server:指定 JVM 以 Server 模式运行(逃逸分析仅在 Server 模式下默认启用;64 位 Java 7+ 已默认采用 Server 模式,可省略);
  • -Xmx10m -Xms10m:将 JVM 堆空间的最大值和初始值均设为 10 MB,制造堆空间不足的场景,放大 GC 效果;
  • -XX:+DoEscapeAnalysis:显式启用逃逸分析(Server 模式下默认开启,显式指定可提升可读性);
  • -XX:+PrintGC:打印 GC 日志,用于判断是否触发了垃圾回收;
  • -XX:-UseTLAB:关闭线程本地分配缓冲区(TLAB),避免 TLAB 机制掩盖堆分配的真实 GC 行为;
  • -XX:+EliminateAllocations:开启标量替换优化(Java 7+ 已默认开启),允许 JVM 将无逃逸对象拆解为标量分配在栈帧中。

运行结果如下:

实际运行代码后,控制台无任何 GC 日志输出,程序快速执行完毕。

这表明:JVM 并未在堆上分配 User 对象,而是通过逃逸分析识别出对象无逃逸后,结合标量替换优化将对象拆解为标量分配在栈帧中(栈帧随方法执行结束自动销毁,无需 GC 回收)。

若关闭逃逸分析或标量替换中的任意一项,再次运行程序会看到大量 GC 日志输出 ------ 这证明标量替换完全依赖逃逸分析的结果,且二者需配合生效。

不启用逃逸分析:

-server -Xmx10m -Xms10m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:-UseTLAB -XX:+EliminateAllocations

不启用标量替换:

-server -Xmx10m -Xms10m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-UseTLAB -XX:-EliminateAllocations

综上,标量替换(栈上分配优化)为零散小对象的分配提供了高效的优化策略:栈帧分配速度远快于堆分配,且无需 GC 参与,能有效降低垃圾回收的性能损耗;但栈帧的内存空间远小于堆空间,因此体积较大的对象无法也不适合通过该方式优化,仍需分配在堆上。

JVM 基于逃逸分析实现的性能提升策略:

栈上分配:无逃逸对象分配至栈,规避堆 GC;

标量替换:为栈上分配提供技术实现手段;

同步消除:若无逃逸对象的同步锁仅被当前线程访问,JVM 会消除该锁,规避锁竞争开销。

相关推荐
珂朵莉MM21 小时前
全球校园人工智能算法精英大赛-产业命题赛-算法巅峰赛 2025年度画像
java·人工智能·算法·机器人
芒克芒克21 小时前
本地部署SpringBoot项目
java·spring boot·spring
cute_ming21 小时前
关于基于nodeMap重构DOM的最佳实践
java·javascript·重构
sww_102621 小时前
Netty原理分析
java·网络
小突突突21 小时前
Spring框架中的单例bean是线程安全的吗?
java·后端·spring
iso少年21 小时前
Go 语言并发编程核心与用法
开发语言·后端·golang
故事不长丨21 小时前
C#字典(Dictionary)全面解析:从基础用法到实战优化
开发语言·c#·wpf·哈希算法·字典·dictionary·键值对
Sun_小杰杰哇1 天前
Dayjs常用操作使用
开发语言·前端·javascript·typescript·vue·reactjs·anti-design-vue
雒珣1 天前
Qt简单任务的多线程操作(无需创建类)
开发语言·qt