cd ..

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

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

堆内存长什么样?

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

内存布局

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

默认比例配置

Eden : Survivor0 : Survivor1 = 8 : 1 : 1
新生代 : 老年代 = 1 : 2(可通过-XX:NewRatio调整)

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

相关JVM参数

# 堆大小设置
-Xms4g -Xmx4g                    # 初始和最大堆大小

# 新生代设置
-Xmn1g                           # 新生代大小
-XX:NewRatio=3                   # 老年代:新生代=3:1
-XX:SurvivorRatio=8              # Eden:Survivor=8:1

一个对象的一生

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

1. 对象创建与 Eden 区分配

// 新对象首先在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(对象);
}

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 里的对象都是连续排列的,彻底消除了内存碎片问题。

具体执行示例

第二次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. 年龄达到阈值

# JVM参数设置最大年龄阈值
-XX:MaxTenuringThreshold=15  # 默认值15

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

2. 大对象直接分配

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

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

3. Survivor 区空间不足

if (ToSpace剩余空间 < 存活对象大小) {
    // 直接晋升到老年代,避免复制失败
    promoteToOldGeneration(存活对象);
}

4. 动态年龄判断

// 如果相同年龄所有对象大小总和 > Survivor空间的一半
// 则年龄大于等于该年龄的对象直接晋升
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=1m    # 大对象阈值
-XX:TargetSurvivorRatio=50       # Survivor目标使用率

三种 GC 类型,影响各不同

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

Minor GC(新生代 GC)

触发条件:Eden区空间不足
回收范围:新生代(Eden + Survivor区)
回收算法:复制算法(Copying Algorithm)
特点:频繁执行,STW时间短

Major GC(老年代GC)

触发条件:老年代空间不足
回收范围:老年代
回收算法:标记-清除 或 标记-整理算法
特点:执行较少,STW时间较长

Full GC(全堆GC)

触发条件:
1. 老年代空间不足
2. 永久代/元空间不足
3. System.gc()调用
4. 分配担保失败

回收范围:新生代 + 老年代 + 永久代/元空间
特点:STW时间最长,影响性能

Full GC 是我们最不想看到的。它会暂停所有应用线程(Stop The World),如果频繁发生,用户体验会直线下降。

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

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

担保检查流程

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。

担保相关参数

# 分配担保
-XX:+HandlePromotionFailure      # 启用分配担保(JDK6后默认开启)

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

如果你经历过 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)

永久代(PermGen)- JDK 7及以前

存储内容

// 永久代主要存储:
1. 类的元数据信息(Class Metadata)
2. 字符串常量池(String Pool)- JDK 7移到堆中
3. 静态变量(Static Variables)- JDK 7移到堆中
4. 即时编译器编译后的代码缓存

永久代参数

# 永久代大小参数(JDK 7及以前)
-XX:PermSize=64m          # 初始永久代大小
-XX:MaxPermSize=256m      # 最大永久代大小

元空间(Metaspace)- JDK 8及以后

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

引入原因

永久代的问题:
❌ 大小固定,容易OOM
❌ 回收困难,效率低
❌ 调优复杂

元空间的优势:
✅ 使用本地内存,大小可动态扩展
✅ 自动触发垃圾回收
✅ 减少OOM风险

元空间参数

# 元空间大小设置(JDK 8+)
-XX:MetaspaceSize=128m           # 初始元空间大小
-XX:MaxMetaspaceSize=512m        # 最大元空间大小(默认无限制)
-XX:CompressedClassSpaceSize=1g  # 压缩类空间大小

# 元空间回收设置
-XX:MinMetaspaceFreeRatio=40     # 最小空闲比例
-XX:MaxMetaspaceFreeRatio=70     # 最大空闲比例

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:直接晋升到老年代

if (survivingObjectsSize > toSpaceAvailableSize) {
    promoteToOldGeneration(survivingObjects);
} else {
    copyToToSpace(survivingObjects);
}

策略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 日志中频繁看到 Promotion Failed,那就该认真审视 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. 代码层面优化

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

问题代码

// ❌ 大量对象同时存活
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。

优化代码

// ✅ 分批处理,减少同时存活对象
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++) {
            String data = heavyComputation(j);
            batch.add(data);
        }
        processBatch(batch); // 处理完一批就释放
        // batch会在下次Minor GC时被回收
    }
}

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

监控与诊断

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

1. GC日志分析

启用GC日志

# 启用详细GC日志
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:gc.log

日志解读

# 正常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——这说明大量对象被直接晋升到了老年代,是一个值得关注的信号。

2. 监控工具使用

命令行工具

# 使用jstat监控元空间
jstat -gc <pid> 1s

# 使用jcmd查看详细信息
jcmd <pid> VM.classloader_stats

自定义监控

// 性能监控指标
public class GCMetrics {
    private long minorGCCount;        // Minor GC次数
    private long minorGCTime;         // Minor GC总时间
    private long majorGCCount;        // Major GC次数
    private long majorGCTime;         // Major GC总时间
    private long promotedBytes;       // 晋升字节数
    private long allocationRate;      // 分配速率

    // 计算新生代回收效率
    public double getMinorGCEfficiency() {
        return (double) promotedBytes / (minorGCCount * youngGenSize);
    }
}

写在最后

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

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

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