JVM 运行时数据区:堆、虚拟机栈、本地方法栈、程序计数器、元空间(051)

JVM原理与调优约 19 分钟阅读10,603 字符
知识库文档 52 / 251

一、全景概览:JVM 内存布局的"五大区域"

Java 程序员每天都在 new 对象、调用方法、访问静态变量,但很少有人停下来想一想:这些操作背后,JVM 到底把数据放在了哪里?new Object() 分配的对象在堆上,方法里的局部变量在栈上,类的元信息在方法区——这些概念面试必考,但真正理解它们各自的设计意图、生命周期、以及区域之间的协作关系,才是从"知道"到"理解"的关键一步。

JVM 规范(《Java 虚拟机规范》第 2.5 节)定义了 JVM 运行时数据区的五个主要组成部分:

  • 程序计数器(Program Counter Register)——当前线程正在执行的字节码指令地址
  • Java 虚拟机栈(JVM Stack)——方法调用的栈帧,存储局部变量、操作数栈、动态链接等
  • 本地方法栈(Native Method Stack)——为 Native 方法服务的栈
  • 堆(Heap)——所有对象实例和数组的存储区域
  • 方法区(Method Area)——存储类的元数据、运行时常量池、静态变量等

这几个区域可以按"线程共享"维度分为两大类:

线程私有(每个线程独立一份,生命周期与线程绑定):程序计数器、虚拟机栈、本地方法栈。这三个区域随线程创建而创建,随线程销毁而销毁,不存在线程安全问题。

线程共享(所有线程共享同一份,生命周期与 JVM 进程绑定):堆、方法区。它们的创建和销毁时机由 JVM 启动和关闭决定,多线程同时访问时需要考虑线程安全问题。

理解这个"谁共享谁私有"的划分非常重要,因为它直接影响了内存模型的设计:为什么堆上的对象需要加锁而栈上的局部变量不需要?因为栈是线程私有的,天然隔离;而堆是所有线程共用的,并发写入必须同步。

文章配图
文章配图

二、程序计数器:线程执行的"行号指示器"

程序计数器(PC Register)是五个区域中最简单、但也最容易被低估的一个。它是一块很小的内存空间,可以理解为"当前线程正在执行的字节码指令的行号指示器"。

为什么需要它?

JVM 的多线程是通过 CPU 时间片轮转来实现的。在任何一个确定的时刻,一个 CPU 核心只能执行一个线程中的一条指令。为了让线程切换后能准确地恢复到"刚才切出去时执行到哪了"的位置,每个线程都必须独立记录自己当前执行到了字节码的哪条指令——这就是程序计数器的核心作用。

如果当前线程正在执行的是一个 Java 方法,程序计数器记录的就是正在执行的虚拟机字节码指令的地址;如果当前线程正在执行的是一个 Native 方法,程序计数器的值为 undefined(因为 Native 方法的执行已经脱离了 JVM 的控制范围,交给了操作系统层面的本地代码)。

两个独特之处

  1. 它是 JVM 规范中唯一没有规定任何 OutOfMemoryError 情况的区域。程序计数器的大小是固定的,不会随线程执行动态增长,所以不存在内存溢出的可能。
  2. 它是 JVM 内存模型中最小的区域,小到很多 JVM 实现(比如 HotSpot)都没有为它单独分配一块独立的内存,而是把它嵌在线程控制块(TCB)里。

面试中的常见误区

一个常见的误解是:"程序计数器存储的是当前线程执行到的行号"。但实际上,它存储的是字节码指令的偏移地址,而不是 Java 源代码的行号。虽然 JVM 可以通过字节码的 LineNumberTable 属性反查源码行号,但程序计数器本身并不直接存储源码行号,它存储的是字节码层面的指令地址。另外,初学者容易把程序计数器和 CPU 的 PC 寄存器混为一谈——CPU 的 PC 寄存器是硬件层面的寄存器,存储的是当前要执行的机器指令地址;而 JVM 的程序计数器是 JVM 规范定义的一个抽象概念,存储的是字节码指令地址,两者不在同一个抽象层面。

三、虚拟机栈:方法的"调用轨迹与局部变量表"

