← 文章

JVM 分代垃圾回收机制详解

深入理解 JVM 分代模型的设计原理、执行流程和优化策略

文章目录13 节
  1. 为什么要分代?从三个假说说起
  2. 传统分代堆内存长什么样?
  3. 内存布局
  4. 常见比例配置
  5. 堆内存核心参数
  6. 一个对象的一生
  7. 1. 对象创建与 Eden 区分配
  8. 2. Minor GC 触发条件
  9. 3. Minor GC 执行过程
  10. 4. 对象年龄(Age)机制
  11. Survivor 区的精妙设计
  12. From Space 和 To Space 概念
  13. 具体执行示例
  14. 什么时候进入老年代?
  15. 晋升相关参数
  16. GC 日志中的几类称呼
  17. 传统分代收集器中的晋升空间
  18. 从永久代到元空间:一次重要的进化
  19. JVM内存结构完整图景
  20. 永久代 vs 元空间
  21. 元空间核心参数
  22. Survivor 区满了怎么办?
  23. 问题场景:To Space 空间不足
  24. JVM处理策略
  25. 连锁反应
  26. 实战调优:从参数到代码
  27. 1. Survivor区大小调整
  28. 2. 对象晋升策略调整
  29. 3. 代码层面优化
  30. 监控与诊断
  31. 1. GC日志分析
  32. 2. 常用监控命令
  33. GC 调优检查清单
  34. 写在最后

文章目录

13 节
  1. 为什么要分代?从三个假说说起
  2. 传统分代堆内存长什么样?
  3. 内存布局
  4. 常见比例配置
  5. 堆内存核心参数
  6. 一个对象的一生
  7. 1. 对象创建与 Eden 区分配
  8. 2. Minor GC 触发条件
  9. 3. Minor GC 执行过程
  10. 4. 对象年龄(Age)机制
  11. Survivor 区的精妙设计
  12. From Space 和 To Space 概念
  13. 具体执行示例
  14. 什么时候进入老年代?
  15. 晋升相关参数
  16. GC 日志中的几类称呼
  17. 传统分代收集器中的晋升空间
  18. 从永久代到元空间:一次重要的进化
  19. JVM内存结构完整图景
  20. 永久代 vs 元空间
  21. 元空间核心参数
  22. Survivor 区满了怎么办?
  23. 问题场景:To Space 空间不足
  24. JVM处理策略
  25. 连锁反应
  26. 实战调优:从参数到代码
  27. 1. Survivor区大小调整
  28. 2. 对象晋升策略调整
  29. 3. 代码层面优化
  30. 监控与诊断
  31. 1. GC日志分析
  32. 2. 常用监控命令
  33. GC 调优检查清单
  34. 写在最后

深入理解 JVM 分代模型的设计原理、执行流程和优化策略。

你有没有遇到过这样的场景:线上应用跑着跑着突然卡顿几秒,监控面板上 GC 时间飙升,用户开始投诉响应变慢?你赶紧登上服务器看日志,满屏的 Full GC 让人心跳加速。

这时候你会意识到,理解 JVM 的垃圾回收机制不是面试八股文,而是真正能帮你定位问题、拯救线上事故的硬技能。今天我们就来把分代垃圾回收这件事彻底聊透。

重要

下文关于连续新生代、Eden、两块 Survivor 和 Minor GC 的流程,主要用 Serial / Parallel 等传统分代收集器建立概念。G1 虽然也有 Eden 和 Survivor 角色,但底层按 Region 管理并动态调整年轻代;ZGC、Shenandoah 等收集器又有不同实现。调参数前先确认当前 JDK 和收集器,不要把一套固定布局套到所有 JVM 上。

为什么要分代?从三个假说说起

JVM 的设计者们不是拍脑袋决定把堆内存分成几块的。分代回收的背后,有三个经过大量实践验证的假说作为理论支撑。

1. 弱分代假说(Weak Generational Hypothesis)

