云计算百科
云计算领域专业知识百科平台

【JVM原理详解】24-垃圾回收算法-标记清除复制整理分代

24-垃圾回收算法:标记-清除、复制、整理与分代

知道了"哪些对象活着、哪些该死",下一步就是"如何高效回收死对象"。GC 算法经过几十年的演化,沉淀出四种基础范式:Mark-Sweep(标记-清除)、Copying(复制)、Mark-Compact(标记-整理) 和 Generational(分代收集)。本篇将逐一拆解它们的原理、优劣与适用场景,并讨论 HotSpot 中的卡表机制如何解决跨代引用问题。

Mark-Sweep:标记-清除

这是最基础、也最直观的 GC 算法。过程分两步:

  • 标记阶段:从 GC Roots 出发,遍历对象图,标记所有存活对象。
  • 清除阶段:线性扫描堆,回收未标记的对象。
  • 标记前: [A*] [B] [C*] [D] [E*] (* 为存活)
    标记后: [A*] [ ] [C*] [ ] [E*]
    碎片 碎片

    优点

    • 实现简单,不需要移动对象。
    • 在存活对象比例较低时效率较高(少标记、多回收)。

    缺点

    • 效率不稳定:标记和清除都随堆增大而线性增长。
    • 内存碎片:回收后产生大量不连续空间。后续分配大对象时可能找不到足够连续空间,触发提前 GC。

    Mark-Sweep 是早期 JVM 的主流算法,也是 CMS(Concurrent Mark Sweep)收集器的核心。CMS 之所以被淘汰,碎片问题是重要原因之一——长期运行后需要 Full GC 来整理空间。

    演进:标记-清除的对象头开销

    HotSpot 在对象头(Mark Word)中用一个 bit 标记对象的存活状态。标记阶段会修改对象头,这个写入操作在并发收集器中需要写屏障支持,是 CMS/G1 性能开销的来源之一。

    Copying:复制算法

    复制算法的思路是"用空间换时间":将内存平分为两块,每次只用其中一块;GC 时把存活对象全部复制到另一块,然后清空当前块。

    From 区: [A*] [B] [C*] [D] [E*]
    ↓ 复制
    To 区: [A*] [C*] [E*] [ … ]
    From 区清空,角色互换

    优点

    • 无碎片:复制后的对象紧密排列。
    • 分配快:分配时只需移动指针(bump pointer)。
    • 当存活对象很少时,复制效率非常高——只搬运少量存活对象即可完成回收。

    缺点

    • 空间利用率低:可用内存直接减半,这是无法接受的代价。
    • 存活对象多时复制开销大:极端情况下所有对象都存活,复制成本接近全堆扫描。

    HotSpot 的解决方案是Appel 式回收:新生代并不按 1:1 划分,而是 Eden : Survivor0 : Survivor1 = 8 : 1 : 1,每次只用 Eden + 一个 Survivor,回收时把存活对象复制到另一个 Survivor。这样浪费的空间只有 10%。

    |—– Eden (80%) —–|– S0 (10%) –|– S1 (10%) –|
    ↑ ↑
    使用中 空(备份区)

    这就是新生代采用复制算法的根本原因——新生代"朝生夕灭"的特性让复制成本极低。

    Survivor 区的作用

    Survivor 不是"为了备份"而存在,它的本质是**“晋升缓冲区”**:经历多次 Minor GC 仍存活的对象会被晋升到老年代。-XX:MaxTenuringThreshold(默认 15)控制晋升年龄阈值。

    # 调整晋升阈值
    java -XX:MaxTenuringThreshold=10 -cp MyApp com.example.Main

    Mark-Compact:标记-整理

    标记-整理算法结合了前两者的思路:先标记存活对象(同 Mark-Sweep),然后将存活对象向一端移动,清理边界以外的空间。

    标记前: [A*] [B] [C*] [D] [E*]
    整理后: [A*] [C*] [E*] [——- 空 ——-]

    优点

    • 无碎片:移动后空间连续。
    • 空间利用率高:不像复制算法那样浪费一半空间。

    缺点

    • 移动对象开销大:移动后需要更新所有指向这些对象的引用,写屏障或 STW 不可少。
    • 暂停时间更长:相比 Mark-Sweep,整理阶段更耗时。

    老年代通常采用 Mark-Compact,因为老年代存活率高,复制算法成本不可接受。但也要注意:移动对象会导致更长的 STW,所以有些收集器(如 CMS)选择不整理,用 Mark-Sweep 换取更低的停顿。

    Generational:分代收集

    分代收集不是一种独立的算法,而是一种理论框架——它基于弱代假说(Weak Generational Hypothesis):

  • 绝大多数对象朝生夕灭。
  • 熬过越多次 GC 的对象越难以消亡。
  • 基于这两点观察,HotSpot 把堆分为新生代(Young Generation)和老年代(Old Generation),针对不同代采用不同算法:

    • 新生代:存活率低,用 Copying(Minor GC)。
    • 老年代:存活率高,用 Mark-Sweep 或 Mark-Compact(Major GC / Full GC)。

    |———— 新生代 (1/3) ————|——– 老年代 (2/3) ——–|
    | Eden | S0 | S1 | | |
    | 复制算法 Mark-Compact / Mark-Sweep

    Minor GC 与 Full GC

    • Minor GC / Young GC:只回收新生代,频率高、停顿短。
    • Major GC:回收老年代,常与 Minor GC 联动。
    • Full GC:回收整个堆及元空间,停顿最长,应尽量避免。

    工程上 GC 调优的核心目标往往是减少 Full GC 频率——通过合理设置新生代/老年代比例、晋升阈值、 Survivor 大小,让短命对象在新生代就被消化掉,不进入老年代污染老年代。

    跨代引用与卡表

    分代收集有一个绕不开的问题:跨代引用。新生代对象可能被老年代对象引用,反之亦然。Minor GC 时如果只扫新生代根,就会漏掉老年代指向新生代的引用,导致误回收。

    简单方案的代价

    一种朴素做法是:Minor GC 时把整个老年代当作额外 GC Roots 来扫描。但老年代通常很大,全扫一遍成本不可接受。

    卡表(Card Table)

    HotSpot 采用卡表解决这个问题:把老年代划分为固定大小的"卡片"(Card,通常是 512 字节),每张卡对应卡表中的一个字节。当老年代对象写入一个指向新生代的引用时,通过写屏障把对应卡标记为"脏"(dirty)。

    老年代: |—卡0—|—卡1—|—卡2—|—卡3—|
    卡表: 0 1 0 0
    dirty

    Minor GC 时只扫描"脏卡"对应的老年代区域,把这些对象作为附加 GC Roots。这样把"全扫老年代"降为"只扫脏卡",极大降低了跨代引用的扫描成本。

    写屏障的开销

    写屏障(write barrier)是 JVM 在每次引用写入时插入的额外逻辑。HotSpot 使用精化卡表(Card Table Refinement):写屏障只做"置脏"这一最便宜的操作,扫描交给 GC 线程异步"精化"。这种设计在吞吐量与延迟之间取得了平衡。

    G1 进一步引入 Remembered Set(RSet),每个 region 维护指向自己的卡表集合,实现"谁指向我"的反向索引。ZGC 和 Shenandoah 则通过染色指针与并发整理规避了卡表的部分开销。

    其他记忆集实现

    除卡表外,记忆集还有几种实现:

    • 字节数组(HotSpot 卡表):每 512B 一字节。
    • 位图:每个对象对应一个 bit,更精确但更新成本高。
    • 对象数组:精确记录引用对象,内存占用大。

    卡表是精度与成本的折中选择,也是 HotSpot 长期沿用的方案。

    各算法优缺点对比

    算法是否移动碎片空间开销停顿时间适用场景
    Mark-Sweep 无额外开销 中等 老年代、低延迟场景(CMS)
    Copying 浪费一半(或 10%) 短(存活少时) 新生代、Survivor 区
    Mark-Compact 无额外开销 老年代、吞吐量优先
    Generational 视代而定 视代而定 综合可控 综合较优 主流 JVM 默认策略

    选择依据可以归纳为两条主线:

    • 吞吐量优先:选择 Mark-Compact,停顿长但空间利用率高。
    • 延迟优先:选择 Mark-Sweep 或复制算法,停顿短但有碎片或空间浪费。

    现代收集器(G1、ZGC、Shenandoah)通过分区(Region) + 并发整理打破了"必须二选一"的困局,但底层仍以这四种算法为根基。

    代码示例:观察不同算法的行为

    /**
    * 演示分代 GC 下不同对象的生命周期
    * 适用 JDK 8/11/17
    *
    * 运行参数:
    * java -Xms20m -Xmx20m -Xmn10m
    * -XX:SurvivorRatio=8 -XX:+UseSerialGC
    * -Xlog:gc* -cp MyApp GcAlgorithmDemo
    */

    public class GcAlgorithmDemo {

    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws Exception {
    // 短命对象,新生代中即被回收
    for (int i = 0; i < 100; i++) {
    allocateTransient();
    }

    // 长命对象,会晋升到老年代
    byte[] longLived = new byte[4 * _1MB];

    // 再分配一批,触发 Minor GC
    for (int i = 0; i < 5; i++) {
    byte[] tmp = new byte[2 * _1MB];
    Thread.sleep(200);
    }
    }

    static void allocateTransient() {
    byte[] tmp = new byte[512 * 1024]; // 512KB
    // 方法返回,tmp 失去引用,下次 Minor GC 即被回收
    }
    }

    JDK 11+ 的 GC 日志:

    java -Xlog:gc*=info -cp MyApp GcAlgorithmDemo

    输出中可以看到:

    • GC(0) Pause Young (Allocation Failure) —— 新生代分配失败触发 Minor GC,使用复制算法。
    • 后续 Pause Full 表示晋升失败或老年代空间不足,触发 Full GC,使用 Mark-Compact。

    通过对比 -XX:+UseSerialGC(Mark-Compact 老年代)和 -XX:+UseConcMarkSweepGC(Mark-Sweep 老年代)的日志,可以直观感受两种算法在停顿时间和碎片表现上的差异。

    实践要点

    1. 不要盲目扩大新生代

    新生代过大会让 Minor GC 周期变长但单次停顿增加;新生代过小则 Minor GC 频繁、晋升压力大。一般建议新生代占堆的 1/3 ~ 1/2。

    2. Survivor 不能太小

    Survivor 太小会导致存活对象放不下,直接晋升老年代,造成"过早晋升"。监控 -XX:+PrintAdaptiveSizePolicy(JDK 8)或 -Xlog:gc+ergo(JDK 11+)可看到 JVM 动态调整 Survivor 大小的过程。

    3. CMS 的碎片代价

    CMS 采用 Mark-Sweep,长期运行后碎片会触发并发模式失败(Concurrent Mode Failure),退化为 Serial Old 的 Mark-Compact Full GC,停顿可能达到秒级。这是 CMS 被 G1 取代的关键原因。

    4. G1 的"混合回收"本质

    G1 的 Mixed GC 既回收 Young Region 又回收部分 Old Region,相当于把分代收集与分区结合。理解了 Copying + 卡表,再看 G1 会顺理成章。

    5. 监控对象晋升速率

    通过 jstat -gc <pid> 1s 观察老年代使用量增长速率。如果增长过快,说明短命对象逃逸到了老年代,应排查 Survivor 配置或大对象阈值 -XX:PretenureSizeThreshold。

    小结

    • Mark-Sweep:实现简单,但产生碎片,适合低延迟场景。
    • Copying:无碎片、分配快,但浪费空间,适合新生代。
    • Mark-Compact:无碎片、空间利用率高,但停顿长,适合老年代。
    • Generational:基于弱代假说,分而治之,是主流 JVM 的基础框架。
    • 卡表通过写屏障记录跨代引用,把"全扫老年代"降为"只扫脏卡",是分代收集的关键支撑。

    下一篇我们将进入具体的收集器实现,先看最早出现、也最简单的两个:Serial 与 ParNew。

    更多内容:JVM调优实战

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【JVM原理详解】24-垃圾回收算法-标记清除复制整理分代
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!