虚拟机栈是 Java 方法执行的内存模型。JVM 每调用一个方法,就会创建一个栈帧(Stack Frame)压入虚拟机栈;方法执行完毕(无论正常返回还是抛出异常),对应的栈帧就会从栈中弹出。这种"后进先出"的结构天然地对应了方法调用的嵌套关系。

栈帧的内部结构

每个栈帧包含四个核心部分:

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

存储方法参数和方法内定义的局部变量。它是一组变量值的存储空间,以"槽"(Slot)为最小单位。一个 Slot 可以存放一个 boolean、byte、char、short、int、float、reference 或 returnAddress 类型的数据,而 long 和 double 这两种 64 位类型需要占用两个连续的 Slot。

局部变量表在编译期就已经确定大小——class 文件的 Code 属性中记录了 max_locals 的值,JVM 在创建栈帧时直接按这个值分配空间,运行时不会动态改变。这也是为什么 JVM 能提前知道一个方法需要多大的栈帧。

2. 操作数栈(Operand Stack)

也叫操作栈,是一个"后进先出"的栈结构。JVM 的字节码指令是基于栈的指令集架构,大多数指令都是在操作数栈上执行运算——先把操作数压入栈顶,执行运算,再把结果压回栈顶。比如 iadd 指令(整数加法)会从操作数栈顶弹出两个 int 值,相加后将结果压回栈顶。

操作数栈的最大深度也在编译期就确定了(max_stacks),所以 JVM 能提前为栈帧分配好操作数栈的空间。

3. 动态链接(Dynamic Linking)

每个栈帧中都包含一个指向运行时常量池中该栈帧所属方法的引用。这个引用的作用有两个:一是支持方法调用时的符号引用解析,二是支持方法体中使用的其他符号引用(比如访问其他类的字段、调用其他类的方法)。之所以叫"动态链接",是因为这些符号引用是在每一次运行期间才转换为直接引用的——而类加载阶段的"解析"步骤虽然也能把一部分符号引用转成直接引用,但最终用到的所有符号引用不一定在类加载时就能全部解析完,有一部分必须留到运行时按需解析,这就是动态链接存在的意义。

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

方法执行完毕后,需要回到调用者的位置继续执行。方法返回地址记录的就是调用者调用该方法的那条字节码指令之后的下一条指令地址。如果方法正常返回,调用者的程序计数器会恢复到返回地址指向的位置;如果方法抛出异常且在当前方法内没有找到匹配的异常处理器,栈帧被弹出,异常会沿调用链向上传播,此时返回地址不会启用。

文章配图
文章配图

两种异常

虚拟机栈可能抛出两种异常:

  • StackOverflowError:线程请求的栈深度超过了 JVM 允许的最大深度。比如无限递归、或者递归深度过大时就会触发。这个错误的典型场景是一个递归方法没有正确的终止条件,每次调用自身都压入一个新的栈帧,直到栈空间耗尽。
  • OutOfMemoryError:如果虚拟机栈允许动态扩展,但在扩展时无法申请到足够的内存,就会抛出 OOM。不过 HotSpot 虚拟机是不支持栈的动态扩展的——栈的容量在创建线程时就固定下来了(通过 -Xss 参数设置,默认约 1MB),所以 HotSpot 上虚拟机栈的 OOM 几乎不会发生,除非在创建线程时就已经没有足够内存分配给这个线程的栈了。

局部变量表的 Slot 复用

Java 代码中,局部变量的作用域结束后,它占用的 Slot 不会立即被清空,而是可以被后续声明的局部变量复用。这个机制在垃圾回收中有一个微妙的影响:如果一个局部变量在作用域结束后,虽然代码逻辑上不再使用它,但如果没有手动将它设为 null,且后续没有新的局部变量声明来复用这个 Slot,那么这个 Slot 仍然持有对原对象的引用,可能导致这个对象无法被 GC 回收——即使从代码逻辑上看它已经"不再被使用"了。这就是为什么在一些早期的 Java 编码规范中会建议手动将不再使用的大对象引用设为 null,不过现代 JVM 的即时编译器(JIT)已经能通过活跃变量分析在很大程度上缓解这个问题。

四、本地方法栈:Native 方法的"专属通道"

本地方法栈与虚拟机栈的作用非常相似,区别只在于:虚拟机栈为 JVM 执行 Java 方法服务,而本地方法栈为 JVM 执行 Native 方法(即用 C/C++ 等非 Java 语言编写的方法,通过 JNI 调用)服务。

