分代收集理论:年轻代和老年代的垃圾回收策略
为什么 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 化设计有什么优势?
