前言
很多 Java 开发者刚接触 JVM 时,都背过 "堆、栈、方法区" 这些名词,但真问到:
一行代码里的变量、对象、类信息分别存在哪?
往往就模糊了。
还有一个经典面试题:
年轻代为什么要设计两个 Survivor 区?
很多人答不上来本质。
这篇文章用生活化类比 + 一段完整代码 + 数值模拟,带你彻底搞懂:
- JVM 运行时内存怎么划分
- 对象到底存在堆、栈还是元空间
- 年轻代为什么是 Eden + 双 Survivor
- Young GC 和 Full GC 的完整执行流程
- 怎么判断老年代高位是不是内存泄漏
一、📋 JVM 运行时数据区全景
JVM 执行 Java 程序时,会把内存划分成若干区域。先记住一个核心规律:
堆存对象本体,栈存引用和基本类型值,元空间存类描述。

| 区域 | 线程 | 存什么 | 常见异常 | 是否 GC |
|---|---|---|---|---|
| 堆 | 共享 | 对象实例、数组 | Java heap space |
✅ 主战场 |
| 方法区/元空间 | 共享 | 类元数据、常量池 | Metaspace |
极少 |
| 虚拟机栈 | 私有 | 栈帧(局部变量、操作数栈) | StackOverflowError |
❌ |
| 本地方法栈 | 私有 | Native 方法栈帧 | StackOverflowError |
❌ |
| 程序计数器 | 私有 | 当前字节码行号 | 唯一不会 OOM | ❌ |
方法区演进:永久代 → 元空间

| 版本 | 实现 | 位置 | OOM 报错 |
|---|---|---|---|
| JDK 7- | 永久代 PermGen | JVM 堆 | PermGen space |
| JDK 8+ | 元空间 Metaspace | 本地内存 | Metaspace |
为什么改 :永久代大小难预测易 OOM,元空间用本地内存自动伸缩更灵活。
注意 :静态变量、字符串常量池 JDK 7 之后也移到堆了。
二、💻 一段代码看懂对象存在哪
我们用一段代码,把每个元素对应到具体内存区域:
arduino
// 类元信息 → 元空间
public class MemoryDemo {
// 静态变量引用在堆的 Class 对象中,指向的对象实体在堆
private static final String STATIC_STR = "hello_static";
private static User staticUser = new User("静态用户");
// 成员变量属于对象实例,存放在堆的对象内部
private int age;
private String name;
// 方法字节码 → 元空间;执行时栈帧 → 虚拟机栈
public void testMethod() {
// 基本类型局部变量 → 栈的局部变量表
int num = 100;
// 对象引用存栈,new 出的对象实体存堆
User localUser = new User("本地用户");
// String 对象实体在堆(字符串常量池),引用在栈
String str = "test_str";
// 数组实体在堆,引用在栈
int[] arr = new int[10];
// 执行本地方法 → 使用本地方法栈
nativeMethod();
}
// 本地方法声明元信息在元空间;执行时使用本地方法栈
public native void nativeMethod();
// 内部类元信息 → 元空间
static class User {
private String userName;
public User(String userName) {
this.userName = userName;
}
}
}
| 元素 | 存储位置 | 说明 |
|---|---|---|
new User(...) 对象本体 |
堆 | 所有 new 出来的对象实体都在堆 |
STATIC_STR 字符串对象 |
堆 | 字符串常量池在 JDK 7 后移到堆 |
staticUser 引用 |
堆 | 静态变量引用存在 Class 对象中 |
age、name 成员变量 |
堆 | 属于对象实例的一部分 |
num = 100 |
虚拟机栈 | 基本类型值存在局部变量表 |
localUser、str、arr 引用 |
虚拟机栈 | 对象引用存在局部变量表 |
MemoryDemo.class 元数据 |
元空间 | 类结构、字段表、方法表 |
testMethod() 字节码 |
元空间 | 方法字节码存在方法区 |
nativeMethod() 执行 |
本地方法栈 | Native 方法使用本地方法栈 |
一句话总结:对象引用和基本类型值在栈,对象实体在堆,类元数据在元空间。
2.2 快速判断口诀
vbnet
看到 new → 对象实体在堆
看到基本类型变量 → 值在栈
看到对象引用变量 → 引用在栈,实体在堆
看到 static → 引用在堆的 Class 对象,实体在堆
看到 类名.class / 方法字节码 → 元空间
看到 native 方法 → 本地方法栈
三、🔍 年轻代为什么是 Eden + 双 Survivor?
3.1 堆内存分代结构