绝大多数对象都是朝生夕灭的
→ 新创建的对象很快就会变成垃圾
→ 新生代应该频繁回收

想想你写的代码:一个方法里创建的局部变量、临时的 StringBuilder、循环中的中间对象——这些东西用完就扔,生命周期极短。

2. 强分代假说(Strong Generational Hypothesis)

熬过越多次GC的对象越难以回收
→ 存活时间长的对象继续存活的概率大
→ 老年代可以较少回收

而那些被缓存持有的对象、Spring 容器中的 Bean、全局配置数据,一旦活过了最初的几轮 GC,大概率会一直活下去。

3. 跨代引用假说(Intergenerational Reference Hypothesis)

跨代引用相对于同代引用占极少数
→ 老年代对象很少引用新生代对象
→ 可以通过记忆集等技术优化扫描

这个假说让我们在回收新生代时不必扫描整个老年代,大幅提升了 GC 效率。

注意

这三个假说来自对大量程序运行特征的经验归纳,不是每个应用都满足的固定比例。对象存活率应以自己服务的 GC 日志和分配剖析结果为准。

正是基于这三个假说,JVM 才有了“对不同生命周期的对象采用不同回收策略”的设计思路。

graph LR
    A[弱分代假说] -->|多数对象朝生夕灭| B[新生代频繁回收]
    C[强分代假说] -->|长寿对象持续存活| D[老年代低频回收]
    E[跨代引用假说] -->|跨代引用极少| F[记忆集优化扫描]
    B --> G[分代回收策略]
    D --> G
    F --> G

传统分代堆内存长什么样?

理解了为什么要分代,接下来看看 JVM 堆到底是怎么划分的。

内存布局

堆内存(Heap)
├── 新生代(Young Generation)
│   ├── Eden区(伊甸园区)
│   ├── Survivor0区(幸存者0区,简称S0)
│   └── Survivor1区(幸存者1区,简称S1)
└── 老年代(Old Generation/Tenured Generation)

常见比例配置

对使用这些参数的传统分代收集器,-XX:SurvivorRatio=8 对应:

Eden:S0:S1=8:1:1Eden : S0 : S1 = 8 : 1 : 1

-XX:NewRatio=2 对应:

Young:Old=1:2Young : Old = 1 : 2

这些是参数含义的示例,不是所有 JDK、所有收集器都遵循的统一默认布局。G1 会根据暂停时间目标动态调整年轻代,通常不应再用 -Xmn-XX:NewRatio 固定它的大小。

为什么 Eden 占了新生代的 80%?因为根据弱分代假说,绝大多数对象在 Eden 区就会被回收掉,真正能存活下来进入 Survivor 区的只是少数,所以 Survivor 不需要太大的空间。

堆内存核心参数

参数说明示例
-Xms / -Xmx初始 / 最大堆大小-Xms4g -Xmx4g
-Xmn新生代大小-Xmn1g
-XX:NewRatio老年代与新生代的比值-XX:NewRatio=3(老:新=3:1)
-XX:SurvivorRatioEden 与单个 Survivor 的比值-XX:SurvivorRatio=8(Eden:S0=8:1)
提示

对延迟敏感且内存余量明确的服务,常见做法是把 -Xms-Xmx 设为相同值,减少堆扩缩带来的波动;但它会一次占用更多可用内存,仍要结合容器限制和同机进程评估。

一个对象的一生

了解了内存结构,我们来跟踪一个对象从出生到最终归宿的完整旅程。

flowchart TD
    A[新对象创建] --> B{Eden 区空间足够?}
    B -->|是| C[分配到 Eden 区]
    B -->|否| D[触发 Minor GC]
    D --> E{对象存活?}
    E -->|否| F[回收]
    E -->|是| G{Survivor 区放得下?}
    G -->|是| H[复制到 Survivor 区\nage + 1]
    G -->|否| I[直接晋升老年代]
    H --> J{age >= 阈值?}
    J -->|是| K[晋升到老年代]
    J -->|否| L[继续在 Survivor 区\n等待下次 GC]
    L --> D