在 HotSpot 虚拟机中,本地方法栈和虚拟机栈是直接合并在一起的——也就是说,HotSpot 的实现中,并没有单独为本地方法栈分配一块独立的内存区域,而是复用了虚拟机栈的结构。当你调用一个 Native 方法时,HotSpot 会在同一个栈上处理 Native 方法调用,只是这部分栈帧的管理方式略有不同。

本地方法栈和虚拟机栈一样,也会抛出 StackOverflowError 和 OutOfMemoryError。

在真实的 Java 开发中,大多数开发者不会直接接触到本地方法栈,因为现代 Java 生态中直接使用 JNI 的场景已经越来越少。但如果你在分析 JVM 崩溃日志(hs_err_pid.log)时,看到 Native 方法调用堆栈,就需要知道这部分信息来自本地方法栈。

五、堆:对象王国的"领土"

堆(Heap)是 JVM 管理的最大一块内存区域,也是垃圾回收(GC)的主战场。JVM 规范对堆的描述是:"所有的对象实例和数组都应该在堆上分配"。堆在 JVM 启动时创建,所有线程共享,大小可以通过 -Xms(初始堆大小)和 -Xmx(最大堆大小)参数设置。

分代结构

现代 JVM 的垃圾回收器大多采用分代收集(Generational Collection)理论,将堆内存划分为不同的区域,针对不同区域采用不同的回收算法。以经典的 G1 之前的分代布局为例:

  • 新生代(Young Generation):新创建的对象首先分配在这里。新生代又分为三个区域:
    • Eden 区(伊甸园区):绝大多数新对象在这里分配。这个名字来自《圣经》中的伊甸园,寓意"万物初始之地"。
    • 两块 Survivor 区(S0 和 S1,也叫 From 和 To):每次 Minor GC 时,Eden 和当前使用的 Survivor 区中存活的对象会被复制到另一块 Survivor 区。两块 Survivor 区始终有一块是空的,这种"复制-清空"的机制是复制算法(Copying)的核心。
  • 老年代(Old Generation/Tenured Generation):在新生代经历了多次 GC 仍然存活的对象(默认 15 次,由 -XX:MaxTenuringThreshold 控制),会被"晋升"(Promotion)到老年代。老年代中的对象通常生命周期较长,GC 频率远低于新生代。

新生代 GC 称为 Minor GC(也叫 Young GC),只回收新生代,速度较快,触发频率较高。老年代 GC 称为 Major GC / Full GC,通常伴随新生代的回收,比 Minor GC 慢得多(通常慢 10 倍以上),对应用响应时间的影响也更大——这也是 JVM 调优的核心目标之一:尽量减少 Full GC 的次数和停顿时间。

文章配图
文章配图

字符串常量池

字符串常量池(String Pool)从 JDK 7 开始被移到了堆中(之前在永久代/方法区中)。这个变动的动机是:方法区的 GC 回收条件比较苛刻,在方法区中的字符串常量池容易因为大量字符串的 intern() 操作导致空间无法及时回收,引发 OOM(典型的场景是 String.intern() 在 JDK 6 及之前与 JDK 7 之后的行为差异,这是面试高频考点)。移到堆中后,字符串常量池可以享受堆 GC 的常规回收机制,这个问题就解决了。

TLAB:线程本地分配缓冲区

堆是线程共享的,这意味着多线程同时分配对象时,需要同步操作来保证分配指针的正确性。但同步的成本很高,如果每次 new 一个对象都要加锁,Java 程序的性能将是灾难性的。

JVM 的解决方案是 TLAB(Thread Local Allocation Buffer,线程本地分配缓冲区)。每个线程在 Eden 区预先分配一小块私有内存,线程在这个私有缓冲区里分配对象时不需要加锁——只有缓冲区用完了,才需要去向 Eden 区申请一块新的 TLAB,这个申请过程才会涉及同步。TLAB 默认是开启的,大小通常占 Eden 的 1%,可以通过 -XX:+UseTLAB 和 -XX:TLABSize 调整。

栈上分配与逃逸分析

