4. 垃圾收集器原理与选型
垃圾收集器是 JVM 内存管理中真正决定停顿表现的部分。堆结构、对象分配、可达性分析只是“准备条件”,而收集器决定的是:什么时候回收、用多少线程回收、是否移动对象、停顿能压到多低。选错收集器,往往不是吞吐量差一点,而是延迟指标直接失控。因此理解各收集器的机制差异,比记住“G1 适合大堆、ZGC 适合低延迟”这类结论更重要。
一、先看分代假设与收集器演进
绝大多数收集器都建立在弱分代假设上:大部分对象朝生夕死,少数对象长期存活。于是堆被划分为年轻代和老年代,年轻代又分为 Eden 与两个 Survivor 区。新对象优先在 Eden 分配,Minor GC 时把存活对象复制到 Survivor,经历多次回收仍存活的对象晋升到老年代。当老年代空间不足时触发 Full GC 或混合回收。
不同收集器的差异,本质上是在“吞吐量、停顿时间、内存占用”三者之间做不同取舍。吞吐量关注单位时间内完成的有效工作比例;停顿时间关注单次 GC 让应用暂停多久;内存占用关注收集器自身需要的额外结构。三者不可兼得,选型就是根据业务 SLA 决定优先保谁。
二、Serial 与 Parallel:吞吐量优先的两种形态
Serial 是最简单的收集器,年轻代用复制算法,老年代用标记整理算法,全程单线程 STW。它的优势是额外内存开销小、没有线程交互成本,在单核或小堆环境下反而效率最高。启动参数为 -XX:+UseSerialGC。如果应用堆只有几百 MB、部署在单核容器中,Serial 的停顿可能只有几十毫秒,完全可接受。
Parallel 又称吞吐量收集器,把 Serial 的单线程换成多线程并行回收,年轻代用 Parallel Scavenge,老年代用 Parallel Old。它不追求最短停顿,而是追求最高吞吐量。关键参数是 -XX:MaxGCPauseMillis 和 -XX:GCTimeRatio,前者设置期望最大停顿,后者设置 GC 时间占比。例如 -XX:GCTimeRatio=99 表示希望 GC 时间不超过总时间的 1%。Parallel 适合离线计算、报表生成、大数据批处理这类“跑完就行、中间卡一下无所谓”的任务。
三、CMS:并发标记清除的得与失
CMS 是第一款真正意义上的并发收集器,目标是把停顿压到最低。它把回收拆成四个阶段:初始标记、并发标记、重新标记、并发清除。初始标记和重新标记需要 STW,但时间很短;并发标记和并发清除与应用线程同时运行。启用参数为 -XX:+UseConcMarkSweepGC。
CMS 的问题在于标记清除算法不移动对象,长期运行会产生大量内存碎片。当老年代没有连续空间分配大对象时,会触发 Concurrent Mode Failure,退化为 Serial Old 做一次 Full GC,停顿反而更长。此外 CMS 还有浮动垃圾问题,并发清除阶段新产生的垃圾只能留到下次回收。因此 CMS 在 JDK 9 被标记废弃,JDK 14 正式移除。新项目不应再选 CMS,但维护老系统时需要理解它的退化路径。
四、G1:面向大堆的可预测停顿收集器
G1 把堆划分为大小相等的 Region,每个 Region 可以扮演 Eden、Survivor 或 Old 角色。它不再要求物理连续的分代,而是用逻辑分代管理。G1 的核心是停顿预测模型:维护每个 Region 的回收价值(回收能释放多少空间、需要多少时间),每次 Young GC 或 Mixed GC 时优先回收价值最高的 Region,从而在指定停顿目标内获得最大收益。
G1 的回收过程包括初始标记、并发标记、最终标记、筛选回收。其中并发标记与筛选回收阶段部分并发,停顿可控。关键参数:
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 期望最大停顿,默认 200ms
-XX:G1HeapRegionSize=16m # Region 大小,1~32MB,2 的幂
-XX:InitiatingHeapOccupancyPercent=45 # 触发并发标记的堆占用阈值
-XX:G1NewSizePercent=5 # 年轻代最小占比
-XX:G1MaxNewSizePercent=60 # 年轻代最大占比
G1 适合堆在 4GB 到数十 GB、要求停顿在 200ms 以内的服务端应用。JDK 9 起 G1 成为默认收集器。调优时不要同时设置 -Xmn 和 MaxGCPauseMillis,否则会干扰 G1 自适应调整年轻代大小。
五、ZGC 与 Shenandoah:亚毫秒停顿的两种实现
ZGC 的目标是任意堆大小下停顿都不超过 1ms。它依靠染色指针和读屏障实现并发标记、并发转移、并发重定位。染色指针把标记信息存在指针本身而不是对象头,读屏障在应用线程读取引用时修正指针,从而允许对象在应用运行期间被移动。ZGC 在 JDK 11 引入,JDK 15 转正,JDK 16 起停顿进入亚毫秒级。启用参数为 -XX:+UseZGC,JDK 21 起支持分代 ZGC:-XX:+UseZGC -XX:+ZGenerational。
Shenandoah 由 Red Hat 主导,思路与 ZGC 类似但实现不同:它使用 Brooks 转发指针,每个对象额外维护一个转发字段,并发压缩整理时通过转发指针定位新地址。Shenandoah 的停顿同样与堆大小无关,目标在 10ms 级。启用参数为 -XX:+UseShenandoahGC。两者都适合超低延迟场景,区别在于 ZGC 对超大堆(TB 级)更友好,Shenandoah 在中等堆上内存开销更可控。
六、选型决策与实操建议
选型时先明确延迟目标,再看堆规模,最后看 JDK 版本。下面给出一个可直接对照的决策表:
| 场景 | 推荐收集器 | 关键参数 | 判断依据 |
|---|---|---|---|
| 单核小堆客户端 | Serial | -XX:+UseSerialGC | 堆 < 500MB,停顿可接受 |
| 离线批处理、吞吐优先 | Parallel | -XX:+UseParallelGC -XX:GCTimeRatio=99 | 不关心单次停顿 |
| JDK 8 老系统低延迟 | CMS(维护) | -XX:+UseConcMarkSweepGC | 仅维护,不新选 |
| 通用服务端、堆 4~32GB | G1 | -XX:+UseG1GC -XX:MaxGCPauseMillis=200 | JDK 9+ 默认 |
| 超低延迟、堆 > 32GB | ZGC | -XX:+UseZGC | 停顿 < 1ms 要求 |
| 低延迟、中等堆 | Shenandoah | -XX:+UseShenandoahGC | 停顿与堆大小无关 |
-Xlog:gc*:file=gc.log:time,uptime,level,tags 打开 GC 日志,观察 GC 频率、停顿分布和堆增长趋势,再决定是否换收集器。不要在没有日志依据的情况下盲目切换。最后强调一个常见误区:收集器不是越新越好。G1 在 8GB 堆、200ms 停顿目标下表现稳定;ZGC 在 4GB 堆上未必比 G1 吞吐更高。选型的本质是让收集器的取舍方向与业务的 SLA 方向一致。延迟敏感选 ZGC 或 Shenandoah,吞吐敏感选 Parallel,通用场景选 G1,资源受限选 Serial。理解机制,才能在看 GC 日志时判断“这次停顿是正常的还是退化了”。