Skip to content

分代收集理论:年轻代和老年代的垃圾回收策略

为什么 Java 要把堆分成年轻代和老年代? 为什么大多数对象朝生夕死? 为什么 G1 能同时处理年轻代和老年代?

答案都藏在分代收集理论里。


核心假说

分代收集建立在两个经验假说之上:

弱分代假说(Weak Generational Hypothesis)

大多数对象都是朝生夕灭的。

一个请求对象、一个大 Map、用完即弃的临时变量……这些对象在第一次 GC 时就会被回收。

强分代假说(Strong Generational Hypothesis)

熬过多次 GC 的对象,越难死亡。

长期存活的对象(如 Spring Bean、静态单例、线程池中的任务)通常会持续存活很久。


分代划分

基于这两个假说,JVM 将堆划分为:

┌─────────────────────────────────────────────────────────────┐
│                         堆内存                              │
│  ┌───────────────────────┬───────────────────────────────┐ │
│  │       年轻代          │          老年代                │ │
│  │   ┌───┬───┬───┐       │                               │ │
│  │   │Eden│S0 │S1 │       │                               │ │
│  │   └───┴───┴───┘       │                               │ │
│  │    8/10  1/10  1/10   │                               │ │
│  │   (默认比例)          │                               │ │
│  └───────────────────────┴───────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

Eden 区:新对象的出生地,8/10 的空间
Survivor From (S0):上一次 GC 的幸存者
Survivor To (S1):下一次 GC 的目标空间
老年代:长期存活的对象

比例配置

bash
# 年轻代 : 老年代 = 1 : 2(默认)
java -XX:NewRatio=2 -Xmx256m your.App

# 手动指定年轻代大小
java -XX:NewSize=64m -XX:MaxNewSize=64m your.App

# Survivor 区比例:Eden : S0 : S1 = 8 : 1 : 1
java -XX:SurvivorRatio=8 your.App

对象分配与回收流程

分配

新对象优先在 Eden 区分配:

java
public class ObjectAllocation {

    public static void main(String[] args) {
        // 所有 new 对象都在 Eden 区分配
        Object obj = new Object();  // → Eden 区

        // 大对象直接进入老年代
        // -XX:PretenureSizeThreshold=1024(超过 1KB 直接老年代)
        byte[] largeArray = new byte[1024 * 1024];  // → 老年代
    }
}

回收

第一次 Minor GC:
┌─────────────────────────────────────────┐
│ Eden: [obj1][obj2][obj3] │ S0 │ S1 │    │
│        3 个对象        │空  │空  │    │
└─────────────────────────────────────────┘
                    ↓ GC
┌─────────────────────────────────────────┐
│ Eden: [空]             │ S0 │ S1 │    │
│                        │obj1│空  │    │
│ 存活的 obj1 放入 S0    │obj2│    │    │
│ obj3 死亡              │obj3│    │    │
└─────────────────────────────────────────┘

第二次 Minor GC:
┌─────────────────────────────────────────┐
│ Eden: [obj4][obj5]       │ S0 │ S1 │    │
│                        │obj1│空  │    │
│                        │obj2│    │    │
└─────────────────────────────────────────┘
                    ↓ GC
┌─────────────────────────────────────────┐
│ Eden: [空]             │ S0 │ S1 │    │
│                        │空  │obj1│    │
│ S0 → S1(复制算法)    │空  │obj2│    │
│ Eden 中存活的 obj4     │空  │obj4│    │
│ obj1, obj2, obj4 年龄+1│空  │obj5│    │
└─────────────────────────────────────────┘

年龄阈值

对象每经历一次 Minor GC 年龄 +1,到达阈值后晋升老年代:

bash
# 默认年龄阈值:15(因为对象头中 age 只占 4 位,最大 15)
java -XX:MaxTenuringThreshold=15 your.App

# JVM 动态调整:-XX:+UseAdaptiveSizePolicy
# 根据 GC 情况自动调整各区大小和阈值

晋升老年代的两种情况

java
public class PromoteToOld {

    public static void main(String[] args) {
        // 情况 1:年龄达到阈值
        // 对象熬过多次 Minor GC,年龄超标 → 晋升

        // 情况 2:大对象直接晋升
        // -XX:PretenureSizeThreshold=1024
        byte[] big = new byte[2 * 1024];  // 直接进老年代

        // 情况 3: Survivor 区空间不足
        // GC 后 Survivor 区放不下 → 提前晋升(空间分配担保)
    }
}

