Skip to content

新生代 GC 流程(Minor GC 全过程)

问题

一次 Minor GC 发生的过程是怎样的?对象从创建到晋升老年代之间经历了什么?

分析

Minor GC(又称 Young GC)是 JVM 最频繁的 GC 事件。理解它的完整流程,是排查 GC 停顿、优化吞吐量的基础。很多开发者知道「对象先在 Eden 分配,满了就触发 GC」,但每步的决策逻辑、潜在的失败场景、JVM 的兜底机制,才是区分熟练工和专家的分水岭。

新生代结构与设计前提

先回顾新生代的物理划分:

┌──────────────────────────────────────────────────┐
│                     Eden                          │
│                 (80% 新生代)                       │
├──────────────────────┬───────────────────────────┤
│   Survivor From (S0) │  Survivor To (S1)         │
│        (10%)         │       (10%)               │
└──────────────────────┴───────────────────────────┘

默认比例 Eden : S0 : S1 = 8 : 1 : 1,由 -XX:SurvivorRatio=8 控制。这意味着任何时候只有一个 Survivor 区是可用的,另一个是空的「To」区,等待复制。

这个设计基于「弱分代假说」:绝大多数对象朝生夕死,存活率低,所以用复制算法最划算——只浪费 10% 的空间,换来无碎片、指针碰撞分配的效率。

一次 Minor GC 的完整流程

阶段 1:触发条件

当 Eden 区空间不足时,JVM 触发 Minor GC。注意:不是等到 Eden 完全满才触发,而是 Eden 无法分配下一个对象时触发。如果 Eden 还有空间但不够分配当前对象,大对象(-XX:PretenureSizeThreshold)直接进老年代,不经过 Minor GC。

阶段 2:根扫描(Root Scanning)

从 GC Roots 出发,扫描新生代中存活的对象。这一阶段必须 STW(Stop-The-World),因为根集合会随应用线程运行而改变。

关键优化:Minor GC 不需要扫描整个老年代。老年代到新生代的引用通过 Card Table(卡表)记录——一个字节数组,每个字节对应老年代一段 512 字节的区域。被标记为 dirty 的卡页说明该区域有引用指向新生代,GC 时只扫描这些脏页。扫描成本 ≈ O(老年代脏页数),而非 O(老年代对象数)。

阶段 3:复制到 Survivor

将 Eden 和 From Survivor(S0)中的存活对象复制到 To Survivor(S1):

  • 对象年龄 age++
  • 复制完成后的对象位于 S1
  • 如果 S1 空间不足,提前晋升到老年代(Handle Promotion Failure)

阶段 4:清空

清空 Eden 和 From Survivor,然后交换角色:

Minor GC 前:Eden(对象) + S0(对象) → 复制到 S1
Minor GC 后:Eden(空) + S1(存活对象) → From 角色切换

下一次 Minor GC 时,S1 变成 From,S0 变成 To,角色互换。

阶段 5:晋升决策

对象年龄达到阈值后晋升到老年代。晋升判断有两个维度:

静态阈值-XX:MaxTenuringThreshold(默认 15,Parallel GC 下实际最大 6,因为只用 3bit 存年龄)。

动态年龄判断(更常用):HotSpot 不是死板等到 15 岁才晋升。它统计 Survivor 中每个年龄的对象大小,当某个年龄的对象总和超过 Survivor 的 50%(-XX:TargetSurvivorRatio 默认 50%),该年龄及以上的对象直接晋升。

java
// 动态年龄判断的简版逻辑
int targetSurvivorRatio = 50; // 默认 50%
int totalSurvivorSize = 256 * 1024; // 假设 Survivor 256KB

// 按年龄从小到大累加对象大小
int accumulated = 0;
for (int age = 1; age <= maxTenuringThreshold; age++) {
    accumulated += ageTable[age]; // 该年龄的对象总大小
    if (accumulated > totalSurvivorSize * targetSurvivorRatio / 100) {
        // 年龄 >= age 的对象全部晋升
        promoteAge = age;
        break;
    }
}

分配担保(Handle Promotion Failure)

