Java 分代 GC¶
分代 GC 的核心思想:不同生命周期的对象用不同策略回收——短命的快扫,长寿的慢扫。
为什么要分代¶
研究表明,绝大多数对象都是"朝生夕灭"的(比如方法里的临时变量、请求处理的中间对象),只有少数对象能活很久。分代 GC 就是利用这个规律。
堆的分代结构¶
┌─────────────────────────────────────────────────────────┐
│ Java 堆(Heap) │
│ │
│ ┌──────────────────────────┐ ┌─────────────────────┐ │
│ │ 年轻代 (Young Gen) │ │ 老年代 (Old Gen) │ │
│ │ │ │ │ │
│ │ ┌─────┬─────┬────────┐ │ │ │ │
│ │ │ Eden │ S0 │ S1 │ │ │ │ │
│ │ │ 80% │ 10% │ 10% │ │ │ Tenured Space │ │
│ │ └─────┴─────┴────────┘ │ │ (70%) │ │
│ │ (默认 1/3 堆) │ │ (默认 2/3 堆) │ │
│ └──────────────────────────┘ └─────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 元空间 (Metaspace) │ │
│ │ 存类的元数据(JDK 8+ 移到堆外,不归 GC 管) │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
年轻代(Young Generation)—— 细分三块¶
Eden 区(80%)¶
新对象都在这里出生。绝大多数对象第一次 GC 就死了。
Survivor 0 / Survivor 1(各 10%)¶
也叫 S0、S1,交替使用。活过一次 GC 的对象先搬到这里暂住。
运作流程¶
graph TD
A[新对象分配到 Eden] --> B{Eden 满了?}
B -->|否| A
B -->|是| C[触发 Minor GC]
C --> D[扫描 Eden + S0,标记存活]
D --> E[存活对象复制到 S1]
E --> F[Eden 和 S0 全部清空]
F --> G{对象年龄 ≥ 阈值?}
G -->|否| H[留在 Survivor,年龄+1]
G -->|是| I[晋升到老年代]
H --> A
流程演示:
第 1 步:新对象分配到 Eden
┌────────┬──────┬──────┐
│ Eden │ S0 │ S1 │
│▓▓▓▓▓▓▓│ │ │
│▓▓▓▓▓▓▓│ │ │
└────────┴──────┴──────┘
▓ = 新对象
第 2 步:Eden 满了,触发 Minor GC
- 扫描 Eden + S0,标记存活对象
- 存活对象复制到 S1
- Eden 和 S0 全部清空
┌────────┬──────┬──────┐
│ Eden │ S0 │ S1 │
│ │ │▓▓ │
└────────┴──────┴──────┘
S1 里是活下来的对象
第 3 步:下次 Minor GC
- 扫描 Eden + S1,存活对象复制到 S0
- S0 和 Eden 清空
- S0 ↔ S1 角色互换
第 4 步:对象每活过一轮 GC,年龄 +1
- 年龄达到阈值(默认 15)→ 晋升到老年代
老年代(Old Generation)¶
存放长期存活的对象:
- 年龄达标的对象晋升过来
- 大对象直接分配在这里(避免在年轻代来回复制)
- 回收频率低,用标记-清除或标记-压缩
触发 GC 的条件¶
| GC 类型 | 触发时机 | 回收范围 | 算法 | 特点 |
|---|---|---|---|---|
| Minor GC | Eden 区满 | Eden + 活跃 Survivor | 复制算法 | 快(几毫秒),频繁 |
| Major GC / Full GC | 老年代空间不足 | 整个堆(甚至方法区) | 标记-整理/清除 | 慢(几百毫秒~秒级) |
| Mixed GC | G1 专用,老年代占阈值 | 年轻代 + 部分老年代 Region | 混合 | 折中,可控暂停 |
对象晋升流程¶
graph TD
A[new 对象] --> B[Eden 区]
B --> C{Minor GC}
C -->|死亡| D[直接回收]
C -->|存活| E[进入 Survivor]
E --> F{年龄 ≥ 15?}
F -->|否| E
F -->|是| G[晋升到老年代]
G --> H{老年代满?}
H -->|否| I[继续存活]
H -->|是| J[触发 Major GC / Full GC]
为什么这样设计有效¶
场景:一个 Web 请求处理
├─ 请求进来了,创建一堆临时对象 → Eden
├─ 处理完,这些对象没人引用了
├─ Minor GC 一扫,99% 都是垃圾,直接清掉
└─ 真正活得久的(连接池、缓存)慢慢晋升到老年代
效率:只扫描 Eden(小区域),速度极快
真正长寿的对象不频繁被扫描
GC 收集器演进¶
| JDK 版本 | 年轻代收集器 | 老年代收集器 | 默认 |
|---|---|---|---|
| 1.x ~ 7 | Serial / Parallel / ParNew | Serial Old / CMS / Parallel Old | Parallel |
| 8 | Parallel | Parallel Old | Parallel |
| 9+ | G1(全堆) | G1(全堆) | G1 |
| 15+ | ZGC / Shenandoah | ZGC / Shenandoah | 可选 |
练习题¶
题目一:为什么 Survivor 区要分成 S0 和 S1 两个?
答案
因为复制算法需要一块空闲空间来存放存活对象。如果只有一个 Survivor 区,就没地方复制了。两个 Survivor 区交替使用:Minor GC 时,把 Eden 和当前 Survivor 中存活的对象复制到另一个 Survivor,然后清空前者。这样每次都有一块干净的空间可用。
题目二:对象年龄阈值默认是 15,为什么不能设太小?
答案
如果阈值太小,很多对象还没来得及死亡就被晋升到老年代,导致老年代快速增长,触发频繁的 Major GC。Major GC 比 Minor GC 慢得多。默认 15 是经验值,大多数短命对象在年轻代就被回收了,少数真正长寿的对象才晋升。年龄存储在对象头的 Mark Word 中,占 4 位,所以最大值就是 15。