新生代 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%),该年龄及以上的对象直接晋升。
// 动态年龄判断的简版逻辑
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 先做一次风险评估:
- 检查老年代最大可用连续空间是否 大于新生代所有对象总大小
- 如果大于 → 本次 Minor GC 安全
- 如果小于 → 检查
HandlePromotionFailure(JDK 6u24 后默认开启且不可关闭) - 允许担保失败 → 冒险 Minor GC,如果 Survivor 放不下就溢出到老年代
- 不允许担保失败 → 直接触发 Full GC
// 分配担保的简化逻辑
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,总容量 1280KB1024K->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 资源或内存分配压力大。
常见优化点
- Survivor 空间太小 → 对象过早晋升到老年代,导致 Full GC 频繁。调大
-XX:SurvivorRatio或增大-Xmn。 - 动态年龄判断过早晋升 →
-XX:TargetSurvivorRatio默认 50%,如果 Survivor 中较大对象多,小年龄对象也会被提前晋升。可调大该值。 - TLAB 过小导致频繁 GC → TLAB 默认 Eden 的 1%,对象分配频繁且 TLAB 太小,会频繁触发 TLAB 刷新,可用
-XX:+PrintTLAB观察。 - 大对象直接进老年代 → 合理设置
-XX:PretenureSizeThreshold,避免大对象在新生代复制时的开销。
总结
一次 Minor GC 不是简单的「复制 + 清空」,它的背后是:
- Card Table 优化:避免全堆扫描,大幅降低 STW 时间
- 动态年龄判断:不是死板等 15 岁,而是根据 Survivor 实际占用动态调整
- 分配担保机制:失败时提前触发 Full GC,避免数据丢失
- 角色互换设计:Eden : S0 : S1 = 8 : 1 : 1,用 10% 空间浪费换无碎片复制
理解这些细节,才能从 GC 日志中快速定位问题,而不是盲目调参数。