空间分配担保

Minor GC 前,JVM 检查老年代最大可用连续空间:

┌─────────────────────────────────────────────────────────────┐
│                      空间分配担保流程                        │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  Minor GC 触发                                               │
│       │                                                     │
│       ▼                                                     │
│  老年代能容纳新生代所有对象吗?                              │
│       │                                                     │
│    ┌──┴──┐                                                  │
│    ▼     ▼                                                  │
│   能     不能 ──► 检查 HandlePromotionFailure                │
│    │         │                                               │
│    │     老年代可用 > 历史平均值?                           │
│    │         │                                               │
│    │      ┌──┴──┐                                            │
│    │      ▼     ▼                                            │
│    │     能     不能 ──► Full GC                              │
│    │      │         │                                        │
│    │      ▼         ▼                                        │
│    │   Minor GC   Full GC                                     │
│    │      │         │                                        │
│    └──────┴─────────┘                                        │
│         │                                                     │
│         ▼                                                     │
│    Minor GC(安全)                                           │
│                                                              │
└─────────────────────────────────────────────────────────────┘
bash
# JDK 6 Update 24 之后,规则简化(忽略 HandlePromotionFailure)
# 老年代最大连续空间 > 新生代所有对象总大小? → Minor GC
# 否则 → Full GC

为什么要两个 Survivor 区?

核心原因:复制算法需要空区间

没有 Survivor 区的问题

假设只有 1 个 Survivor 区(S0):

第一次 GC:Eden + S0 → S0(但 S0 可能放不下!)
第二次 GC:Eden + S0 → S0(空间不够就提前晋升老年代)

有两个 Survivor 区的优势

  • 交替使用:S0 和 S1 每次只有 1 个使用,1 个空着
  • 复制算法完美适配:Eden + S0 的存活对象 → S1
  • 减少晋升压力:短命对象在 Survivor 区流转,不急着进老年代
java
public class SurvivorAdvantage {

    public static void main(String[] args) {
        // 1000 个对象,999 个朝生夕死
        for (int i = 0; i < 1000; i++) {
            byte[] temp = new byte[1024];  // 临时对象
            // 处理...
        }
        // 只有 1 个存活,Minor GC 后进入 Survivor 区
        // 如果只有 1 个 Survivor 区,这个区必须够大才能容纳

        // 两个 Survivor 区:只需要容纳存活对象的 1/10(Eden 的 1/9)
        // 节省空间!
    }
}

分代收集的 GC 策略

分代GC 类型频率回收对象STW 时间
年轻代Minor GC(Young GC)Eden + Survivor
老年代Major GC / Full GC老年代 + 全堆
整堆Full GC全部最长

注意:Major GC 不等于 Full GC,取决于收集器实现。


分代收集的代价

跨代引用的问题

年轻代对象可能被老年代引用,Minor GC 时需要扫描老年代:

┌─────────────────────────────────────────────────────────────┐
│  跨代引用问题                                               │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│     老年代 ──────────────────┐                              │
│         │                    │                              │
│         ▼                    ▼                              │
│    [objA] ───引用──→ [Eden 区 objB]                        │
│                              ↑                              │
│                              │                              │
│                         年轻代                              │
│                                                              │
│  问题:Minor GC 时,如何发现 objB?                          │
│  方案:Card Table(卡表)+ Remembered Set(记忆集)           │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Card Table(卡表)

将老年代划分为固定大小的卡页(Card Page)

java
// Card Table 示意:每个 Card 对应老年代一段内存
// 当 Old 区对象引用 Young 区对象时,对应 Card 标记为 dirty

// Minor GC 时,只扫描 dirty 的 Card,而不是整个老年代

面试追问方向

  • 对象年龄超过 -XX:MaxTenuringThreshold 一定会晋升老年代吗?
  • Eden 区满了一定会触发 Minor GC 吗?
  • 为什么 HotSpot 要把 Survivor 区设计成两个,而不是一个更大的?
  • 什么情况下 Minor GC 会导致 Full GC?(空间分配担保失败)
  • G1 为什么不需要固定的分代结构?它的 Region 化设计有什么优势?

基于 VitePress 构建