引言:完善的垃圾回收方案

上一篇我们学习了两种基础算法:

  • 标记-清除:简单但有碎片
  • 标记-复制:无碎片但浪费空间

本文将学习另外两种算法,它们解决了前两种算法的缺陷:

  • 标记-整理:无碎片且不浪费空间
  • 分代收集:综合运用多种算法,是现代JVM的主流方案

标记-整理算法(Mark-Compact)

核心思想

分三个阶段

  1. 标记阶段(Mark):标记所有存活对象
  2. 整理阶段(Compact):将存活对象移动到内存一端
  3. 清除阶段(Sweep):清除边界外的所有内存

与标记-清除的区别

算法清除方式内存布局
标记-清除直接释放死亡对象分散,有碎片
标记-整理移动存活对象后清除紧凑,无碎片

详细流程

阶段1:标记(Mark)

从GC Roots开始,标记所有可达对象。

GC Roots开始遍历
    ↓
标记对象A(可达)
    ↓
标记对象B(A引用)
    ↓
标记对象C(B引用)
    ↓
完成标记

阶段2:整理(Compact)

将所有存活对象移动到内存的一端,使其紧密排列。

整理前:
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ A  │ 空 │ C  │ 空 │ E  │ 空 │ 空 │ H  │
└────┴────┴────┴────┴────┴────┴────┴────┘

整理后:
┌────┬────┬────┬────┬────────────────────┐
│ A  │ C  │ E  │ H  │        空          │
└────┴────┴────┴────┴────────────────────┘

阶段3:清除(Sweep)

清除边界后的所有内存。


图解示例

初始状态

堆内存:
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ A  │ B  │ C  │ D  │ E  │ F  │ G  │ H  │
└────┴────┴────┴────┴────┴────┴────┴────┘

存活对象: A, C, E, H

标记阶段

┌────┬────┬────┬────┬────┬────┬────┬────┐
│ A✓ │ B  │ C✓ │ D  │ E✓ │ F  │ G  │ H✓ │
└────┴────┴────┴────┴────┴────┴────┴────┘

整理阶段

移动存活对象:
A → 保持不动(已在起始位置)
C → 移动到位置2
E → 移动到位置3
H → 移动到位置4

┌────┬────┬────┬────┬────────────────────┐
│ A  │ C  │ E  │ H  │        空          │
└────┴────┴────┴────┴────────────────────┘

清除阶段

┌────┬────┬────┬────┬────────────────────┐
│ A  │ C  │ E  │ H  │ 可分配的连续空间    │
└────┴────┴────┴────┴────────────────────┘

结果:
· 存活对象紧密排列
· 无内存碎片
· 空闲内存连续

实现细节

移动对象需要更新引用

// 伪代码
public void compact() {
    // 1. 计算每个对象的新地址
    for (Object obj : markedObjects) {
        obj.newAddress = calculateNewAddress(obj);
    }

    // 2. 更新所有引用
    for (Object obj : allObjects) {
        if (obj.references != null) {
            for (int i = 0; i < obj.references.length; i++) {
                Object ref = obj.references[i];
                if (ref != null) {
                    obj.references[i] = ref.newAddress;  // 更新引用
                }
            }
        }
    }

    // 3. 移动对象到新地址
    for (Object obj : markedObjects) {
        copyObject(obj, obj.newAddress);
    }

    // 4. 清除旧数据
    clearOldData();
}

优点

1️⃣ 无内存碎片

存活对象紧密排列,空闲内存连续。


2️⃣ 不浪费空间

不需要像复制算法那样预留一半空间。


3️⃣ 适合存活率高的场景

老年代对象存活率高,标记-整理比复制算法更高效。


缺点

1️⃣ 效率低(移动对象开销大)

需要移动存活对象,并更新所有引用。

时间开销

  • 标记:O(存活对象数)
  • 整理:O(存活对象数) + O(更新引用)
  • 清除:O(1)

2️⃣ Stop The World时间长

移动对象期间,必须暂停所有应用线程(保证引用正确性)。


3️⃣ 实现复杂

需要计算新地址、更新引用、移动对象,实现较复杂。


适用场景

  • 老年代:对象存活率高(通常>70%),标记-整理比复制算法更高效
  • Full GC:整理内存碎片,为后续分配提供连续空间

优化:标记-清除-整理(Mark-Sweep-Compact)

