跳转至

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。