Skip to content

Java 内存划分详解

很多人第一次学 Java 内存,会把它背成几块区域:

  1. 方法区
  2. 程序计数器

这样记不能说错,但还是太薄了。真正到了需要继续展开的时候,更可能继续追问:

  1. 这些区域分别存什么
  2. 哪些是线程私有,哪些是线程共享
  3. 堆为什么还要分新生代和老年代
  4. StackOverflowErrorOutOfMemoryError 分别通常出现在什么地方
  5. 方法区、永久代、元空间到底是什么关系

这篇文章就把 Java 的内存划分从“能背名词”讲到“能解释原理”。

1. Java 内存划分到底在说什么

“Java 内存划分”通常就是指 JVM 运行时数据区的划分。

也就是 Java 程序跑起来之后,JVM 会把运行时需要的内存按职责拆成不同区域,分别负责:

  1. 记录线程执行位置
  2. 管理方法调用过程
  3. 存放对象实例
  4. 存放类元数据和常量
  5. 支撑本地方法调用

这样做的好处是:

  1. 职责更清晰
  2. 管理方式不同
  3. 回收策略也可以不同

2. 先从大图上看:线程私有 vs 线程共享

最常见的一种记忆方式,是先按“线程维度”来分。

2.1 线程私有

每个线程都会有自己独立的一份:

  1. 程序计数器
  2. Java 虚拟机栈
  3. 本地方法栈

这些区域不会被其他线程直接共享,所以天然更贴近“线程执行过程”。

2.2 线程共享

多个线程共同使用:

  1. 方法区

它们更贴近“整个应用运行时的公共数据”。

如果只想先记一个框架,可以直接记成:程序计数器、虚拟机栈、本地方法栈是线程私有;堆和方法区是线程共享。

3. 程序计数器

3.1 它是什么

程序计数器可以理解成 当前线程下一条将要执行的字节码指令位置记录器

因为 Java 程序在 JVM 里执行时,本质上是在线程之间切换着跑的。线程一旦被挂起,再恢复时必须知道自己之前执行到了哪里。

这时候程序计数器就派上用场了。

3.2 它负责什么

它主要负责:

  1. 记录当前线程执行位置
  2. 在线程切换后恢复正确执行点
  3. 配合字节码解释执行流程

3.3 它的特点

  1. 线程私有
  2. 生命周期跟线程一致
  3. 占用内存通常很小
  4. JVM 规范里,它是少数不会规定出现 OutOfMemoryError 的区域

4. Java 虚拟机栈

4.1 它是什么

虚拟机栈是 Java 方法执行时的内存模型。

更具体地说:每次方法调用,都会对应一个栈帧入栈;方法执行结束,对应栈帧出栈。

所以它最适合从“方法调用过程”来理解。

4.2 栈帧里有什么

一个栈帧里通常至少包括:

  1. 局部变量表
  2. 操作数栈
  3. 动态链接
  4. 方法返回地址

你可以抓住最重要的两个:

  1. 局部变量表:存方法参数和局部变量
  2. 操作数栈:执行字节码计算时临时使用

4.3 一个简单例子

java
public int add(int a, int b) {
    int c = a + b;
    return c;
}

方法执行时:

  1. abc 这类局部变量会进入当前栈帧
  2. 计算过程会用到操作数栈
  3. 方法返回时,当前栈帧出栈

4.4 常见异常

最典型的是:StackOverflowError

例如无限递归:

java
public void test() {
    test();
}

因为方法不断入栈,栈深度持续增加,最终就会栈溢出。

在某些极端情况下,栈扩展失败也可能导致 OutOfMemoryError,不过平时更常见的情况还是:

递归过深更容易导致 StackOverflowError。

5. 本地方法栈

5.1 它是什么

本地方法栈和虚拟机栈很像,只不过它服务的是:native 方法。

也就是那些不是用 Java 本身实现,而是通过 C/C++ 等本地代码实现的方法。

5.2 它的作用

当 Java 程序调用本地方法时,本地方法栈会为这些调用过程提供支持。

你可以简单记成:

  1. Java 方法主要走虚拟机栈
  2. native 方法主要走本地方法栈

5.3 为什么它存在

因为 JVM 并不是一个完全封闭的世界。它需要:

  1. 调用操作系统能力
  2. 调用本地库
  3. 支持底层高性能组件

这就是 JNI 等机制存在的背景。

6. 堆

6.1 它是什么

堆是 Java 内存里最核心的一块区域,也是 GC 重点工作的地方。

最常见的理解是:对象实例大多分配在堆上。

比如:

java
User user = new User();

这里 new User() 创建出来的对象,通常就在堆里。

6.2 为什么堆重要

因为 Java 里的绝大多数对象生命周期管理,都发生在堆中。

堆负责的主要是:

  1. 对象实例存储
  2. 数组对象存储
  3. 垃圾回收的主要空间

6.3 堆为什么还要继续细分

因为对象并不是都活得一样久。

现实里经常是:

  1. 大量对象很快就死掉
  2. 少数对象会长期存活

如果所有对象都混在一起回收,成本会很高。

所以 HotSpot 通常会把堆再继续划分:

  1. 新生代
  2. 老年代

7. 新生代和老年代

7.1 新生代

新生代主要存放“新创建出来、生命周期偏短”的对象。

它通常还会再分成:

  1. Eden 区
  2. Survivor From
  3. Survivor To

大多数对象创建时先进入 Eden。

经历一次垃圾回收后:

  1. 活下来的对象会被复制到 Survivor 区
  2. 在 Survivor 中多次存活后,可能晋升到老年代

7.2 老年代

老年代主要存放:

  1. 生命周期较长的对象
  2. 大对象
  3. 从新生代晋升上来的对象