策略

  • 平时使用 标记-清除(效率高)
  • 定期使用 标记-整理(清理碎片)
private int gcCount = 0;

public void gc() {
    if (gcCount % 10 == 0) {
        markCompact();  // 每10次GC整理一次
    } else {
        markSweep();    // 平时使用标记-清除
    }
    gcCount++;
}

优势

  • 平衡效率和碎片问题
  • CMS收集器采用类似策略

分代收集理论(Generational Collection)

核心思想

根据对象存活周期的不同,将堆分为不同的区域,针对不同区域使用不同的GC算法

三大假说

分代收集基于以下三个经验假说:

1️⃣ 弱分代假说(Weak Generational Hypothesis)

绝大多数对象都是朝生夕死的

数据支持

  • 研究表明,98%的对象生命周期小于1秒
  • 只有2%的对象存活超过1秒

示例

public void processRequest(Request request) {
    Response response = new Response();  // 短命对象
    Result result = service.handle(request);  // 短命对象
    response.setData(result);
    return response;  // 方法结束,response和result成为垃圾
}

2️⃣ 强分代假说(Strong Generational Hypothesis)

熬过多次GC的对象,越难以消亡

数据支持

  • 对象经历的GC次数越多,再次被回收的概率越低
  • 如:全局缓存、单例对象、静态对象

示例

public class CacheManager {
    private static Map<String, Object> cache = new HashMap<>();  // 长命对象

    public static void put(String key, Object value) {
        cache.put(key, value);  // 对象存活时间长
    }
}

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

跨代引用相对于同代引用来说占极少数

含义

  • 老年代对象很少引用新生代对象
  • 大部分引用都是同代之间的

推论

  • 新生代GC时,不需要扫描整个老年代
  • 只需扫描存在跨代引用的部分

分代结构

┌─────────────────────────────────────────────┐
│              JVM 堆内存                      │
├─────────────────────────────────────────────┤
│                                             │
│  ┌────────────────────────────────────┐    │
│  │       新生代 (Young Generation)     │    │
│  │  ┌─────────┬────────┬────────┐    │    │
│  │  │  Eden   │   S0   │   S1   │    │    │
│  │  │   8     │   1    │   1    │    │    │
│  │  └─────────┴────────┴────────┘    │    │
│  │                                     │    │
│  │  · 对象优先分配                      │    │
│  │  · 存活率低(<10%)                  │    │
│  │  · 使用复制算法                      │    │
│  │  · Minor GC频繁但快                 │    │
│  └────────────────────────────────────┘    │
│                                             │
│  ┌────────────────────────────────────┐    │
│  │       老年代 (Old Generation)       │    │
│  │                                     │    │
│  │  · 存放长期存活对象                  │    │
│  │  · 存活率高(>70%)                  │    │
│  │  · 使用标记-清除或标记-整理          │    │
│  │  · Major/Full GC罕见但慢            │    │
│  └────────────────────────────────────┘    │
│                                             │
└─────────────────────────────────────────────┘

分代GC流程

Minor GC(新生代GC)

触发条件

  • Eden区空间不足

回收范围

  • 新生代(Eden + From Survivor)

使用算法

  • 复制算法

流程

  1. 标记Eden区和From Survivor区的存活对象
  2. 将存活对象复制到To Survivor区
  3. 清空Eden区和From Survivor区
  4. From和To互换

特点

  • 频率高(可能每秒数次)
  • 速度快(几毫秒到几十毫秒)
  • Stop The World时间短

Major GC(老年代GC)

触发条件

  • 老年代空间不足
  • 分配担保失败

回收范围

  • 老年代

使用算法

  • 标记-清除或标记-整理

特点

  • 频率低(可能几分钟到几小时一次)
  • 速度慢(几百毫秒到几秒)
  • Stop The World时间长

Full GC(全堆GC)

触发条件

  • 老年代空间不足
  • 元空间不足
  • 调用System.gc()(不推荐)
  • CMS GC出现Concurrent Mode Failure

回收范围

  • 新生代 + 老年代 + 元空间

特点

  • 频率最低
  • 速度最慢
  • Stop The World时间最长
  • 应尽量避免

跨代引用问题

问题:新生代GC时,如何处理老年代对新生代的引用?

朴素方案:扫描整个老年代(效率低)