1. 对象创建与 Eden 区分配

String name = "张三";                    // 分配在Eden区
List<String> list = new ArrayList<>();   // 分配在Eden区

多数普通小对象会先进入年轻代的 Eden(通常还会经过线程本地分配缓冲区 TLAB)。大对象、逃逸分析后的优化结果以及不同收集器的策略都可能例外,所以这不是绝对规则。

2. Minor GC 触发条件

当 Eden 区空间不足时,触发 Minor GC(新生代垃圾回收)

3. Minor GC 执行过程

第一次Minor GC:
Eden区:[对象A][对象B][垃圾1][垃圾2][对象C]
S0区:  [空]
S1区:  [空]

GC后:
Eden区:[空]
S0区:  [对象A(age=1)][对象B(age=1)][对象C(age=1)]
S1区:  [空]

注意,存活下来的对象被复制到了 S0 区,同时每个对象多了一个 age 标记。这个年龄计数器,将决定对象未来的命运。

4. 对象年龄(Age)机制

// 每经历一次Minor GC,存活对象年龄+1
对象年龄 = 经历的Minor GC次数

if (对象年龄 >= MaxTenuringThreshold) {
    promoteToOldGeneration(对象);
}

对于支持 -XX:MaxTenuringThreshold 的收集器,常见上限是 15;对象也可能因为 Survivor 空间和动态年龄判断而更早晋升,实际行为要以当前收集器和 GC 日志为准。

Survivor 区的精妙设计

为什么需要两个 Survivor 区?一个不行吗?这其实是复制算法的经典应用,目的是避免内存碎片。

From Space 和 To Space 概念

任意时刻,两个Survivor区中:
- 一个作为From Space(当前使用)
- 一个作为To Space(空闲备用)

Minor GC时:
1. 扫描Eden区 + From Space的存活对象
2. 将存活对象复制到To Space
3. 清空Eden区和From Space
4. 交换From Space和To Space的角色

这种乒乓式的设计,保证了每次 GC 后 To Space 里的对象都是连续排列的,彻底消除了内存碎片问题。

重要

“From / To 一用一空”描述的是经典复制流程中的逻辑角色。不同监控工具的采样时机和展示口径不同,G1 又是按多个 Region 统计,不能仅凭“两边都有数据”判断 GC 异常。

具体执行示例

第二次Minor GC前:
Eden区:[对象D][对象E][垃圾3][垃圾4]
S0区:  [对象A(age=1)][对象B(age=1)][对象C(age=1)]  ← From Space
S1区:  [空]  ← To Space

第二次Minor GC后:
Eden区:[空]
S0区:  [空]  ← 新的To Space
S1区:  [对象A(age=2)][对象B(age=2)][对象C(age=2)][对象D(age=1)][对象E(age=1)]  ← 新的From Space

看到了吗?S0 和 S1 的角色发生了互换。之前的老对象年龄 +1,新存活的对象年龄从 1 开始。

什么时候进入老年代?

对象不会永远待在新生代。下面仍以支持这些机制的传统分代收集器为例,说明几种常见晋升路径:

1. 年龄达到阈值

-XX:MaxTenuringThreshold=15  # 最大年龄阈值示例

对象年龄达到当前收集器允许的阈值后,会进入老年代;它也可能因为空间压力更早晋升。

2. 大对象直接分配

-XX:PretenureSizeThreshold=1048576  # 1MB 阈值示例

体积太大的对象在 Survivor 区之间来回复制太浪费性能,不如直接安排到老年代。

3. Survivor 区空间不足

if (ToSpace剩余空间 < 存活对象大小) {
    promoteToOldGeneration(存活对象);
}

4. 动态年龄判断

如果相同年龄所有对象大小总和超过 Survivor 空间的一半,则年龄大于等于该年龄的对象直接晋升。即当累计大小满足:

