JVM 内存区域与栈帧详解

内容涵盖:运行时数据区、虚拟机栈栈帧、符号引用/直接引用、常量池、操作数栈实例。

目录

  1. JVM 运行时数据区
  2. Java 虚拟机栈 · 栈帧详解
  3. 符号引用 vs 直接引用
  4. 常量池:class 常量池 / 运行时常量池 / 字符串常量池
  5. 操作数栈实例

一、JVM 运行时数据区

运行时数据区主要分为线程私有和线程共享两大类,共 5 个核心区域(外加一个常被提及的"直接内存")。

线程私有(随线程创建/销毁)

1. 程序计数器(Program Counter Register)

  • 作用:记录当前线程正在执行的字节码指令地址(行号指示器)。
  • 特点:每个线程一份;是唯一一个规范中不会抛出 OOM 的区域。
  • 用于多线程切换后恢复到正确执行位置(分支、循环、跳转、异常处理、线程恢复都依赖它)。

2. Java 虚拟机栈(JVM Stack)

  • 作用:描述 Java 方法的执行过程。每个方法调用会创建一个栈帧(Stack Frame)。
  • 特点:方法调用入栈、返回出栈。
  • 异常:栈深度超上限抛 StackOverflowError(如递归过深);无法分配内存抛 OutOfMemoryError。

3. 本地方法栈(Native Method Stack)

  • 作用:为 Native 方法(native 修饰、由 C/C++ 等实现的方法)服务。
  • 特点:HotSpot 中与虚拟机栈合二为一。
  • 异常:同样会抛 StackOverflowError 和 OutOfMemoryError。

线程共享(所有线程共享)

4. Java 堆(Heap)

  • 作用:存放几乎所有对象实例和数组,是 GC 管理的主要区域。
  • 细分:新生代(Eden + Survivor S0/S1)、老年代。
  • 异常:堆中无内存完成实例分配且无法扩展时抛 OutOfMemoryError: Java heap space。