JVM 基于 "绝大多数对象朝生夕死" 的统计规律,给年轻代选择了复制算法:
- 优点:回收速度快,回收后内存绝对连续,没有碎片
- 原生缺点:经典复制算法会把内存平分成两块,每次只用一块,利用率只有 50%
为了在保留复制算法优点的同时提升利用率,JVM 把年轻代拆成 1 个 Eden + 2 个 Survivor ,默认比例 8:1:1。
3.2 为什么不能只有 Eden 区?
如果年轻代只有 Eden 一块区域:
- Eden 满触发 Young GC,存活对象没有缓冲地带,只能直接晋升老年代
- 老年代被快速填满,Full GC 频率飙升
- 而 Full GC 停顿时间远长于 Young GC,性能急剧下降
Survivor 的核心作用是「年龄缓冲带」:让对象在年轻代多经历几次 GC,确认是长期存活的再晋升老年代,从而大幅减少 Full GC 次数。
3.3 为什么 1 个 Survivor 区不行?
假设只有 1 个 Survivor 区:
- 第一次 GC:把 Eden 存活对象复制到 Survivor 区
- 第二次 GC:Eden 有新的存活对象,同时 Survivor 区里也有部分对象已经死亡
此时 Survivor 区内同时存在存活对象和死亡空位,内存是碎片化的,既不能保证连续分配,也失去了复制算法 "零碎片、分配快" 的优势。
单 Survivor 的本质缺陷:没有一块干净的空闲内存作为复制目标,必然产生内存碎片。
3.4 为什么 2 个 Survivor 是最优解?
两个 Survivor 区(S0/From、S1/To)的核心设计是:永远保持其中一个完全空闲,另一个存放存活对象。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 只有 Eden | 简单 | 存活对象直接晋升老年代,Full GC 频繁 |
| Eden + 1 Survivor | 有缓冲带 | 产生碎片,复制算法优势丧失 |
| Eden + 2 Survivor | 零碎片 + 高利用率 | 浪费约 10% 空间 |
| Eden + 3+ Survivor | 更复杂 | 收益不明显,空间被拆小 |
两个 Survivor 是兼顾效率、内存利用率和实现复杂度的最优解。
四、🔄 Young GC 完整执行流程
4.1 前置环境配置
我们用一套固定参数来量化演示:
ini
-Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15
- 堆总大小 20MB,年轻代 10MB,老年代 10MB
- 年轻代:Eden 8MB,S0/S1 各 1MB
- 对象晋升年龄阈值:15
4.2 阶段 1:对象分配到 Eden
arduino
// 每个数组 512KB,连续分配 14 个 → 7MB
byte[] b1 = new byte[512 * 1024];
byte[] b2 = new byte[512 * 1024];
// ... b3 ~ b14
- 14 个对象全部在 Eden 区分配
- Eden 已用 7MB,剩余 1MB
- S0、S1 均为空
4.3 阶段 2:Eden 不足,触发第一次 Young GC
arduino
// 再分配 2MB,Eden 剩余 1MB 放不下
byte[] b15 = new byte[2 * 1024 * 1024];
触发 Young GC,执行流程如下:
- 标记存活对象 :从 GC Roots 出发,通过可达性分析标记 Eden 中存活对象。假设只有
b1、b2(共 1MB)被引用。 - 复制存活对象 :把
b1、b2复制到空的 S0 区,紧密排列无碎片;对象年龄设为 1。 - 清空 Eden:把整个 Eden 区标记为空闲。
- 分配新对象 :把新的 2MB 对象
b15放入 Eden。 - 角色互换:S0 变为 From 区,S1 变为 To 区。