i=1ageObjectSize(i)SurvivorCapacity2\sum_{i=1}^{age} ObjectSize(i) \geq \frac{SurvivorCapacity}{2}

则年龄 age\geq age 的对象全部晋升到老年代。

动态年龄判断伪代码
public int calculateDynamicAge() {
    int targetSize = survivorSize / 2;
    int cumulativeSize = 0;

    for (int age = 1; age <= MaxTenuringThreshold; age++) {
        cumulativeSize += getObjectSizeByAge(age);
        if (cumulativeSize >= targetSize) {
            return age;
        }
    }
    return MaxTenuringThreshold;
}

这个动态年龄判断非常聪明——它不会死板地等到 15 岁才晋升,而是根据 Survivor 区的实际使用情况灵活调整。

晋升相关参数

参数说明使用提示
-XX:MaxTenuringThreshold最大晋升年龄上限和实际年龄取决于收集器
-XX:PretenureSizeThreshold大对象直接进入老年代的阈值并非所有收集器都支持或采用同样行为
-XX:TargetSurvivorRatioSurvivor 区目标使用率,影响动态年龄判断仅在对应收集器中按实测调整

GC 日志中的几类称呼

Minor GC、Major GC、Full GC 是常见的概念性称呼,但不同收集器和日志格式对它们的使用并不完全一致。下面的表格只帮助理解大致范围,不能作为统一的触发条件或耗时标准。

特性Minor GCMajor GCFull GC
常见理解年轻代回收老年代回收整堆或更完整的回收路径
常见触发年轻代分配压力老年代回收需求整堆或收集器退化路径,原因因实现而异
回收算法取决于收集器取决于收集器取决于收集器
STW 时间与堆、存活对象和收集器有关与堆、存活对象和收集器有关通常风险更高,但没有统一数值
执行频率按分配速率变化按晋升和并发回收状态变化应结合业务延迟目标观察
影响程度看暂停时间和吞吐量看暂停时间和吞吐量需重点关联应用延迟和错误率
警告

不要用“每分钟几次”作为通用红线。应把 GC 原因、暂停时间、吞吐量、堆增长趋势与业务延迟目标放在一起判断;如果 Full GC 持续出现并伴随长暂停或回收后占用仍然很高,再重点排查内存泄漏、大对象分配、元空间压力和显式 System.gc()

传统分代收集器中的晋升空间

年轻代回收后,部分存活对象可能要晋升到老年代。收集器需要判断老年代是否有足够空间承接这些对象;空间不足时,可能提前发起老年代回收,或者在晋升失败后进入更重的回收路径。

flowchart TD
    A[年轻代回收前] --> B[估算存活对象与晋升空间]
    B --> C{老年代承接能力是否足够?}
    C -->|足够| D[执行年轻代回收]
    C -->|不足| E[先处理老年代压力]
    D --> F{实际晋升是否成功?}
    F -->|成功| G[回收完成]
    F -->|失败| H[进入收集器对应的失败处理]

这里保留的是帮助理解的逻辑模型。具体判断会随 JDK 版本和收集器变化,旧资料里常见的 HandlePromotionFailure 等开关不能直接当作现代 JVM 的通用实现。

从永久代到元空间:一次重要的进化

如果你经历过 JDK 7 到 JDK 8 的迁移,一定对 PermGen 的消失印象深刻。

JVM内存结构完整图景

JVM内存结构
├── 堆内存(Heap)
│   ├── 新生代(Young Generation)
│   └── 老年代(Old Generation)
├── 非堆内存(Non-Heap)
│   ├── 方法区(Method Area)
│   │   ├── 永久代(PermGen)- JDK 7及以前
│   │   └── 元空间(Metaspace)- JDK 8及以后
│   ├── 程序计数器(PC Register)
│   ├── 本地方法栈(Native Method Stack)
│   └── 虚拟机栈(VM Stack)
└── 直接内存(Direct Memory)

永久代 vs 元空间

