4. GC策略与调优:常见GC算法对比、CMS与G1实战参数、ZGC入门

GC调优,说白了就是跟JVM的垃圾回收器打交道。很多同学觉得这玩意儿玄学,其实不然。我做了这么多年运维,踩过的坑不少,但GC这块只要理清思路,大部分问题都能找到规律。

今天咱们就聊聊GC策略。我会把常见的算法、CMS和G1的实战参数,还有ZGC的入门知识,一次性讲清楚。

4.1 常见GC算法对比

先看基础。GC算法就那么几种,但每种都有它的脾气。

算法 核心思想 优点 缺点 适用场景
标记-清除 先标记存活对象,再清除未标记的 实现简单,不移动对象 产生内存碎片,效率随对象增多下降 老年代,配合CMS使用
标记-复制 将存活对象复制到另一块区域 无碎片,分配高效 浪费一半内存,存活率高时效率低 新生代(Young GC)
标记-整理 标记存活对象,然后向一端移动 无碎片,内存利用率高 移动对象开销大,STW时间长 老年代,CMS的备选方案
分代收集 按对象存活周期分代,不同代用不同算法 综合性能最优,兼顾吞吐和延迟 实现复杂,跨代引用处理麻烦 几乎所有生产环境

我个人习惯把分代收集当作默认选择。你想想看,新生代对象朝生夕死,用复制算法最合适;老年代对象活得久,用标记-整理或标记-清除更合理。这就是为什么HotSpot虚拟机一直坚持分代设计。

核心观点:没有完美的GC算法,只有最适合当前场景的算法。选型时优先考虑延迟要求,其次才是吞吐量。

4.2 CMS实战参数

CMS(Concurrent Mark Sweep)是老牌的低延迟GC。它的目标是减少STW时间,适合对响应时间敏感的应用。

我在项目中遇到过一个问题:一个电商平台的订单服务,用了CMS,但每次GC后CPU飙升。后来发现是CMS的并发模式失败(Concurrent Mode Failure)导致的。嗯,这里要注意,CMS最怕的就是这个。

4.2.1 核心参数

-XX:+UseConcMarkSweepGC      # 启用CMS
-XX:+UseParNewGC             # 新生代使用ParNew(CMS的标配)
-XX:CMSInitiatingOccupancyFraction=70  # 老年代使用率达到70%时触发CMS
-XX:+UseCMSInitiatingOccupancyOnly     # 只使用设定的阈值,不动态调整
-XX:+CMSScavengeBeforeRemark  # 在重新标记前做一次Young GC
-XX:+CMSParallelRemarkEnabled # 并行重新标记
-XX:CMSFullGCsBeforeCompaction=5  # 每5次Full GC后做一次整理
-XX:+UseCMSCompactAtFullCollection # Full GC时进行内存整理

我建议你把 -XX:CMSInitiatingOccupancyFraction 设到70%左右。设得太低,CMS频繁触发,浪费CPU;设得太高,容易触发Concurrent Mode Failure,导致Full GC。

避坑指南:我曾经把CMSInitiatingOccupancyFraction设到90%,结果线上频繁出现Concurrent Mode Failure。后来改成75%,配合CMSScavengeBeforeRemark,问题解决了。记住,CMS的并发收集阶段不能回收浮动垃圾,所以阈值不能设得太高。

4.2.2 CMS的六个阶段

  1. 初始标记(STW):标记GC Roots直接关联的对象。时间很短。
  2. 并发标记:从GC Roots开始遍历整个对象图。耗时最长,但与应用并发执行。
  3. 并发预清理:处理并发标记阶段发生变化的对象。
  4. 重新标记(STW):修正并发标记期间变动的对象。时间比初始标记长,但比并发标记短。
  5. 并发清除:清除未标记的对象。与应用并发执行。
  6. 并发重置:重置CMS内部数据结构,准备下一次GC。

你看,CMS只有两个STW阶段:初始标记和重新标记。这就是它低延迟的秘密。但代价是CPU占用高,而且会产生内存碎片。

4.3 G1实战参数

G1(Garbage First)是CMS的接班人。它把堆分成多个Region,优先回收垃圾最多的Region。说白了,就是“哪块垃圾多,先收哪块”。

我记得有一次帮一个金融客户调优,他们的交易系统用了G1,但延迟总是不稳定。后来发现是Region大小设置不合理。嗯,这里要重点讲一下。

4.3.1 核心参数

-XX:+UseG1GC                # 启用G1
-XX:G1HeapRegionSize=4m     # Region大小(1MB~32MB,默认根据堆大小自动计算)
-XX:MaxGCPauseMillis=200    # 目标最大停顿时间(默认200ms)
-XX:G1NewSizePercent=5      # 新生代初始占比(默认5%)
-XX:G1MaxNewSizePercent=60  # 新生代最大占比(默认60%)
-XX:G1HeapWastePercent=5    # 允许浪费的堆空间百分比(默认5%)
-XX:G1MixedGCLiveThresholdPercent=85  # 混合GC中存活对象占比阈值
-XX:G1MixedGCCountTarget=8  # 混合GC的目标次数
-XX:G1ReservePercent=10     # 预留空间百分比,防止晋升失败