7.3 为什么要这样分代

因为不同生命周期的对象,适合不同的回收策略。

这就是分代回收的核心思想:让短命对象和长命对象分开管理,提高回收效率。

8. 方法区

8.1 它是什么

方法区是 JVM 规范里定义的一块逻辑区域,用来存放类级别的信息。

它主要存:

  1. 类信息
  2. 运行时常量池
  3. 静态变量
  4. 即时编译后的代码缓存等

你可以把它和堆区分开:

  1. 堆更偏对象实例
  2. 方法区更偏类元数据

8.2 永久代和方法区是什么关系

这是很高频的易错点。

正确理解是:

  1. 方法区是 JVM 规范里的概念
  2. 永久代是 HotSpot 早期对方法区的一种实现方式

也就是说:永久代不是方法区本身,而是方法区的一种实现。

8.3 元空间又是什么

Java 8 开始,HotSpot 移除了永久代,改用:Metaspace,元空间

来实现方法区。

所以现在更准确的说法是:

  1. 规范层面叫方法区
  2. HotSpot 在 Java 8 以后常说元空间

8.4 常见异常

如果类元数据不断增长,可能出现:OutOfMemoryError: Metaspace

常见诱因:

  1. 动态生成大量类
  2. 类加载器泄漏
  3. 热部署场景回收不及时

9. 运行时常量池

运行时常量池可以理解成方法区中的一部分。

它存放的是:

  1. 编译期生成的字面量
  2. 符号引用
  3. 运行期也可能加入的新常量

比如字符串相关问题里,常量池就是很高频的考点。

要注意:

  1. 运行时常量池属于方法区的逻辑范畴
  2. 字符串常量池在 HotSpot 的具体实现上,又有版本差异

所以答题时最好区分:

  1. 规范怎么定义
  2. HotSpot 怎么实现

10. 栈和堆到底怎么区分

这是最容易被问成场景题的一块。

10.1 先说一个不容易错的版本

你可以这样理解:

  1. 方法调用过程主要依赖栈
  2. 对象实例大多在堆上
  3. 局部变量通常跟着栈帧走

10.2 为什么不能说得太绝对

因为现代 JVM 会做很多优化,比如:

  1. 标量替换
  2. 栈上分配
  3. 逃逸分析

所以如果把这个问题压缩成一段完整说明得很深,就不要把它说死成:所有对象都一定在堆上。

更稳妥的说法是:通常情况下对象实例主要分配在堆上,方法调用和局部变量主要在栈相关结构里管理,但具体实现会受到 JVM 优化策略影响。

11. 常见异常分别对应哪里

这部分很适合快速拉开理解层次。

11.1 StackOverflowError

常见来源:

  1. 无限递归
  2. 递归层次过深
  3. 单次调用栈帧过大

核心区域:虚拟机栈

11.2 OutOfMemoryError: Java heap space

常见来源:

  1. 对象创建太多
  2. 内存泄漏
  3. 大对象堆积

核心区域:

11.3 OutOfMemoryError: Metaspace

常见来源:

  1. 动态代理生成过多类
  2. 类加载器泄漏
  3. 热部署反复加载类

核心区域:方法区 / 元空间

12. 一张图把它们串起来

可以把 JVM 内存结构压缩成下面这个脑图:

text
JVM 运行时数据区
├── 线程私有
│   ├── 程序计数器
│   ├── Java 虚拟机栈
│   └── 本地方法栈
└── 线程共享
    ├── 堆
    │   ├── 新生代
    │   │   ├── Eden
    │   │   ├── Survivor From
    │   │   └── Survivor To
    │   └── 老年代
    └── 方法区
        └── 运行时常量池

13. 用一段话把“Java 内存是怎么划分的”讲清楚

顺着下面这个顺序往下讲会更顺:

Java 的内存划分通常指 JVM 运行时数据区,主要包括程序计数器、虚拟机栈、本地方法栈、堆和方法区。程序计数器、虚拟机栈、本地方法栈是线程私有的;堆和方法区是线程共享的。程序计数器负责记录线程当前执行位置,虚拟机栈负责方法调用时的栈帧管理,本地方法栈服务 native 方法,堆主要存对象实例,也是 GC 的核心区域,而方法区主要存类信息、静态变量、常量等。堆里通常还会分新生代和老年代;方法区在 HotSpot 早期通过永久代实现,Java 8 以后更多对应元空间。

14. 几个容易混的点

14.1 堆和栈的区别是什么

把这 3 个点分开:

  1. 栈更贴近方法调用过程,线程私有
  2. 堆更贴近对象实例存储,线程共享
  3. 栈的分配和回收通常更直接,堆主要依赖 GC

14.2 为什么要分新生代和老年代

根子就在这里:因为不同对象生命周期差异很大。大多数对象朝生夕死,少数对象长期存活,所以把它们分开管理,可以用更合适的回收策略提升 GC 效率。

14.3 方法区和永久代是一个东西吗

实际写项目时,更多是这样:不能完全画等号。方法区是 JVM 规范定义的一块逻辑区域,永久代只是 HotSpot 早期对方法区的一种实现;Java 8 以后永久代被移除,改成元空间。

15. 总结

把 Java 内存划分压缩到最核心,其实就是三层:

  1. 线程执行相关:程序计数器、虚拟机栈、本地方法栈
  2. 对象存储与回收相关:堆
  3. 类元数据相关:方法区

如果你能把“线程私有 vs 线程共享、堆分代、方法区和元空间的关系、常见异常对应区域”这几条线讲顺,这一块基本就已经够应对大多数继续追问了。

基于 VitePress 构建的个人技术笔记。