4.4 阶段 3:后续 GC 的 Survivor 轮转
继续分配对象,Eden 再次占满,触发第二次 Young GC:
- 扫描范围:Eden + From 区(S0)
- 把所有存活对象统一复制到空的 To 区(S1) ,年龄 + 1
- 清空 Eden 和原 From 区
- 角色互换:S1 变 From 区,S0 变 To 区
每触发一次 Young GC,存活对象就在两个 Survivor 区间轮转一次,年龄 + 1。
4.5 怎么看 Young GC 是否健康?
通过 jstat 实时观察:
ruby
$ jstat -gc <pid> 1000
yaml
S0C S1C S0U S1U EC EU OC OU YGC YGCT FGC FGCT
1024.0 1024.0 0.0 512.0 8192.0 2048.0 10240.0 3072.0 128 0.523 0 0.000
关键字段含义:
| 字段 | 含义 | 健康特征 |
|---|---|---|
YGC |
Young GC 次数 | 次数合理,不会突增 |
YGCT |
Young GC 总耗时 | 单次耗时短(几 ms~几十 ms) |
FGC |
Full GC 次数 | 理想为 0 或极低 |
OU |
老年代已用 | 关注 Full GC 后是否回落 |
五、🚀 对象晋升老年代的 3 种规则
不是所有对象都要熬到 15 岁才晋升,有三种情况会直接进入老年代:
| 规则 | 触发条件 | 参数 |
|---|---|---|
| 年龄达标 | 对象年龄达到阈值 | -XX:MaxTenuringThreshold=15 |
| 动态年龄判定 | Survivor 同年龄对象总大小 > 50% | 自动触发 |
| 大对象直接晋升 | 对象大小超过阈值 | -XX:PretenureSizeThreshold |

六、⚡ Full GC 与内存泄漏判断
6.1 Full GC 触发条件
| 触发条件 | 说明 |
|---|---|
| 老年代空间不足 | 无法容纳下一批晋升对象 |
| 元空间达到上限 | -XX:MaxMetaspaceSize 触发 |
显式调用 System.gc() |
线上建议禁用 |
| 空间担保失败 | Young GC 前老年代担保不足 |
6.2 Full GC 执行流程
- STW(Stop-The-World) :暂停所有用户线程
- 全范围回收:年轻代 + 老年代 + 元空间
- 老年代回收:根据回收器执行标记-清除/标记-整理
- 恢复用户线程

6.3 Young GC vs Full GC 对比
| 维度 | Young GC(Minor GC) | Full GC(Major GC) |
|---|---|---|
| 回收范围 | 仅年轻代 | 年轻代 + 老年代 + 元空间 |
| 触发频率 | 高,Eden 满即触发 | 低,老年代空间不足时触发 |
| 停顿时间 | 短,几毫秒~几十毫秒 | 长,几百毫秒~数秒 |
| 回收算法 | 复制算法 | 标记-清除 / 标记-整理 |
6.4 为什么老年代持续高位是内存泄漏信号?
关键前提 :判断泄漏看的是 Full GC 后的最低水位,不是瞬时峰值。
正常状态下的老年代走势
正常无泄漏的服务,老年代内存曲线是平稳锯齿形:
- 上升:每次 Young GC,少量长期存活对象晋升,水位缓慢上涨
- 回落:触发 Full GC,回收无用对象,水位大幅跳水
- 长期规律:每一轮回落的最低点(基线)基本保持稳定
因为老年代里存放的是真正需要长期存活的对象(Spring 单例 Bean、常驻缓存等),服务稳定后总量基本固定。
内存泄漏时的水位特征
内存泄漏的本质是:对象已经没有业务价值,但仍然被引用链持有,GC 认为它还活着,无法回收。
这些泄漏对象最终都会晋升到老年代;而且哪怕触发 Full GC,也收不掉它们。
结果:
- 每次 Full GC,回落幅度越来越小
- GC 后的最低水位呈阶梯式持续抬升
- 最终老年代彻底占满,频繁 Full GC,直至 OOM