注意:G1的停顿时间目标(MaxGCPauseMillis)不是硬性保证。如果设得太小(比如10ms),G1会频繁调整,反而影响性能。我建议从200ms开始,根据实际监控逐步下调。

4.3.2 G1的Region设计

G1把堆分成大小相等的Region。每个Region可以是Eden、Survivor、Old或者Humongous(大对象区域)。这种设计让G1可以灵活地调整各代大小,不需要固定比例。

你想想看,传统分代GC的年轻代和老年代是连续的,大小固定。G1打破了这种限制,每个Region独立管理,回收时只选垃圾最多的Region,效率自然高。

4.3.3 实战调优建议

  • Region大小:建议设为2MB~8MB。太小会导致Region数量过多,管理开销大;太大则失去细粒度优势。
  • 停顿时间:从200ms开始,观察GC日志。如果实际停顿远低于目标,可以适当调低;如果频繁超时,需要调高。
  • 混合GC:G1的混合GC会回收老年代Region。如果老年代增长快,可以调大G1MixedGCLiveThresholdPercent,让更多Region参与回收。

个人经验:我曾经遇到一个案例,G1的Full GC频繁触发。排查后发现是Humongous对象(超过Region大小50%的对象)导致的。解决方案是调大Region大小,或者优化代码减少大对象分配。记住,G1处理大对象很吃力。

4.4 ZGC入门

ZGC(Z Garbage Collector)是JDK 11引入的低延迟GC。它的目标是STW时间不超过10ms,而且不随堆大小增长。说白了,就是“堆越大,越能体现ZGC的价值”。

我刚开始接触ZGC时,觉得它太激进了。但实际测试下来,确实惊艳。嗯,这里简单介绍一下。

4.4.1 核心特性

  • 染色指针:ZGC在指针中存储元数据,不需要额外的内存来记录对象状态。
  • 读屏障:在读取对象时检查指针状态,实现并发标记和重定位。
  • 并发整理:ZGC的整理阶段是并发的,不需要STW。
  • Region动态调整:ZGC的Region大小动态变化,适应不同大小的对象。

4.4.2 启用参数

-XX:+UseZGC                # 启用ZGC(JDK 11+)
-XX:ZAllocationSpikeTolerance=2.0  # 分配突发容忍度(默认2.0)
-XX:ZCollectionInterval=120        # 强制GC间隔(秒)
-XX:ZFragmentationLimit=25         # 碎片率限制(默认25%)
-XX:ZMarkStackSpaceLimit=16MB      # 标记栈空间限制
-XX:ZUncommitDelay=300             # 内存归还延迟(秒)

适用场景:ZGC适合大堆(100GB+)、低延迟(10ms以内)的应用。如果你的堆小于4GB,或者对吞吐量要求极高,ZGC可能不是最佳选择。

4.4.3 ZGC的局限

ZGC虽然好,但不是万能的。我在测试中发现几个问题:

  • CPU占用高:ZGC的读屏障和并发标记会消耗大量CPU。在CPU资源紧张的环境下,可能影响业务。
  • 内存占用高:ZGC需要额外的内存来支持染色指针和并发整理。实际内存占用比G1高10%~20%。
  • JDK版本限制:ZGC在JDK 11是实验特性,JDK 15才正式发布。建议使用JDK 17+。

4.5 GC选型决策树

说了这么多,到底怎么选?我画了一张图,帮你理清思路。

GC选型决策树 GC选型决策 堆 < 4GB 堆 ≥ 4GB 延迟敏感? 是→CMS 否→G1 延迟要求 < 10ms? 是→ZGC 否→G1 选型说明 • 堆 < 4GB:CMS或G1均可,CMS延迟更低,G1更稳定 • 堆 4GB~100GB:G1是首选,兼顾延迟和吞吐 • 堆 > 100GB且延迟 < 10ms:ZGC是最优解 • CPU资源紧张:优先考虑G1,ZGC的CPU开销较高

4.6 实战调优步骤

最后,我总结一套通用的GC调优步骤。这套方法我在多个项目中验证过,效果不错。

  1. 明确目标:先问自己,要低延迟还是高吞吐?延迟要求是多少毫秒?
  2. 选择GC:根据堆大小和延迟要求,用上面的决策树选一个。
  3. 设置基础参数:堆大小、Region大小、停顿时间目标等。
  4. 运行并监控:用GC日志、JMX、Prometheus等工具收集数据。
  5. 分析瓶颈:看GC频率、STW时间、晋升情况、碎片率等。
  6. 调整参数:每次只改一个参数,观察效果。
  7. 验证结果:确认是否达到目标。如果没达到,回到第4步。

重要提醒:GC调优不是一劳永逸的。业务变了、流量变了、硬件变了,GC参数都要跟着调整。我建议每季度做一次GC健康检查,确保系统始终处于最佳状态。