JVM 规范说"所有对象实例都在堆上分配",但现代 JVM 通过逃逸分析(Escape Analysis)和标量替换(Scalar Replacement)技术,可以在特定条件下将对象直接分配在栈上,从而避免堆分配和 GC 的压力。

逃逸分析判断一个对象的作用域是否"逃逸"出了当前方法:如果一个对象只在方法内部使用,没有被外部引用(包括没有作为返回值返回、没有被赋值给静态字段、没有被赋值给传入的参数对象等),那么这个对象就是"未逃逸"的。对于未逃逸的对象,JIT 编译器可以将其拆解为若干个基本类型的标量,直接分配在栈帧的局部变量表中,而不是在堆上分配一个完整的对象。这减少了堆内存的占用和 GC 的压力,是 JIT 编译器的重要优化手段之一。

六、元空间(方法区):类的"元数据中心"

方法区(Method Area)是 JVM 规范定义的一个概念,而元空间(Metaspace)是 HotSpot 在 JDK 8 中对方法区的一种具体实现。在 JDK 7 及之前,HotSpot 使用永久代(PermGen)来实现方法区;从 JDK 8 开始,永久代被彻底移除,取而代之的是元空间。

方法区存储什么?

方法区是线程共享的区域,主要存储以下内容:

  • 类的元数据:类的名称、访问修饰符、父类、接口、字段描述、方法描述等。
  • 运行时常量池(Runtime Constant Pool):class 文件中的常量池(Constant Pool Table)在类加载后进入方法区,成为运行时常量池。它包含编译期就确定的字面量(如字符串、final 常量值)和符号引用(如类名、方法名、字段名、方法描述符等)。
  • 静态变量(Class Variables):static 修饰的变量,属于类而不是实例。
  • 即时编译器编译后的代码缓存(JIT Code Cache):经过 JIT 编译后的本地机器码。

从永久代到元空间:为什么要有这个变化?

永久代在 JDK 8 被移除,改为使用本地内存(Native Memory)的元空间,主要有以下几个原因:

  1. 永久代大小难以设置:永久代的大小由 -XX:PermSize 和 -XX:MaxPermSize 限制,设置太小容易 OOM(尤其是动态生成大量类的场景,比如使用 CGLIB 动态代理、JSP 等),设置太大又浪费堆内存。因为永久代使用的是 JVM 堆内存的一部分,而元空间使用的是本地内存,默认情况下只受限于系统可用内存,不再需要为"类元数据存不下"而手动调参。
  2. 永久代 GC 复杂:永久代中存储的类元数据、字符串常量池等,GC 的回收条件很苛刻——一个类要被卸载,必须满足:该类所有的实例已被回收、加载该类的 ClassLoader 已被回收、该类对应的 java.lang.Class 对象没有被任何地方引用。这些条件在很多应用中很难同时满足,导致永久代 GC 效率不高,经常出现"明明还有很多空间但 OOM"的情况。
  3. 为未来的 JVM 优化铺路:将方法区的实现与堆解耦,可以为不同 GC 算法提供更大的灵活性。

文章配图
文章配图

元空间的关键参数

  • -XX:MetaspaceSize:元空间初始大小,达到该值时会触发 GC 进行类型卸载。默认值约 21MB(取决于平台)。
  • -XX:MaxMetaspaceSize:元空间最大值,默认无限制(受限于系统内存),但建议在生产环境设置一个上限,防止本地内存被无限占用。
  • -XX:MinMetaspaceFreeRatio 和 -XX:MaxMetaspaceFreeRatio:GC 后元空间的最小和最大空闲比例,影响元空间的大小调整策略。

方法区的"回收"

方法区的回收主要分为两类:废弃常量的回收和不再使用的类型的卸载。

废弃常量的回收相对简单:当一个常量(如字符串字面量)在运行时常量池中不再被任何引用时,就可以被 GC 回收。这和堆上对象的回收逻辑类似。

类型的卸载则复杂得多,必须同时满足三个条件:该类的所有实例已被回收;加载该类的 ClassLoader 已被回收;该类对应的 java.lang.Class 对象没有被任何地方引用。即使满足这三个条件,JVM 也只是"允许"回收,而非"一定会回收"——是否真的回收还取决于 GC 策略和参数设置。在生产环境中,使用大量动态代理(如 Spring AOP、Hibernate 等)或频繁热部署的应用(如 OSGi 容器)需要特别关注类的卸载机制,防止元空间持续膨胀。