注意:老年代高位 ≠ 一定是泄漏。如果堆配置太小、或业务量上涨,常驻对象本身就多,GC 后基线稳定,不会持续抬升。
6.5 从 GC 日志看问题
开启 GC 日志(JDK 9+):
ruby
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags
Young GC 日志示例:
scss
[2026-07-14T10:00:01.456][1.456s][info][gc] GC(0) Pause Young (G1 Evacuation Pause) 24M->5M(256M) 5.123ms
| 字段 | 含义 |
|---|---|
GC(0) |
第 0 次 GC |
Pause Young |
年轻代 GC |
24M->5M |
GC 前后堆使用量 |
(256M) |
当前堆总大小 |
5.123ms |
GC 停顿时间 |
Full GC 日志示例:
scss
[2026-07-14T10:05:12.123][312.123s][info][gc] GC(45) Pause Full (System.gc()) 198M->186M(256M) 485.6ms
如果 Full GC 后
198M->186M只回收了很少,说明基线水位已经很高,可能存在内存泄漏或堆空间不足。
七、🎯 高频面试题
Q1: 堆和栈的区别?
① 存储:堆存对象实例,栈存局部变量 + 栈帧 ② 共享:堆线程共享,栈线程私有 ③ 管理:堆 GC 回收,栈方法结束自动释放 ④ 异常:堆 Java heap space,栈 StackOverflowError。
Q2: 元空间和永久代有什么区别?
永久代(JDK 7-)在 JVM 堆内,易 OOM;元空间(JDK 8+)用本地内存,默认自动伸缩,GC 更高效。都存类元数据、常量池。静态变量和字符串常量池 JDK 7 后移到堆。
Q3: 年轻代为什么需要两个 Survivor 区?
单 Survivor 回收后会产生碎片,失去复制算法优势。双 Survivor 设计永远保持一个空闲、一个存放存活对象,每次 GC 把 Eden + From 存活对象复制到空的 To 区,零碎片、高分配效率。
Q4: 对象什么时候晋升老年代?
三种情况:① 年龄达到 MaxTenuringThreshold(默认 15) ② Survivor 同年龄对象总大小 > 50% 的动态年龄判定 ③ 大对象超过 PretenureSizeThreshold 直接晋升。
Q5: 如何判断老年代高位是不是内存泄漏?
看 Full GC 后的最低水位是否持续抬升。正常服务 Full GC 后老年代会大幅回落到稳定基线;泄漏时每次回落幅度越来越小,基线阶梯式上升。
Q6: Young GC 和 Full GC 的核心区别?
Young GC 回收年轻代,Eden 满触发,停顿短,用复制算法;Full GC 回收年轻代+老年代+元空间,老年代满触发,停顿长,用标记-清除/整理算法。
Q7: 字符串常量池存在哪?
JDK 6 在方法区(永久代),JDK 7+ 在堆。
八、📝 总结
一句话总结:
JVM 运行时内存可归纳为:堆存对象本体,栈存引用和基本类型值,元空间存类元数据 。堆按分代设计,年轻代用 Eden + 双 Survivor 实现复制算法,高效无碎片;对象通过年龄达标、动态年龄判定、大对象直接晋升三种方式进入老年代;判断内存泄漏要看 Full GC 后的基线水位是否持续抬升。
速记核心:
| 概念 | 一句话 |
|---|---|
| 堆 | 存对象实例和数组 |
| 栈 | 存引用、基本类型值、栈帧 |
| 元空间 | 存类元数据、方法字节码 |
| Eden | 新对象优先分配 |
| Survivor | 年龄缓冲带,双 Survivor 零碎片 |
| Young GC | Eden 满触发,复制算法,停顿短 |
| Full GC | 老年代满触发,停顿长 |
| 晋升 | 年龄、动态年龄、大对象 |
| 泄漏判断 | 看 Full GC 后基线是否持续抬升 |
九、面试速记 10 条
| # | 要点 |
|---|---|
| 1 | 堆存对象本体,栈存引用,元空间存类元数据 |
| 2 | JDK 8+ 用元空间替代永久代 |
| 3 | 字符串常量池 JDK 7+ 在堆 |
| 4 | 年轻代 = Eden + S0 + S1,默认 8:1:1 |
| 5 | 双 Survivor 设计实现零碎片复制算法 |
| 6 | Young GC:Eden 满触发,复制算法 |
| 7 | Full GC:老年代满触发,标记-清除/整理 |
| 8 | 对象晋升 3 种:年龄、动态年龄、大对象 |
| 9 | 判断泄漏看 Full GC 后的基线水位 |
| 10 | 程序计数器是唯一不会 OOM 的区域 |