垃圾回收算法与收集器:从 Serial 到 ZGC

从可达性分析讲起,拆解标记-清除、复制、标记-整理三种基础 GC 算法,说明分代收集为什么新生代用复制、老年代用整理,再沿 Serial、Parallel、CMS、G1、ZGC 的演进讲清吞吐量优先与低停顿优先两条路线,最后给出收集器选型。

Java 程序员不用手动 free 内存,是因为 JVM 有垃圾回收(GC)——它自动找出堆里“不再被使用的对象”并回收。这个“自动”是有代价的:GC 要暂停业务线程(Stop The World)、要占用 CPU,选错收集器,程序就会在“吞吐掉下去”和“卡顿变长”之间挣扎。

第一篇 JVM 内存结构讲过堆分代(新生代 + 老年代),那是“内存怎么摆”;这篇讲“摆在那里的内存怎么收”。GC 的核心问题只有一个:怎么以最小的代价,回收最多的垃圾。围绕这个目标,演进出了三种基础算法和五代收集器。

本文回答一个问题:GC 怎么判断对象可回收、三种基础算法怎么工作、分代收集为什么这么配、以及 Serial 到 ZGC 这几代收集器各解决了什么。主要依据是 HotSpot 的实现,版本差异在关键处标注。

一、怎么判断对象死了:可达性分析

回收的第一步是判断“哪些对象还能用”。Java 用可达性分析:从一组叫 GC Roots 的对象出发,顺着引用链能到达的对象就是活的,到不了的就是垃圾。

GC Roots 包括:虚拟机栈(栈帧局部变量表)里引用的对象、静态变量引用的对象、运行时常量池里的引用、JNI 引用的对象、活跃线程本身。第一篇讲过栈是线程私有的、堆是共享的——GC 正是从栈里的引用出发,去堆里标记“活的”对象。所以“局部变量指向的对象不会被回收、局部变量离开作用域后对象就可以回收”,背后就是可达性分析。

一个对象的生命周期就此清晰:被 GC Roots 引用链连着时是活的;引用链断开(局部变量出作用域、list.remove、置 null)后,就变成垃圾,等下一次 GC 收走。

二、三种基础算法

找出垃圾之后,怎么回收?有三种基础算法,各有各的代价。

标记-清除(Mark-Sweep):先标记存活对象,再清除未标记的。简单,但清除后内存七零八落,产生大量碎片——以后想分配一个大对象,可能明明有足够总空间、却没有连续空间放得下。

复制(Copying):把内存分成两块,只在一块里分配;GC 时把存活对象复制到另一块,然后把原块整块清空。无碎片、速度快,但代价是浪费一半空间。它适合“存活对象少”的场景——复制的工作量小,浪费的那半空间换来“整块清空”的高效。

标记-整理(Mark-Compact):标记存活对象后,把它们向一端移动、紧凑排列,然后清掉边界外的内存。无碎片,但“移动对象”本身有成本,还要更新所有指向这些对象的引用。

三种 GC 基础算法

这三种算法没有谁绝对更好,关键是“套在什么场景”上——这就引出了分代收集。

三、分代收集:新生代复制,老年代整理

第一篇说过,堆分新生代和老年代,依据是“大部分对象朝生夕死”。这个假设直接决定了两个代各用哪种算法。

新生代对象大多很快死掉,每次 GC 后存活的对象很少,所以用复制算法——把存活的少数对象复制到 Survivor(或另一个 Survivor),然后清空 Eden。浪费的那半空间(Eden 相对大、Survivor 相对小)换来了“只搬少数存活对象”的高效。

老年代对象大多活得久,每次 GC 存活对象很多,用复制就不划算(要搬一大堆);又需要避免碎片,所以用标记-整理(或标记-清除 + 空闲列表)。整理要移动对象、更新引用,成本高,但老年代 GC 频率低,可以接受。

分代收集:新生代复制、老年代整理

于是“分代”不是目的,是手段:它让两种算法各得其所——新生代用复制的高效、老年代用整理的紧凑。这也是为什么谈 GC 一定要先谈分代,否则“复制算法浪费一半空间”和“整理算法移动成本高”这两句话看着互相矛盾。

顺带分清三种 GC 的叫法:Minor GC 是新生代 GC,只回收 Eden 和 Survivor,触发频繁、速度快;Major GC 是只回收老年代的 GC,单独出现的不多,常由 Full GC 连带触发;Full GC 是整堆(新生代 + 老年代)的 GC,停顿最长、代价最大。日常调优最关心的是 Full GC——它越频繁、单次越久,说明堆的配置或收集器的选择越需要调整。

四、收集器演进:从 Serial 到 ZGC

算法之上,HotSpot 实现了一系列收集器,演进的主线是“怎么减少停顿”。

