大白话 JVM:对象到底存在哪?GC 又是怎么运行的?

前言

很多 Java 开发者刚接触 JVM 时,都背过 "堆、栈、方法区" 这些名词,但真问到:

一行代码里的变量、对象、类信息分别存在哪?

往往就模糊了。

还有一个经典面试题:

年轻代为什么要设计两个 Survivor 区?

很多人答不上来本质。

这篇文章用生活化类比 + 一段完整代码 + 数值模拟,带你彻底搞懂:

  1. JVM 运行时内存怎么划分
  2. 对象到底存在堆、栈还是元空间
  3. 年轻代为什么是 Eden + 双 Survivor
  4. Young GC 和 Full GC 的完整执行流程
  5. 怎么判断老年代高位是不是内存泄漏

一、📋 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 对象中
agename 成员变量 属于对象实例的一部分
num = 100 虚拟机栈 基本类型值存在局部变量表
localUserstrarr 引用 虚拟机栈 对象引用存在局部变量表
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,执行流程如下:

  1. 标记存活对象 :从 GC Roots 出发,通过可达性分析标记 Eden 中存活对象。假设只有 b1b2(共 1MB)被引用。
  2. 复制存活对象 :把 b1b2 复制到空的 S0 区,紧密排列无碎片;对象年龄设为 1。
  3. 清空 Eden:把整个 Eden 区标记为空闲。
  4. 分配新对象 :把新的 2MB 对象 b15 放入 Eden。
  5. 角色互换:S0 变为 From 区,S1 变为 To 区。

4.4 阶段 3:后续 GC 的 Survivor 轮转

继续分配对象,Eden 再次占满,触发第二次 Young GC:

  1. 扫描范围:Eden + From 区(S0)
  2. 把所有存活对象统一复制到空的 To 区(S1) ,年龄 + 1
  3. 清空 Eden 和原 From 区
  4. 角色互换: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 执行流程

  1. STW(Stop-The-World) :暂停所有用户线程
  2. 全范围回收:年轻代 + 老年代 + 元空间
  3. 老年代回收:根据回收器执行标记-清除/标记-整理
  4. 恢复用户线程

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 的区域
相关推荐
炸薯条!2 小时前
从零开始学C++ (内存管理)
java·jvm·c++
凤山老林9 小时前
从“无法创建本地线程“异常说起:深度解析线程栈与JVM线程优化指南
java·开发语言·jvm
csdn2015_1 天前
JVM 常用命令
jvm
fīɡЙtīиɡ ℡1 天前
JVM从入门到精通
jvm
wear工程师1 天前
volatile 这题别只答「可见性」:面试官真正想听的是这条 happens-before
java·jvm
sycmancia2 天前
Qt——多线程中的信号与槽(二)
jvm·qt
库克克3 天前
【C++】类和对象--类对象模型与大小计算
开发语言·jvm·c++
嘘嘘出差3 天前
天气数据监控系统
jvm·爬虫