Java 垃圾回收全解:从分代内存模型到各收集器的分代职责
很多人问"垃圾收集器到底是收哪一代的垃圾",这个问题的坑在于:它默认了每个收集器都收"一整堆"。实际上 JVM 把堆分成了年轻代和老年代,不同收集器要么只负责其中一代、要么整堆一把抓、要么干脆不分代。搞懂这张地图,比死记每个收集器的名字有用得多。
本文按一条递进链讲透:GC 为什么存在(内存模型)→ GC 在收什么(对象可达性)→ 用什么算法收(三种回收算法)→ 具体哪个收集器收哪一代(核心)→ 怎么搭配怎么选型。
一、先厘清:GC 到底在收"哪一代"的垃圾
1.1 JVM 运行时内存分区
JVM 把运行时内存按用途分了几块。跟垃圾回收直接相关的只有堆(Heap),其他区域要么不参与回收、要么回收规则简单:
| 区域 | 是否参与 GC | 说明 |
|---|---|---|
| 堆(Heap) | ✅ 主要回收对象 | 存放对象实例,GC 的主战场 |
| 方法区 / 元空间(Metaspace) | ⚠️ 有回收但较弱 | 存放类元信息、常量池,JDK8+ 用元空间替代永久代,主要回收"废弃的类和无用的常量" |
| 虚拟机栈 / 本地方法栈 / 程序计数器 | ❌ 不参与 GC | 栈帧随方法进出栈自动创建销毁 |
🔴 重点:平时说的"垃圾回收",九成九指堆的回收。
1.2 堆的分代模型
堆内部不是一坨整块,而是按"对象存活时间"切成不同区域:
sql
┌────────────────────────────── 堆 ──────────────────────────────┐
│ 年轻代(Young) │ 老年代(Old) │
│ ┌──────────┬─────┬─────┐ │ │
│ │ Eden │ S0 │ S1 │ │ │
│ └──────────┴─────┴─────┘ │ │
└──────────────────────────────────────────────────────────────┘
Eden:新对象出生地
Survivor S0/S1:从 Eden 熬过一轮 GC 的"幸存者",两块来回倒
Old:熬过多轮 GC 的对象,或者大对象直接进来
- 年轻代:Eden + 两块 Survivor(S0/S1)。绝大多数对象在这出生、也在这消亡。
- 老年代:存活久、晋升上来的对象,以及大对象。
- 分代回收的三种叫法:Young GC(新生代/Minor GC)、Old GC(Major GC,一般特指老年代)、Full GC(整堆回收)。
1.3 为什么要分代:弱分代假说
分代不是拍脑袋,背后是两个经验性规律(合称"分代收集理论"):
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是"朝生夕灭"的,用完就扔。
- 强分代假说(Strong Generational Hypothesis):熬过越多次回收的对象,越难以消亡。
由此推导出一个设计:把朝生夕灭的对象集中放在年轻代,回收时只处理这一小块、重点保留少量存活对象,而不是每次整堆扫描去标记大量将死的对象。这大幅降低了单次回收的成本。
⚠️ 避坑:分代是"大多数情况"下的优化,不代表老年代对象不会引用年轻代对象。做年轻代回收时,JVM 会借助"记忆集(Remembered Set)"来记录老年代对年轻代的引用,避免整堆扫描老年代。
二、回收的前提:先判定哪些对象是"垃圾"
GC 的第一步不是"收",而是"判断谁该死"。主流判断方式是可达性分析,而不是简单数引用。
2.1 引用计数法的缺陷
早期语言用引用计数:每个对象记录被引用的次数,为 0 就回收。它有两个硬伤,导致 JVM 不用它:
- 无法处理循环引用(A 引 B、B 引 A,彼此都不为 0,但已无人可达)。
- 每次引用赋值都要维护计数器,开销大。
2.2 可达性分析与 GC Roots
JVM 采用可达性分析:从一组根节点(GC Roots)出发,沿着引用链往下走,能走到的对象标记为"存活",走不到的即为可回收垃圾。
可作为 GC Roots 的对象主要包括:
| GC Root | 来源 |
|---|---|
| 虚拟机栈(栈帧本地变量表)中引用的对象 | 正在执行的方法里的局部变量 |
| 方法区中静态属性引用的对象 | 类的静态变量 |
| 方法区中常量引用的对象 | 字符串常量池、字面量等 |
| 本地方法栈中 JNI 引用的对象 | Native 方法持有的引用 |
| 被同步锁(synchronized)持有的对象 | 锁监视器 |
| 活跃线程对象、类加载器等 | JVM 内部引用 |
✅ 一句话:从 GC Roots 出发"摸得到的活着,摸不到的该死"。
三、回收算法:不同代为什么用不同算法
判断完谁该死,下一步是用什么姿势收。三大经典算法各有短板,这正是"哪一代用哪个算法"由来的根源。
3.1 标记-清除(Mark-Sweep)
分两步:标记存活对象,再统一清除未被标记的对象。
- 优点:实现简单,无需移动对象。
- 缺点:产生大量内存碎片,导致后续分配大对象时明明有空间却放不下;且标记和清除两个阶段效率都随对象数量下降。
3.2 复制(Copying)
把内存分成两块,只用一块;回收时把存活对象复制到另一块,原块一次性清空。
- 优点:无碎片,实现简单,只处理存活对象(存活少时极快)。
- 缺点:浪费空间(总有一半空闲),存活对象多时复制成本高。
所以复制算法天然适合年轻代------因为年轻代绝大多数对象朝生夕灭、存活率低,复制成本低,还能省下整理碎片的时间。JVM 把它细化为 Eden + 两块 Survivor(比例默认 8:1:1),不是简单对半切,避免一半内存闲置。
3.3 标记-整理(Mark-Compact)
标记存活对象后,把它们往一端移动压实,再清掉边界之外的空间。
- 优点:无碎片,空间利用率高。
- 缺点:移动对象需要更新所有引用,STW 成本高。
标记-整理适合老年代------老年代对象存活率高、复用复制算法不划算,而长期运行又受不了碎片。
3.4 算法与代的对应关系
| 算法 | 空间浪费 | 碎片 | 存活率高时表现 | 适合的代 |
|---|---|---|---|---|
| 标记-清除 | 无 | 严重 | 一般 | (CMS 用,靠后续整理兜底) |
| 复制 | 有 | 无 | 差 | 年轻代 |
| 标记-整理 | 无 | 无 | 好 | 老年代 |
四、各收集器 + 分代职责(核心章节)
🔴 本节回答你最关心的问题:每个收集器到底收哪一代。先记结论------传统收集器分三类:只收年轻代、只收老年代、整堆一把抓;而 G1 用 Region 打破了代边界,ZGC / Shenandoah 干脆不分代。
4.1 只收集【年轻代】的收集器
| 收集器 | 算法 | 线程 | 特点 |
|---|---|---|---|
| Serial | 复制 | 单线程 | 最简单,适合单核/客户端、内存小的场景 |
| ParNew | 复制 | 多线程 | Serial 的多线程版,只能配合 CMS 使用 |
| Parallel Scavenge | 复制 | 多线程 | 目标是吞吐量优先,可精确控制吞吐量 |
- Serial:垃圾回收时"Stop The World"(暂停应用)并单线程收集,简单可靠,是默认兜底。
- ParNew:本质是 Serial 的并行版。它的存在意义很大一部分是"给 CMS 当年轻代搭档"------CMS 只处理老年代,年轻代得有人收。
- Parallel Scavenge:关注点在于让吞吐量最大化(CPU 用于用户代码的时间占比),适合后台批量计算场景。
4.2 只收集【老年代】的收集器
| 收集器 | 算法 | 线程 | 特点 |
|---|---|---|---|
| Serial Old(MSC) | 标记-整理 | 单线程 | Serial 的老年代版 |
| Parallel Old | 标记-整理 | 多线程 | Parallel Scavenge 的搭档,吞吐量优先 |
| CMS | 标记-清除 | 并发 | 并发低停顿,但会产生碎片 |
- Serial Old / Parallel Old:分别是 Serial / Parallel Scavenge 的"老年代另一半",靠标记-整理消灭碎片。
- CMS(Concurrent Mark Sweep) :追求最短回收停顿 ------标记清除的主要阶段与应用线程并发执行,不全程 STW。代价是标记-清除产生内存碎片 ,且并发阶段占用 CPU。 ⚠️ 避坑:CMS 只收老年代,不碰年轻代 。所以它必须搭配一个年轻代收集器(通常就是 ParNew)。这也是"ParNew + CMS"成为经典组合的原因。 📌 归宿:CMS 在 JDK9 被标记废弃,JDK14(JEP 363)正式移除,由 G1 接棒。
4.3 整堆收集器:G1(打破代边界)
G1(Garbage First) 不再把堆简单切为年轻代/老年代两块,而是把整堆划分成若干固定大小的 Region。每个 Region 可以动态扮演 Eden、Survivor 或 Old 的角色,逻辑上的"年轻代/老年代"只是 Region 集合的概念。
- 收集目标 :整个堆,但优先回收"垃圾最多"(回收价值最大)的 Region------这就是名字 "Garbage First" 的由来。
- 特点 :可预测停顿时间(通过
-XX:MaxGCPauseMillis设定目标),兼顾吞吐与延迟,适合大堆(GB 级以上)。 - 地位 :JDK9(JEP 248)起成为默认收集器,至今仍是默认。
✅ 一句话:G1 名义上"整堆收集",但内部仍保留年轻代/老年代的分代回收逻辑,只是边界由 Region 动态划定。
4.4 不分代收集器:ZGC / Shenandoah
这两兄弟颠覆了"分代"前提------没有年轻代、老年代的概念,直接整堆回收 。它们追求的是极短甚至接近可忽略的 STW,面向大堆、低延迟场景。
| 收集器 | 分代? | 核心卖点 | 演进关键节点 |
|---|---|---|---|
| ZGC | 最初不分代 | 停顿时间不随堆大小增长,可到毫秒级 | JDK11 引入(实验)→ JDK15 转正 → JDK21 分代化(JEP 439) → JDK23 分代默认 → JDK24 移除非分代模式 |
| Shenandoah | 最初不分代 | 与 ZGC 类似的低延迟,RedHat 开发 | JDK12 引入(实验)→ JDK15 转正 → JDK25 分代化(JEP 521) |
📌 有意思的演进:"分代"本身是一种优化 。ZGC / Shenandoah 最初为了极简放弃分代,但后来发现年轻对象死得快、频繁回收能省内存和 CPU,于是 JEP 439(ZGC)和 JEP 521(Shenandoah)又都补上了分代能力。这恰好反证了分代假说的价值。
4.5 收集器总表(一句话记忆)
| 收集器 | 收集区域 | 算法 | 特点 / 备注 |
|---|---|---|---|
| Serial | 年轻代 | 复制 | 单线程,最简 |
| ParNew | 年轻代 | 复制 | 多线程,配合 CMS |
| Parallel Scavenge | 年轻代 | 复制 | 吞吐量优先 |
| Serial Old | 老年代 | 标记-整理 | 单线程 |
| Parallel Old | 老年代 | 标记-整理 | 吞吐量优先 |
| CMS | 老年代 | 标记-清除 | 并发低停顿,JDK14 已移除 |
| G1 | 整堆(Region) | 综合 | 优先回收垃圾多的块,JDK9 起默认 |
| ZGC | 整堆(JDK21 后分代) | 综合 | 超低 STW,大堆 |
| Shenandoah | 整堆(JDK25 后分代) | 综合 | 低延迟 |
五、收集器如何搭配与选型
传统收集器是"一代一个",所以存在经典搭配组合;G1 / ZGC 是整堆的,不需要配。
5.1 经典组合
| 组合 | 年轻代 | 老年代 | 适用场景 |
|---|---|---|---|
| Serial + Serial Old | Serial | Serial Old | 单核、内存小、客户端 |
| ParNew + CMS | ParNew | CMS | 追求低延迟(曾经的主流,CMS 已退役) |
| Parallel Scavenge + Parallel Old | Parallel Scavenge | Parallel Old | 吞吐量优先,后台批量计算 |
| G1(单收集器) | 整堆 Region | 整堆 Region | JDK9+ 默认,通用 |
| ZGC / Shenandoah(单收集器) | 不分代(JDK21/25 后分代) | --- | 大堆 + 低延迟 |
5.2 按场景选型(JDK9+ 视角)
- 没特殊诉求:直接 G1(默认)。
- 吞吐量优先(能接受停顿):Parallel Scavenge + Parallel Old。
- 低延迟、堆很大:ZGC 或 Shenandoah。
- 单核 / 小内存 / 调试:Serial。
- CMS:生产环境别再用,它是历史概念,JDK14 起已不存在。
5.3 JDK 版本关键变化(容易考的演进史)
| 版本 | 变化 | 依据 |
|---|---|---|
| JDK 9 | G1 成为默认收集器 | JEP 248 |
| JDK 11 | ZGC 引入(实验) | JEP 333 |
| JDK 12 | Shenandoah 引入(实验) | JEP 189 |
| JDK 14 | CMS 被移除 | JEP 363 |
| JDK 15 | ZGC / Shenandoah 转生产级 | JEP 377 / JEP 379 |
| JDK 21 | 分代 ZGC(Generational ZGC) | JEP 439 |
| JDK 23 | ZGC 分代模式成为默认 | JEP 474 |
| JDK 24 | ZGC 移除非分代模式 | JEP 490 |
| JDK 25 | 分代 Shenandoah | JEP 521 |
⚠️ 注意:上面是较新 JDK 的走向。如果你还在用 JDK8,默认仍是 Parallel,也没有 ZGC / Shenandoah(JDK8 无官方版本),升级前要先想清楚收集器的迁移。
六、总结:你真正需要记住的 N 件事
- GC 的主战场是堆;堆分年轻代(Eden + 两块 Survivor)和老年代。
- 分代的依据是弱分代假说:大多数对象朝生夕灭,集中回收年轻代效率最高。
- 判定垃圾用可达性分析:从 GC Roots 出发摸得到的存活,摸不到的回收;不用引用计数(解决不了循环引用)。
- 三种算法与代对应:年轻代用复制(无碎片、存活少成本低),老年代用标记-整理(无碎片、能扛高存活),标记-清除有碎片(CMS 的代价)。
- 只收年轻代的收集器:Serial、ParNew、Parallel Scavenge。
- 只收老年代的收集器:Serial Old、Parallel Old、CMS(CMS 需配 ParNew 收年轻代)。
- G1 整堆收集但内部仍分代,靠 Region 动态扮演不同代,JDK9 起是默认。
- ZGC / Shenandoah 最初不分代,但 ZGC 在 JDK21、Shenandoah 在 JDK25 都补上了分代------反证分代假说的价值。
- 选型:默认 G1;吞吐优先用 Parallel 双开;大堆低延迟用 ZGC / Shenandoah;CMS 已是历史。
验证清单
- 能不看表说出"每个收集器收哪一代"
- 能解释"为什么年轻代用复制、老年代用标记-整理"
- 能说出 CMS 为什么必须搭配 ParNew
- 能讲清 G1 与"分代"的关系(Region 动态扮演代)
- 能说清 ZGC 从"不分代"到"分代"的演进(JEP 439/474/490)
- 能在面试中被问到"默认收集器"时答出"G1(JDK9+)"
参考资源
- Oracle 官方 GC 调优指南(HotSpot Virtual Machine Garbage Collection Tuning Guide)
- Oracle《Migrating from JDK 8 to Later JDK Releases》(G1 默认 / JDK 迁移变化)
- JEP 439: Generational ZGC(OpenJDK)
- JEP 363: Remove the Concurrent Mark Sweep (CMS) Garbage Collector
- JEP 248: Make G1 the Default Garbage Collector
- JEP 474: ZGC: Generational Mode by Default / JEP 490: ZGC: Remove the Non-Generational Mode
- JEP 521: Generational Shenandoah
- 《深入理解 Java 虚拟机》(周志明)------分代收集理论、可达性分析、GC Roots 权威参考