优化方案记忆集(Remembered Set)


记忆集(Remembered Set)

定义:记录从非收集区域指向收集区域的引用。

作用:避免扫描整个老年代。

实现

  • 将老年代划分为若干块(如512字节)
  • 记录哪些块包含跨代引用
  • 新生代GC时,只扫描这些块

示例

老年代:
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ 块1 │ 块2 │ 块3 │ 块4 │ 块5 │ 块6 │ 块7 │ 块8 │
└────┴────┴────┴────┴────┴────┴────┴────┘
   ↓                  ↓
新生代对象          新生代对象

记忆集:
· 块1包含跨代引用
· 块4包含跨代引用

Minor GC时:
· 只扫描块1和块4
· 不扫描其他块

卡表(Card Table)

定义:记忆集的一种实现方式,使用字节数组记录跨代引用。

结构

// 卡表(伪代码)
byte[] cardTable;  // 每个元素对应老年代的一块区域(512字节)

// 标记
cardTable[index] = DIRTY;  // 1表示该块包含跨代引用

// 清除
cardTable[index] = CLEAN;  // 0表示该块不包含跨代引用

维护

  • 通过 写屏障(Write Barrier) 维护
  • 老年代对象引用新生代对象时,标记卡表

写屏障(Write Barrier)

定义:在引用更新前后插入的代码片段。

作用:维护记忆集/卡表。

伪代码

// 引用更新
void updateReference(Object obj, Object newRef) {
    obj.field = newRef;  // 更新引用

    // 写屏障:如果是跨代引用,标记卡表
    if (isInOldGen(obj) && isInYoungGen(newRef)) {
        markCardDirty(obj);  // 标记卡表
    }
}

三种算法总结对比

算法内存碎片空间利用率效率适用场景
标记-清除❌ 有碎片✅ 100%存活率高时效率高老年代(配合定期整理)
标记-复制✅ 无碎片❌ 50%(标准)✅ 90%(优化)存活率低时效率高新生代
标记-整理✅ 无碎片✅ 100%效率低(移动开销大)老年代(Full GC)

实战场景

观察分代GC

/**
 * VM参数:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
 */
public class GenerationalGCDemo {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3;

        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];

        // 触发Minor GC
        allocation1 = null;
        allocation4 = new byte[4 * _1MB];

        // 继续分配,可能触发Major GC
        allocation5 = new byte[4 * _1MB];
    }
}

GC日志

[GC (Allocation Failure) [PSYoungGen: 7291K->808K(9216K)] 7291K->6952K(19456K), 0.0031993 secs]
[Full GC (Ergonomics) [PSYoungGen: 808K->0K(9216K)] [ParOldGen: 6144K->6827K(10240K)] 6952K->6827K(19456K), 0.0098765 secs]

常见问题与误区

❌ 误区1:分代收集是一种算法

真相

  • 分代收集是一种 理论/策略
  • 综合运用多种算法(复制、标记-清除、标记-整理)

❌ 误区2:Full GC = Major GC

真相

  • Major GC:只回收老年代
  • Full GC:回收整个堆(新生代 + 老年代 + 元空间)

❌ 误区3:老年代只使用标记-整理

真相

  • 平时使用标记-清除(效率高)
  • 碎片积累到一定程度,使用标记-整理(CMS策略)

总结

核心要点

  1. 标记-整理:无碎片且不浪费空间,但效率低,适合老年代

  2. 分代收集理论基于三大假说

    • 弱分代假说:大部分对象朝生夕死
    • 强分代假说:熬过多次GC的对象难以消亡
    • 跨代引用假说:跨代引用占少数
  3. 分代结构

    • 新生代:复制算法,Minor GC频繁但快
    • 老年代:标记-清除或标记-整理,Major GC罕见但慢
  4. 记忆集和卡表:解决跨代引用问题,避免扫描整个老年代

  5. 写屏障:维护记忆集/卡表,记录跨代引用

与下篇文章的衔接

下一篇文章,我们将学习 垃圾收集器演进史:从Serial到ZGC,理解GC的发展历程、吞吐量vs低延迟的权衡,以及并发vs并行的区别。


参考资料


下一篇预告:《垃圾收集器演进史:从Serial到ZGC》 深入理解GC的发展历程,吞吐量优先vs低延迟优先的权衡,以及并发vs并行的区别。