对比维度永久代(PermGen)元空间(Metaspace)
JDK 版本JDK 7 及以前JDK 8 及以后
存储位置JVM 堆内存中本地内存(Native Memory)
大小限制受永久代参数约束默认受本机可用内存约束,也可设置上限
OOM 风险可能出现 PermGen space类加载泄漏或上限过低时仍可能 OOM
存储内容类元数据 + 字符串常量池 + 静态变量仅类元数据(字符串池和静态变量已移至堆中)

永久代最大的痛点是什么?大小固定。你设小了,动态代理、热部署这些场景分分钟给你一个 java.lang.OutOfMemoryError: PermGen space

元空间核心参数

参数说明使用提示
-XX:MetaspaceSize影响首次元空间 GC 的高水位根据类加载规模和 GC 日志评估
-XX:MaxMetaspaceSize最大元空间大小留出本地内存余量后再决定是否限制
-XX:CompressedClassSpaceSize压缩类空间预留大小一般先保留 JVM 默认值
-XX:MinMetaspaceFreeRatioGC 后最小空闲比例仅在确认元空间扩缩问题后调整
-XX:MaxMetaspaceFreeRatioGC 后最大空闲比例仅在确认元空间扩缩问题后调整
提示

是否设置 -XX:MaxMetaspaceSize 要看本地内存预算和类加载规模。上限可以防止类加载泄漏持续挤占系统内存,但设得过小也会增加 GC 压力,甚至提前触发 OOM;先监控,再留出安全余量。

Survivor 区满了怎么办?

这是很多人会忽略的一个细节,但在实际生产中经常碰到。

问题场景:To Space 空间不足

Minor GC执行过程中:
Eden区:[对象A][对象B][对象C][垃圾...]
From Space(S0):[对象D(age=2)][对象E(age=3)][对象F(age=1)]
To Space(S1):[空] ← 容量不足以容纳所有存活对象

存活对象总大小 > To Space可用空间

JVM处理策略

JVM 不会因为 Survivor 放不下就崩溃,它有两套应对方案:

策略1:直接晋升到老年代 —— 所有放不下的对象一律晋升。

策略2:分批处理 —— 部分对象复制到 To Space,剩余的晋升到老年代。

Eden区存活对象:[A][B][C][D][E]
From Space对象:[F(age=2)][G(age=3)]

处理结果:
To Space:[A][B][F(age=3)] ← 放得下的对象
老年代:  [C][D][E][G]     ← 放不下的对象直接晋升

连锁反应

但这种“提前晋升”并不是没有代价的:

  1. 可能触发 老年代GC:大量对象晋升导致老年代压力
  2. 分配担保检查:检查老年代是否有足够空间
  3. 性能影响:频繁的提前晋升影响GC效率
警告

如果 GC 日志频繁出现晋升失败,先确认是存活对象突增、老年代余量不足,还是回收周期没能及时完成。只有使用支持这些参数的传统分代收集器时,才考虑 -Xmn-XX:SurvivorRatio;G1 不建议用 -Xmn-XX:NewRatio 等参数固定年轻代大小。

实战调优:从参数到代码

理论讲完了,来点实际的。下面的参数示例主要用于说明排查方向,先在与生产一致的环境中验证,再决定是否使用。

1. Survivor区大小调整

以下示例只适用于采用连续年轻代布局、并支持相应参数的收集器:

# 调整 Eden 与单个 Survivor 的比例
-XX:SurvivorRatio=6  # Eden:S0:S1 = 6:1:1

# 或者直接设置新生代大小
-Xmn2g  # 设置新生代为2GB

2. 对象晋升策略调整

同样要先核对当前收集器是否支持,以及该参数是否真的参与当前分配路径:

# 降低最大晋升年龄阈值,让对象更早晋升
-XX:MaxTenuringThreshold=3

# 设置大对象阈值
-XX:PretenureSizeThreshold=1048576  # 大于1MB直接进老年代

3. 代码层面优化

参数调优能做的有限,很多时候问题出在代码本身。来看一个典型的反面教材:

// 10万个对象同时存活,最终全部晋升到老年代
public List<String> processData() {
    List<String> results = new ArrayList<>();
    for (int i = 0; i < 100000; i++) {
        String data = heavyComputation(i);
        results.add(data);
    }
    return results;
}

这段代码的问题在于:10 万个对象同时存活,它们会从 Eden 晋升到 Survivor,再从 Survivor 晋升到老年代,最终导致 Full GC。

// 分批处理,每批1000个对象,用完即回收
public void processDataInBatches() {
    int batchSize = 1000;
    for (int i = 0; i < 100000; i += batchSize) {
        List<String> batch = new ArrayList<>();
        for (int j = i; j < i + batchSize && j < 100000; j++) {
            batch.add(heavyComputation(j));
        }
        processBatch(batch);
    }
}

分批处理后,每批只有 1000 个对象同时存活,它们在 Minor GC 时就能被回收,根本不会进入老年代。

监控与诊断

调优不能靠猜,你需要数据。

1. GC日志分析

开启 GC 日志是排查问题的第一步:

# JDK 8
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:gc.log

# JDK 9+(统一日志框架)
-Xlog:gc*:file=gc.log:time,uptime,level,tags

日志解读示例

# 正常的Minor GC:新生代回收了大部分对象
[GC (Allocation Failure) [PSYoungGen: 8192K->1024K(9216K)] 8192K->2048K(19456K), 0.0123456 secs]

# 异常信号:新生代清零,但老年代暴增——大量对象被直接晋升
[GC (Allocation Failure) [PSYoungGen: 8192K->0K(9216K)] [ParOldGen: 2048K->7168K(10240K)] 8192K->7168K(19456K), 0.0234567 secs]
注意

第二条日志中,新生代回收后变成了 0K,但老年代从 2048K 暴增到 7168K——这说明大量对象被直接晋升到了老年代,是一个值得关注的信号。如果这种情况频繁出现,需要检查是否存在大对象分配或 Survivor 区过小的问题。

2. 常用监控命令

# 实时监控GC状况(每秒刷新)
jstat -gc <pid> 1s

# 查看类加载信息
jcmd <pid> VM.classloader_stats

# 生成堆转储文件用于离线分析
jmap -dump:format=b,file=heap.hprof <pid>

GC 调优检查清单

在进行 GC 调优之前,按照以下清单逐项排查:

  • 确认当前 JDK、垃圾收集器、堆限制和业务延迟目标
  • 开启 GC 日志(JDK 8 用 -XX:+PrintGCDetails,JDK 9+ 用 -Xlog:gc*
  • 分析 GC 原因、暂停时间和回收后占用,并与应用延迟关联
  • 若收集器适用,检查 Survivor 使用率,再判断是否需要调整比例
  • 确认是否存在大对象频繁分配,优先定位分配来源,再评估参数
  • 监控元空间增长,按泄漏风险和内存预算判断是否需要设置上限
  • 排查代码中是否有 System.gc() 的显式调用
  • 使用 jmap 或 MAT 分析堆转储,排除内存泄漏

写在最后

回顾一下整篇文章的核心脉络:

  1. 分代的本质是根据对象生命周期特征采用不同回收策略——短命对象频繁回收,长寿对象少管
  2. Survivor 区的双区设计通过复制算法消除了内存碎片,是空间换时间的经典权衡
  3. 晋升机制不是一刀切的,年龄、大小、空间状况、动态判断四管齐下
  4. Survivor 区满了不会触发额外 GC,而是改变分配策略,但可能引发连锁反应
  5. 元空间替代永久代解决了固定大小的历史包袱,但仍需合理配置

实际工作中,我的建议是:先通过 GC 日志定位问题,再有针对性地调整参数,最后回到代码层面做根本性的优化。盲目调参数往往事倍功半,而好的代码设计才是最好的 GC 调优。更多细节可参考 Oracle 官方 GC 调优指南;使用 G1 时尤其要注意不要固定年轻代大小