Serial / ParNew:最早的单线程收集器。Serial 新生代复制、老年代标记-整理,全程 Stop The World——GC 期间所有业务线程停摆。ParNew 是它的多线程版(只新生代并行)。它们简单、开销小,适合小堆、客户端程序。

Parallel Scavenge / Parallel Old:吞吐量优先的并行收集器,新生代、老年代都多线程并行。它追求的是“总吞吐最大”,能接受较长的单次停顿。适合后台计算、批处理。

CMS(Concurrent Mark Sweep):老年代并发收集器,目标是“低停顿”——大部分标记、清除工作和业务线程并发执行,只留短暂停顿。但 CMS 有先天缺陷:标记-清除会碎片化(碎片多了触发 Full GC)、并发阶段占用 CPU、可能“并发失败”退化成 Serial。它已被废弃:JDK 9 标记弃用,JDK 14 移除。

G1(Garbage First):把堆分成大小相等的 Region,不再严格分“一块新生代、一块老年代”,而是按需把 Region 划给某个代。它用“可预测的停顿模型”——设定一个停顿目标(如 200ms),G1 每次只回收“垃圾最多”的一部分 Region(Garbage First 名字的由来),把停顿控制在目标内。JDK 9 起,G1 成为默认收集器。

ZGC:最新的超低停顿收集器,用“着色指针”和“读屏障”把几乎所有工作并发化,停顿低到亚毫秒,而且不随堆变大而变长。它面向超大堆(TB 级)和极低延迟场景。JDK 11 引入(实验性),JDK 15 生产可用。同类的还有 Shenandoah(JDK 12 引入)。

GC 收集器的演进路线

这条演进的主线是:从“吞吐量优先、接受长停顿”(Parallel),走向“低停顿优先、几乎不暂停业务”(G1、ZGC)。每进一步,停顿更短、能处理的堆更大,代价是实现的复杂度和对 CPU 的占用更高。

G1 的 Region:为什么它能“预测停顿”

G1 最值得单独讲的机制就是 Region,因为“可预测停顿”这个卖点完全建立在它上面。

G1 把整个堆切成大小相等的 Region(默认约 2048 个,每个 1~32MB,且都是 2 的幂),每个 Region 在任意时刻扮演一种角色:Eden、Survivor、Old,或者 Humongous(存放大对象)。注意“角色”是动态的——上一轮还是 Eden 的 Region,回收后可以变成 Old,也可以下一轮再当 Eden。这与传统收集器“新生代和老年代是两块固定的连续内存”完全不同。

G1 的 Region 布局与回收选择

这套布局带来三个直接后果:

  • 回收单位从“整代”变成“部分 Region”:G1 不必一次收完整个新生代或老年代,可以只挑一部分 Region 来收。这正是 “Garbage First” 名字的来源——优先回收垃圾最多的那些 Region(收益最高、最划算);
  • 停顿可以“预测”:G1 维护每个 Region 的回收成本(大概要花多久)和收益(能回收多少垃圾),回收时按 -XX:MaxGCPauseMillis 设定的目标,挑一组“在预算内、收益最大”的 Region 去回收。所以停顿目标是一个软目标——G1 尽力控制,但不保证绝对不超;
  • 大对象有专门处理:一个对象超过 Region 一半大小,就放进 Humongous Region(太大时可能横跨多个连续 Region)。这类对象回收代价高,所以写代码时要避免创建“不大不小”的大对象——它们会直接进老年代,白白加剧 Full GC。

G1 的 GC 类型也相应变成三种:Young GC(只收 Eden / Survivor)、Mixed GC(收新生代 + 一部分回收收益高的老年代 Region)、Full GC(退化的兜底,会 STW 并可能做整堆整理)。日常调优最该盯的是 Mixed GC 的频率与耗时,以及有没有退化成 Full GC。

三种收集器的停顿与吞吐对照

把前面的讨论收进一张表,选型时对照着看:

收集器 停顿 吞吐 堆规模 适合
Serial 长(全程 STW) 中 小(百 MB 级) 客户端、小容器
Parallel 较长(可调) 最高 中(GB 级) 批处理、后台计算
CMS 短 中 中 已移除,用 G1 替代
G1 可控(软目标) 中高 大(几十 GB) 绝大多数在线服务
ZGC 亚毫秒 中 超大(TB 级) 极低延迟、超大堆

五、吞吐量和停顿,是两条路线

理解收集器的选择,关键是把目标拆成两个维度:吞吐量(GC 占总时间的比例)和停顿时间(单次 GC 停多久)。

Parallel 系列走吞吐量路线:GC 时多线程全速并行,尽快收完,但单次停顿可能几百毫秒甚至更长。适合“跑批、算数、不在乎偶尔卡一下”的场景。

