返回

JVM 分代垃圾回收机制详解

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

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

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

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

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

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

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

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

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

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

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

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

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

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

[!NOTE] 这三个假说不是理论推导,而是从大量 Java 应用的运行数据中归纳出来的经验规律。研究表明,绝大多数应用中 90% 以上的对象在第一次 GC 时就已经死亡。

关于对象存活率的统计数据,可参见相关研究1

正是基于这三个假说,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)

默认比例配置

新生代内部的默认空间比例为:

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

新生代与老年代的默认比例为:

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

可通过 -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)

[!TIP] 生产环境建议将 -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 区出生。这里就像一个新生儿病房,热闹但流动性极大。

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(对象);
}

默认的晋升年龄阈值为 MaxTenuringThreshold=15MaxTenuringThreshold = 15,即对象经历 15 次 Minor 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 里的对象都是连续排列的,彻底消除了内存碎片问题。

[!IMPORTANT] 任意时刻总有一个 Survivor 区是空的。如果你通过监控工具发现两个 Survivor 区都有数据,说明 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  # 默认值15

经历了 15 次 Minor GC 还活着?那你大概率是个长期存活的对象,去老年代吧。

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最大晋升年龄15
-XX:PretenureSizeThreshold大对象直接进入老年代的阈值0(不启用)
-XX:TargetSurvivorRatioSurvivor 区目标使用率,影响动态年龄判断50

三种 GC 类型对比

不同的 GC 类型,对应用的影响差异巨大。搞清楚它们的区别,是性能调优的基础。

特性Minor GCMajor GCFull GC
回收范围新生代(Eden + Survivor)老年代新生代 + 老年代 + 元空间
触发条件Eden 区空间不足老年代空间不足老年代不足 / 元空间不足 / System.gc() / 分配担保失败
回收算法复制算法标记-清除 / 标记-整理视收集器而定
STW 时间短(通常 < 50ms)较长最长(可达数秒)
执行频率极低(理想情况)
影响程度几乎无感有感知明显卡顿

[!WARNING] Full GC 是线上事故的常见元凶。如果你在 GC 日志中发现 Full GC 频率超过每分钟一次,需要立即排查。常见原因包括:内存泄漏、大对象频繁分配、元空间不足、代码中误调 System.gc()

空间分配担保:Minor GC 前的安全检查

在执行 Minor GC 之前,JVM 其实会先做一次”风险评估”。毕竟,万一这次 Minor GC 后存活的对象太多,Survivor 区放不下,全都要晋升到老年代,而老年代也放不下呢?

flowchart TD
    A[Minor GC 前] --> B{老年代空间 > 新生代所有对象?}
    B -->|是| C[担保成功 → 执行 Minor GC]
    B -->|否| D{允许冒险担保?}
    D -->|否| E[直接 Full GC]
    D -->|是| F{老年代空间 > 历次晋升平均值?}
    F -->|是| G[冒险执行 Minor GC]
    F -->|否| E
    G --> H{实际晋升是否成功?}
    H -->|是| I[Minor GC 完成]
    H -->|否| J[Promotion Failed → Full GC]
担保检查伪代码
public boolean checkAllocationGuarantee() {
    long oldGenMaxFreeSpace = oldGeneration.getLargestFreeSpace();
    long youngGenTotalSpace = youngGeneration.getTotalSpace();

    if (oldGenMaxFreeSpace > youngGenTotalSpace) {
        return true;  // 担保成功
    }

    // 用历史数据赌一把
    if (HandlePromotionFailure &&
        oldGenMaxFreeSpace > getAveragePromotedSize()) {
        return true;  // 冒险担保
    }

    return false;  // 担保失败,需要Full GC
}

注意第二个判断条件:即使老年代空间不够装下所有新生代对象,JVM 还会用历史数据赌一把——如果过去每次晋升的对象平均大小比当前老年代剩余空间小,就冒险继续 Minor GC。赌输了?那就触发 Full GC。

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

如果你经历过 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 错误常见低,但仍需合理配置上限
回收效率低,回收条件苛刻高,自动触发回收
存储内容类元数据 + 字符串常量池 + 静态变量仅类元数据(字符串池和静态变量已移至堆中)

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

元空间核心参数

参数说明建议值
-XX:MetaspaceSize初始元空间大小(也是首次触发 GC 的阈值)128m–256m
-XX:MaxMetaspaceSize最大元空间大小256m–512m
-XX:CompressedClassSpaceSize压缩类空间大小默认 1g,一般不需调整
-XX:MinMetaspaceFreeRatioGC 后最小空闲比例40
-XX:MaxMetaspaceFreeRatioGC 后最大空闲比例70

[!TIP] 虽然元空间默认不限大小,但生产环境强烈建议设置 -XX:MaxMetaspaceSize。否则一旦出现类加载泄漏(比如热部署场景),元空间会无限增长直到吃光系统内存,到时候排查起来更痛苦。

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效率

[!WARNING] 如果你在 GC 日志中频繁看到 Promotion Failed,那就该认真审视 Survivor 区的大小配置了。通常的解决思路是:增大新生代(-Xmn)或调整 Survivor 比例(-XX:SurvivorRatio),让 Survivor 有足够空间容纳存活对象。

实战调优:从参数到代码

理论讲完了,来点实际的。以下是几个在生产中验证过的调优方向。

1. Survivor区大小调整

# 增大Survivor区比例
-XX:SurvivorRatio=6  # Eden:Survivor = 6:1:1 (默认8:1:1)

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

2. 对象晋升策略调整

# 降低晋升年龄阈值,让对象更早晋升
-XX:MaxTenuringThreshold=3  # 默认15,改为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]

[!NOTE] 第二条日志中,新生代回收后变成了 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 调优之前,按照以下清单逐项排查:

  • 确认 -Xms-Xmx 设为相同值,避免堆内存动态伸缩
  • 开启 GC 日志(JDK 8 用 -XX:+PrintGCDetails,JDK 9+ 用 -Xlog:gc*
  • 分析 GC 日志,确认 Full GC 频率是否在合理范围内(低于每分钟一次)
  • 检查 Minor GC 后的 Survivor 区使用率,判断是否需要调整 -XX:SurvivorRatio
  • 确认是否存在大对象频繁分配,考虑设置 -XX:PretenureSizeThreshold
  • 检查元空间配置,确保设置了 -XX:MaxMetaspaceSize 上限
  • 排查代码中是否有 System.gc() 的显式调用
  • 使用 jmap 或 MAT 分析堆转储,排除内存泄漏

写在最后

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

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

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

Footnotes

  1. 该数据来源于 Sun Microsystems 对大量 Java 应用的运行时统计分析,详见《The Garbage Collection Handbook》及 Oracle 官方文档中对弱分代假说的阐述。