七、各区域对比总结

将五个区域的核心特征放在一起对比,可以更清晰地把握它们的差异和联系:

区域 线程共享/私有 存储内容 生命周期 可能抛出的异常
程序计数器 线程私有 当前线程执行的字节码指令地址 随线程创建/销毁 无(JVM 规范唯一不规定 OOM 的区域)
虚拟机栈 线程私有 栈帧(局部变量表、操作数栈、动态链接、返回地址) 随线程创建/销毁 StackOverflowError、OutOfMemoryError
本地方法栈 线程私有 Native 方法的栈帧 随线程创建/销毁 StackOverflowError、OutOfMemoryError
堆 线程共享 对象实例、数组、字符串常量池(JDK 7+) JVM 启动/关闭 OutOfMemoryError
方法区/元空间 线程共享 类元数据、运行时常量池、静态变量、JIT 代码缓存 JVM 启动/关闭 OutOfMemoryError

文章配图
文章配图

八、面试追问链

从"JVM 运行时数据区包含哪几个区域"这个基础问题出发,逐层追问:哪些是线程私有、哪些是线程共享、为什么这样划分、虚拟机栈的栈帧内部结构是什么、局部变量表中的 Slot 复用机制对 GC 有什么影响、堆的分代结构是什么以及为什么这样设计、TLAB 如何解决并发分配的性能问题、逃逸分析和栈上分配的关系、永久代为什么被元空间替代、元空间使用什么内存以及如何控制其大小。这条链覆盖了面试官在这个话题上的核心考察点。

文章配图
文章配图

延伸阅读

  • 《Java 虚拟机规范》(Java SE 21 Edition)第 2.5 节 Run-Time Data Areas,理解 JVM 规范层面的原始定义
  • 《深入理解 Java 虚拟机》(周志明)第 2 章和第 3 章,覆盖运行时数据区与垃圾回收的详细实现
  • 「04 JVM 原理与调优」系列开篇(051),下一篇将深入 052「Java 对象内存布局:对象头、实例数据、对齐填充与指针压缩」

问答(不计入阅读时长)

Q1:JVM 运行时数据区包含哪几个区域?哪些是线程共享的,哪些是线程私有的?

A:五个区域分别是:程序计数器、虚拟机栈、本地方法栈、堆、方法区(元空间)。其中程序计数器、虚拟机栈、本地方法栈是线程私有的——每个线程独立拥有一份,随线程创建而创建,随线程销毁而销毁,天然线程安全。堆和方法区是线程共享的——所有线程共享同一份,生命周期与 JVM 进程绑定,多线程并发访问时需要考虑同步问题。

Q2:程序计数器有什么作用?为什么它是唯一一个不会抛出 OutOfMemoryError 的区域?

A:程序计数器记录的是当前线程正在执行的字节码指令地址,它的核心作用是让线程在 CPU 时间片切换后能恢复到"刚才切出去时执行到哪了"的位置。如果正在执行 Java 方法,它存储字节码指令地址;如果正在执行 Native 方法,它的值为 undefined。它不会抛出 OOM 的原因是:程序计数器的大小是固定的,只存储一个指令地址,不会随线程执行动态增长,所以不存在内存溢出的可能。JVM 规范中唯一没有规定 OOM 情况的区域就是它。

Q3:虚拟机栈的栈帧包含哪几个部分?每个部分的作用是什么?

A:栈帧包含四个核心部分:(1)局部变量表——存储方法参数和局部变量,以 Slot 为最小单位,long 和 double 占两个 Slot,其他类型占一个 Slot。大小在编译期由 max_locals 确定,运行时不变。(2)操作数栈——JVM 基于栈的指令集架构的核心,字节码指令在操作数栈上执行运算:压入操作数、执行指令、弹出结果。最大深度在编译期由 max_stacks 确定。(3)动态链接——指向运行时常量池中该栈帧所属方法的引用,支持符号引用在运行时解析为直接引用。(4)方法返回地址——记录调用者调用该方法后的下一条指令地址,方法正常返回时恢复调用者的程序计数器,异常抛出时沿调用链向上传播。

Q4:虚拟机栈可能抛出 StackOverflowError 和 OutOfMemoryError,两者有什么区别?在 HotSpot 上哪个更常见?