CMS、G1、ZGC 走低停顿路线:尽量让 GC 和业务并发,单次停顿短(G1 可控、ZGC 亚毫秒),但并发阶段占用 CPU、总吞吐略降。适合“在线服务、用户对延迟敏感”的场景。

没有“更好的收集器”,只有“更匹配的收集器”——选之前先想清楚:你的服务是要“单位时间处理更多任务”,还是要“每个请求都别卡”。这两个目标,多数时候不能同时拿满分。

六、怎么选收集器

场景 推荐 理由
小堆、客户端程序 Serial 简单、开销小
后台计算、批处理,吞吐优先 Parallel 吞吐最大
在线服务,堆中等(< 几 GB) G1(JDK 9 默认) 停顿可控,够用
超大堆、极低延迟 ZGC 亚毫秒停顿,不随堆变大
旧的 CMS 迁移到 G1 CMS 已移除,别再上

一个朴素的判断:现代 JDK 上,默认的 G1 对绝大多数在线服务已经够好,先别急着换;只有当 G1 的停顿满足不了 SLO、或堆大到 TB 级时,才考虑 ZGC。GC 调优的最大陷阱是“凭感觉换收集器、再凭感觉调参数”,正确姿势是先拿到 GC 日志和停顿数据,确认瓶颈在 GC、且是哪种停顿,再决定动哪个收集器、哪个参数。

# 常见 GC 相关 JVM 参数
-Xms4g -Xmx4g                     # 初始堆 / 最大堆(建议设成相等,避免动态扩缩)
-XX:+UseG1GC                      # 使用 G1 收集器
-XX:MaxGCPauseMillis=200          # G1 停顿目标(软目标)
-Xlog:gc*:file=gc.log:time,uptime # JDK 9+ 统一日志:把 GC 日志写到文件
-XX:+HeapDumpOnOutOfMemoryError   # OOM 时自动 dump,事后分析必备

这套参数里有两个容易忽略但很关键的细节:

  • -Xms 和 -Xmx 设成相等:如果两者不等,堆会在运行中反复扩容/缩容,每次都可能触发 Full GC。生产环境一律设成相等,把堆“钉死”。
  • -Xlog:gc*(JDK 9+)取代了 -XX:+PrintGCDetails:老参数在 JDK 9 之后被统一日志框架(Unified Logging)替代,现在还写 PrintGCDetails 会得到一个“已被忽略”的警告。这是很多人照着旧文章配参数、结果发现没有日志的原因。

GC 日志到底怎么看

开了日志之后,一行典型的 G1 日志长这样:

[2026-10-05T10:12:33.412+0800][info][gc] GC(42) Pause Young (Normal) (G1 Evacuation Pause) 512M->128M(2048M) 18.432ms

从左往右逐段读,每一段都在回答一个具体问题:

片段 含义 该关心什么
GC(42) 第 42 次 GC 计数增长快不快
Pause Young (Normal) GC 类型:Young GC 是 Young / Mixed / Full 哪一种
(G1 Evacuation Pause) 具体原因:G1 的复制暂停 出现 Full 就是警报
512M->128M(2048M) 回收前 → 回收后(堆总容量) 回收后降不下来 = 有对象在堆积
18.432ms 本次停顿耗时 是否超过你的 SLO

这五段里,最能说明问题的两组数字是:

  1. 512M->128M 这个“回收后”的值。如果它在一段时间里持续上涨(比如 128M → 400M → 900M),说明有对象在被持续创建且活得越来越久——典型的内存泄漏特征,这时该去看堆 dump,而不是调 GC 参数;
  2. 18.432ms 这个停顿。把一段时间内的停顿排个序,看 P99 是多少。如果 P99 远超 MaxGCPauseMillis,说明停顿目标没达标——可能堆太大、可能 Region 回收收益不佳、也可能有大量 Humongous 对象,才轮到调参数。

再配合两个常用命令:jstat -gcutil <pid> 1000 每隔一秒打印各代使用率(观察老年代是否持续上涨),jmap -dump:live,format=b,file=heap.bin <pid> 导出堆快照(用 MAT 等工具找泄漏对象)。先用数据定位“是什么问题”,再决定“改哪个参数”——调 GC 的顺序是:先开日志、拿到每次 GC 的类型和耗时,再决定动参数;没有日志就调参,和没有检查单就开刀一样盲目。

GC 的完整图景是:可达性分析决定“谁该死”,三种算法决定“怎么收”,分代让算法各得其所,收集器把算法落地成“吞吐或低停顿”的具体取舍。下一篇三色标记,会深入 CMS 和 G1 并发标记的底层——那时这套图景就闭环了。

参考资料