5. 方法区 / 元空间(Method Area / Metaspace)

  • 作用:存储类信息(类的版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码缓存等。
  • 演进:
    • JDK 7 及以前:HotSpot 用"永久代(PermGen)"实现,属于堆的一部分。
    • JDK 8 及以后:用元空间(Metaspace)替代永久代,改用本地内存。
  • 异常:永久代/元空间耗尽抛 OutOfMemoryError: Metaspace。
  • 包含运行时常量池(Runtime Constant Pool):存放编译期生成的字面量、符号引用,以及运行期动态加入的常量(如 String.intern())。

额外:直接内存(Direct Memory)

  • 不属于运行时数据区,也不是堆,而是直接分配的本地内存。
  • 作用:NIO 的 ByteBuffer.allocateDirect() 使用,避免在 Java 堆和 Native 堆间复制数据,提升 IO 性能。
  • 异常:受本机物理内存限制,也会抛 OutOfMemoryError(如 Direct buffer memory)。

小结表

区域 线程归属 存储内容 常见异常
程序计数器 私有 字节码指令地址 无
虚拟机栈 私有 栈帧(局部变量、操作数栈等) SOF / OOM
本地方法栈 私有 Native 方法调用 SOF / OOM
堆 共享 对象实例、数组 OOM: heap space
方法区/元空间 共享 类信息、常量、静态变量 OOM: Metaspace
直接内存 — NIO 缓冲 OOM: Direct buffer

二、Java 虚拟机栈 · 栈帧详解

每个方法调用会创建一个栈帧,栈帧里装着该方法"运行所需的全部现场数据"的容器,包含 4 个部分:

1. 局部变量表(Local Variable Table)

存放方法的参数和内部定义的局部变量,以"槽位(Slot)"为单位,按声明顺序排布。

对 add(int a, int b)(实例方法)来说:

槽位索引 内容
0 this(隐藏参数,实例方法才有)
1 参数 a
2 参数 b
3 局部变量 c
  • 静态方法没有 this,槽位 0 就是第一个参数。
  • 基本类型占 1 个槽;long/double 占 2 个槽;引用类型存的是指向堆中对象的引用(地址),不是对象本身。
  • 大小在编译期确定,运行期不变。

2. 操作数栈(Operand Stack)

方法执行时的"工作台"。字节码指令在这里进行运算——先把操作数压入栈,执行指令时弹出、计算,再把结果压回。

以 int c = a + b 对应字节码:

0: iload_1    // 局部变量槽位1(a) 压入操作数栈
1: iload_2    // 槽位2(b) 压入操作数栈
2: iadd       // 弹出栈顶两个值相加,结果压回栈顶
3: istore_3   // 弹出栈顶结果,存入局部变量槽位3(c)
iload_1   ->   [a]
iload_2   ->   [a, b]
iadd      ->   [a+b]      // 弹出a、b,压入和
istore_3  ->   [ ]        // 弹出和,写回 c

3. 动态链接(Dynamic Linking)

栈帧里保存着一个指向运行时常量池中该栈帧所属方法的引用。方法里调用其他方法、访问字段时,通过它去常量池找到对应的符号引用,再解析成直接引用。

为什么叫"动态":一部分符号引用在编译期无法确定,需要运行期才能解析(比如多态、invokedynamic 的 Lambda)。

4. 方法返回地址(Return Address)

记录方法执行完后"回到哪里继续执行"——即调用该方法的下一条指令地址。

方法返回时:

  1. 把返回值放到调用者的操作数栈上;
  2. 弹出当前栈帧(销毁);
  3. 根据栈帧里保存的返回地址,让调用者继续从"调用该方法的下一条指令"往下执行。

栈帧纵览

┌─────────────────────────────┐
│        栈帧(Stack Frame)      │
├─────────────────────────────┤
│  局部变量表                    │ ← this、参数、局部变量(按槽位)
│  操作数栈                     │ ← 运算的临时工作台
│  动态链接                     │ ← 指向常量池中方法的引用
│  方法返回地址                 │ ← 调用者的下一条指令
│  (可能还有附加信息,如调试信息) │
└─────────────────────────────┘

一句话:方法被调用 → 新建栈帧压栈 → 参数进局部变量表 → 操作数栈做运算 → 动态链接找被调用的方法 → 返回时按返回地址回到调用者并弹出栈帧。


三、符号引用 vs 直接引用

理解两者关键在于区分 编译期 和 运行期。

符号引用(Symbolic Reference)

  • 定义:一组字符串描述,用"名字 + 描述符"方式文字性指明目标方法/字段是谁。
  • 阶段:编译期产物,存在于字节码(.class 文件)的常量池里。
  • 它描述的是"方法叫什么、什么签名",而不是"在内存哪个位置"。

例如常量池项:

Methodref  #5.#23   // 指向类 #5、名字和类型 #23
#5  = Class              #6   // java/lang/String
#23 = NameAndType        #24:#25
#24 = add                    // 方法名 add
#25 = (II)I                  // 描述符:两个int参数,返回int

直接引用(Direct Reference)

  • 定义:能直接定位到目标的引用,是真实的内存地址、偏移量或句柄。
  • 阶段:运行期,类加载/解析后得到。
  • 例如方法在方法区(元空间)里的实际入口地址、字段在实例中的偏移量。

核心区别对比

维度 符号引用 直接引用
产生阶段 编译期(.class 常量池) 运行期(类加载解析后)
本质 字符串描述(名称+描述符) 内存地址 / 偏移量 / 句柄
依赖 不依赖具体内存布局 依赖目标类已加载、地址已确定
是否可直接定位 否,需先解析 是,直接跳转/取值
举例 add (II)I 方法在元空间入口地址 0x00007f...

一句话:符号引用是"门牌号上的名字",直接引用是"GPS 坐标"。

为什么分两个

.class 文件在编译时,目标类可能还没被 JVM 加载,内存布局未知,编译器没法填真实地址。符号引用实现"编译与运行解耦"——编译期只写字符串描述,运行期类加载器把类装进内存后再解析成地址。这支撑了 Java 的动态链接和延迟解析(用到才解析) 特性。


四、常量池:class 常量池 / 运行时常量池 / 字符串常量池

三个易混概念

class 常量池 运行时常量池 字符串常量池
又叫 Constant Pool Table Runtime Constant Pool String Pool / String Table
在哪 .class 文件里(磁盘) 方法区/元空间(JDK8+) 堆里(JDK7 起移到堆)
什么时候有 编译期 类加载时 运行期
存什么 字面量 + 全部符号引用 class 常量池的运行时版本 只有 String 对象
本质 静态表 表 哈希表(类似 HashSet)

class 文件常量池

javac 编译后写进 .class 文件里的一个表,存两类东西:

  1. 字面量:字符串、数值常量、static final 的常量值。
  2. 符号引用:类/接口全限定名、字段名和描述符、方法名和描述符。

它是字节码指令的数据来源——每条指令通过 #编号 去这个表里查。

常见项类型:Class、Methodref、Fieldref、InterfaceMethodref、String、Utf8、Integer、NameAndType、MethodHandle、MethodType、InvokeDynamic。

关键区别

  1. 内容不同:class/运行时常量池存所有符号引用和字面量;字符串常量池只存字符串对象。
  2. 位置不同:运行时常量池在元空间;字符串常量池在堆。
  3. 有交集,但不等同:代码里的字符串字面量先在 class 常量池里有 String 项,运行期被 intern 进字符串常量池成为真正的 String 对象。

字符串常量池示例

String s1 = "成功";              // ① 编译期:class 常量池有 String 字面量 "成功"
                               // ② 运行期:字面量放进字符串常量池(堆里的 String 对象)
String s2 = "成功";              // ③ 直接从字符串常量池取同一对象,s1 == s2 为 true
String s3 = new String("成功");  // ④ new 出来的另开一块,不进池,s3 != s1
  • ① 涉及 class 常量池 / 运行时常量池;
  • ②③ 涉及字符串常量池;
  • 两者是"原料"和"成品池"的关系。

五、操作数栈实例

以 compute(int x) 方法执行 return this.add(x, 10) 为例。

字节码

aload_0          // ① 把 this 压入操作数栈
iload_1          // ② 把 x(槽位1)压入操作数栈
bipush 10        // ③ 把常量 10 压入操作数栈
invokevirtual  #N  // ④ 调用 Calc.add:(II)I
ireturn          // ⑤ 返回栈顶的 int 结果

compute 的局部变量表:

槽位 内容
0 this(Calc 实例引用)
1 参数 x

操作数栈逐步变化(假设 x = 5)

初始           →  栈: [ ]
① aload_0     →  栈: [ this ]
② iload_1     →  栈: [ this, 5 ]
③ bipush 10   →  栈: [ this, 5, 10 ]   ← 栈顶
④ invokevirtual → 弹出 this、5、10 交给 add
                  add 执行完返回结果 15
                  →  栈: [ 15 ]        ← 返回值压回 compute 的栈
⑤ ireturn     →  弹出栈顶 15 作为返回值
                  →  栈: [ ](compute 返回,栈帧销毁)

关键点

  • 操作数栈是后进先出的:压栈顺序 this → x → 10,栈顶是 10。
  • 调用方法时参数按从左到右依次压栈,最左边的接收者 this 在栈底,最后一个参数在栈顶。
  • add 有自己独立的栈帧和操作数栈,不共用 compute 的栈;方法间通过"压参数、弹结果"传递数据。
  • 这就是每个方法都要有自己的操作数栈的原因。
posted on 2026-10-27 18:56  一个痴迷于技术的码农  阅读(21)  评论(0)    收藏  举报