A:StackOverflowError 发生在线程请求的栈深度超过 JVM 允许的最大深度时,典型场景是无限递归——每次递归调用压入新栈帧,直到栈空间耗尽。OutOfMemoryError 在栈允许动态扩展但无法申请到足够内存时抛出。但在 HotSpot 虚拟机中,栈的容量在创建线程时就固定了(通过 -Xss 参数设置,默认约 1MB),不支持动态扩展,所以 HotSpot 上虚拟机栈的 OOM 几乎不会发生——绝大多数情况下,栈溢出报的都是 StackOverflowError。真正可能因栈导致的 OOM 发生在创建线程时:如果系统已经没有足够内存分配给新线程的栈空间,new Thread() 会抛出 OOM。

Q5:堆的分代结构中,为什么需要两块 Survivor 区?只有一块 Survivor 区不行吗?

A:两块 Survivor 区(S0 和 S1)是复制算法(Copying)的核心。Minor GC 时的流程是:将 Eden 区 + 当前使用的 Survivor 区中存活的对象复制到另一块空的 Survivor 区,然后清空 Eden 和刚才使用的 Survivor 区。这保证了每次 GC 后都有一块 Survivor 区是空的,下一轮 GC 时目标区始终是干净的。如果只有一块 Survivor 区,存活对象复制到 Survivor 区后,Eden 区清空了,但 Survivor 区同时存在"上一轮活下来的对象"和"这一轮刚复制过来的对象",内存碎片化严重,无法再使用简单的复制-清空策略。两块 Survivor 区交替使用,始终保持一块干净的空区,是复制算法高效运行的前提。

Q6:TLAB 是什么?它解决了什么问题?

A:TLAB(Thread Local Allocation Buffer,线程本地分配缓冲区)是 JVM 在 Eden 区为每个线程预先分配的一小块私有内存。问题是:堆是线程共享的,多线程同时 new 对象时,如果每次都要同步操作分配指针,加锁的开销会让 Java 程序的性能灾难性下降。TLAB 的解决方案是:线程在自己的 TLAB 里分配对象时不需要加锁——只有 TLAB 用完了,才需要向 Eden 区申请一块新的 TLAB,这个申请过程才涉及同步。TLAB 默认开启,大小通常占 Eden 的 1%,可以通过 -XX:+UseTLAB 和 -XX:TLABSize 调整。它是 JVM 在"线程共享堆"和"并发分配性能"之间找到的一个精巧平衡。

Q7:逃逸分析是什么?它和栈上分配有什么关系?

A:逃逸分析是 JIT 编译器的一种优化技术,判断一个对象的作用域是否"逃逸"出了当前方法。如果一个对象只在方法内部使用,没有被外部引用(没有作为返回值返回、没有赋值给静态字段、没有赋值给传入的参数对象),那么这个对象就是"未逃逸"的。对于未逃逸的对象,JIT 编译器可以将其拆解为若干个基本类型的标量,直接分配在栈帧的局部变量表中(标量替换),而不是在堆上分配一个完整的对象。这就是"栈上分配"的本质——它并不是真的在栈上创建了一个 Java 对象,而是把对象的字段拆散成标量分配在栈上。这减少了堆内存占用和 GC 压力,是 JIT 编译器的重要优化手段。

Q8:永久代为什么被元空间替代?元空间相比永久代有哪些优势?

A:三个主要原因:(1)永久代大小难以设置——由 -XX:MaxPermSize 限制,设小了容易 OOM(尤其在动态生成大量类的场景,如 CGLIB 代理、JSP),设大了浪费堆内存。元空间使用本地内存,默认无上限,只受限于系统可用内存。(2)永久代 GC 复杂且效率低——类卸载的条件苛刻(该类所有实例已回收、ClassLoader 已回收、Class 对象无引用),很多应用难以满足,导致永久代频繁 OOM。元空间将方法区实现与堆解耦,GC 策略更灵活。(3)为 JVM 未来优化铺路——字符串常量池和静态变量从方法区移到堆中后,可以享受堆 GC 的常规回收机制,不再受方法区回收条件苛刻的限制。不过生产环境建议通过 -XX:MaxMetaspaceSize 设置元空间上限,防止本地内存被无限占用。