Minor GC 前,JVM 先做一次风险评估:

  1. 检查老年代最大可用连续空间是否 大于新生代所有对象总大小
  2. 如果大于 → 本次 Minor GC 安全
  3. 如果小于 → 检查 HandlePromotionFailure(JDK 6u24 后默认开启且不可关闭)
  4. 允许担保失败 → 冒险 Minor GC,如果 Survivor 放不下就溢出到老年代
  5. 不允许担保失败 → 直接触发 Full GC
java
// 分配担保的简化逻辑
boolean canPromote = false;
long maxOldContinuous = getMaxOldContinuousSpace();
long allYoungObjectsSize = getYoungTotalObjectsSize();

if (maxOldContinuous >= allYoungObjectsSize) {
    canPromote = true; // 安全,可以 Minor GC
} else if (isHandlePromotionFailure()) {
    canPromote = true; // 冒险,允许担保失败
} else {
    triggerFullGC(); // 不冒险,直接 Full GC
}

if (canPromote) {
    doMinorGC();
}

跨代引用优化:Card Table 详解

这是 Minor GC 性能的关键。如果没有 Card Table,Minor GC 需要扫描整个老年代来找到指向新生代的引用,对于 32GB 老年代这简直是灾难。

Card Table 的工作原理:

老年代内存(按 512 字节划分为 Card)
┌──────┬──────┬──────┬──────┬──────┬──────┐
│Card 0│Card 1│Card 2│Card 3│Card 4│Card 5│...
└──┬───┴──┬───┴──────┴──────┴──┬───┴──────┘
   │      │                     │
   ▼      ▼                     ▼
Card Table(字节数组)
┌─────┬─────┬─────┬─────┬─────┬─────┐
│dirty│clean│clean│clean│dirty│clean│...
└─────┴─────┴─────┴─────┴─────┴─────┘
  • 老年代对象修改引用指向新生代时,JVM 将对应 Card 标记为 dirty(写屏障)
  • Minor GC 时只扫描 dirty 的 Card
  • 扫描完清空 dirty 标记

G1 更进一步,用 Remembered Set(RSet) 来精确到每个 Region 粒度的外部引用记录,但代价是 RSet 内存开销约堆的 5-20%。

实战:看 GC 日志理解 Minor GC

[GC (Allocation Failure)
  [PSYoungGen: 1024K->128K(1280K)] 1024K->256K(4096K), 0.0023 secs]
  [Times: user=0.01 sys=0.00, real=0.00 secs]

解读:

  • Allocation Failure:触发原因,Eden 分配失败
  • PSYoungGen: 1024K->128K(1280K):新生代从 1024KB 降到 128KB,总容量 1280KB
  • 1024K->256K(4096K):整个堆从 1024KB 降到 256KB,总容量 4096KB
  • 128KB(新生代存活)→ 256KB(堆存活)= 128KB 晋升到老年代
  • 0.0023 secs:STW 时间,2.3ms,很健康

如果看到晋升量突然变大,或 [Times: user=0.50 sys=0.30, real=0.80 secs] 这种高 real 时间,说明 GC 线程在等待 CPU 资源或内存分配压力大。

常见优化点

  1. Survivor 空间太小 → 对象过早晋升到老年代,导致 Full GC 频繁。调大 -XX:SurvivorRatio 或增大 -Xmn
  2. 动态年龄判断过早晋升-XX:TargetSurvivorRatio 默认 50%,如果 Survivor 中较大对象多,小年龄对象也会被提前晋升。可调大该值。
  3. TLAB 过小导致频繁 GC → TLAB 默认 Eden 的 1%,对象分配频繁且 TLAB 太小,会频繁触发 TLAB 刷新,可用 -XX:+PrintTLAB 观察。
  4. 大对象直接进老年代 → 合理设置 -XX:PretenureSizeThreshold,避免大对象在新生代复制时的开销。

总结

一次 Minor GC 不是简单的「复制 + 清空」,它的背后是:

  • Card Table 优化:避免全堆扫描,大幅降低 STW 时间
  • 动态年龄判断:不是死板等 15 岁,而是根据 Survivor 实际占用动态调整
  • 分配担保机制:失败时提前触发 Full GC,避免数据丢失
  • 角色互换设计:Eden : S0 : S1 = 8 : 1 : 1,用 10% 空间浪费换无碎片复制

理解这些细节,才能从 GC 日志中快速定位问题,而不是盲目调参数。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。