做线上服务的人基本都绕不开GC调优这个坎。前面一篇聊了CMS和Parallel这类经典回收器这篇专门说现在的默认主角——G1。我自己的一个支付网关服务堆内存48G原本跑在CMS上一到业务高峰老年代就开始抖动Full GC一次两秒多眼睁睁看着接口超时后来迁到G1配合几个关键参数校准停顿基本稳定在一两百毫秒内。这篇就把G1的底细从头到尾捋一遍重点讲它的Region机制、记忆集、并发标记周期、Young GC与Mixed GC的完整流程还有我踩过的坑和排查记录希望对现在还在纠结CMS要不要换G1、或者已经切了G1但停顿控制不住的朋友有实际帮助。1. G1为什么能接CMS的班它到底解决的是哪些老问题先别急着看参数得先把G1存在的理由看明白。很多人知道G1是JDK 9之后默认的垃圾回收器但不理解它为什么能顶掉CMS其实核心就一句话CMS解决了并发回收的问题但留下了两个烂摊子——碎片化和不可控的停顿预测。G1这两个都做了针对性设计。1.1 CMS的三大痛点碎片、停顿波动、全堆扫描CMS用标记-清除算法老年代回收完不压缩时间一长内存空间就被切成细碎的小块。我见过一个跑了三个多月的CMS实例老年代明明还剩七八个G空闲但分配一个大对象时居然晋升失败直接退化成Serial Old的单线程压缩收集停顿直接拉满。这就是最典型的碎片化灾难现场。第二个痛点CMS的并发标记阶段虽然能做到低停顿但它没有“停顿时间预测”的能力。它在并发失败时退化为Full GC停顿能到好几秒完全看运气。你没法告诉JVM“我希望每次GC不要超过200毫秒”它做不到这种反馈控制。第三个痛点之前我们做传统分代回收时新生代Minor GC之后如果存活对象多会晋升到老年代老年代回收往往需要扫描整堆的引用关系。堆越大Full GC或并发标记时扫描的成本越高这是CMS在超大堆场景下撑不住的根本原因之一。64G以上堆基本不建议再碰CMS不是玄学是它的数据结构和算法设计确实跟不上堆的增长。1.2 G1的设计基线用Region分区换可控停顿G1做了个颠覆性的设计——不再把堆分成物理上连续的年轻代和老年代而是把整个堆切成等大小的Region默认2048个左右每个Region在逻辑上可以扮演Eden、Survivor或者Old。这相当于把以前“整块地”的堆划分成一个个独立的“格子”。为什么叫Garbage First因为它在回收时优先挑垃圾比例最高、回收收益最大的那些Region来处理而不是像CMS那样傻乎乎地整堆扫一遍。配合一个可配置的目标停顿时间参数-XX:MaxGCPauseMillisG1能够在每次GC前根据历史统计估算这次我能挑多少个Region能保证在多少毫秒内完成。这套机制就是我们常说的停顿预测模型。可以这样类比CMS就像周末大扫除全家所有房间一次性清理工程量巨大时间不可控G1则是每天顺手把最乱的几个房间重点清理每天花的时间固定长期下来家里也维持得不错。虽然每次回收总量不如大扫除多但胜在稳定可控。2. G1核心机制拆解Region、记忆集与并发标记想要用好G1光知道“它把堆分了格子”远远不够。你至少得理解三样东西Region的分区模型、RSet记忆集、还有并发标记周期。这三者环环相扣任何一个环节出问题都会直接体现在你线上的GC停顿和CPU消耗上。2.1 Region布局与分代逻辑堆如何被切分G1的堆被划分成若干个大小相等的RegionJVM启动时根据堆大小自动计算单个Region的大小。Region大小必须是2的幂次方范围从1MB到32MB。默认情况下G1会根据堆大小尝试分成约2048个Region比如堆是4G时每个Region约2MB8G堆Region约4MB。Region数量不是越多越好。Region太小一个对象的跨区引用就需要在多个RSet中记录记忆集冗余膨胀。Region太大则大对象分配时的粒度变粗回收最小的单元代价也变高。我实际调整时会通过-XX:G1HeapRegionSize手动指定原则是尽量让大对象能落在单个Region内避免动不动进Humongous区。逻辑上Region还是分代的一部分Region充当Eden一部分充当Survivor剩下的都是Old。这与物理连续的老设计不同——年轻代和老年代在地址空间上是打散的但因为G1用Region做逻辑标记所以Young GC和Mixed GC仍然可以区分代际。这种逻辑分代的优势是Eden和Survivor区大小可以根据运行时情况动态调整不用像ParNew那样固定比例。配合-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent年轻代可以在堆的5%到60%之间浮动这对适应业务流量波动非常友好。2.2 RSet记忆集与写屏障G1最重要的成本交换这是G1最核心、也最容易引发性能问题的地方。年轻代GC要回收Eden里的对象时必须知道老年代哪些对象引用了Eden里的对象否则这些存活对象就会被误回收。CMS和Parallel的做法是扫描整个老年代成本高但实现简单。G1的选择是在每个Region上维护一个RSetRemembered Set专门记录有哪些Region引用了当前Region。这个RSet的数据结构是基于卡表的。JVM把堆划分成512字节的卡页当某个线程对对象引用字段做写入操作时写屏障Write Barrier会记录这次跨Region引用并把对应的卡页标记为脏卡。Young GC时G1扫描被收集Region的RSet就能快速定位哪些老年代对象引用了这些新生代对象完全避免全堆扫描。代价是什么写屏障在每一次引用赋值时都会产生额外开销尤其在高并发写的场景下这个开销会被放大。另外RSet本身也是一个内存开销大户如果程序跨区引用极多RSet可能占掉堆内存的百分之十几。我调过一个内存吃紧的服务Region设得偏小结果RSet膨胀光记忆集就吃掉了接近2G白白浪费了堆空间。2.3 SATB与并发标记周期保证并发安全的关键CMS升级到G1的另一个重要变化是并发标记算法用SATBSnapshot At The Beginning起始快照替代了CMS的增量更新。GC在并发标记开始时给堆中的对象打一个逻辑快照后续即使引用关系发生变化G1也基于这个快照判断对象是否存活。这样做的好处是并发标记阶段业务线程不用频繁与GC线程争用减少了停顿代价是在这个周期内新分配或新引用的对象可能被漏标实际上是被当成活跃对象保留下来导致一小部分浮动垃圾需要留到下一轮处理。具体看并发标记周期的四个阶段我整理了一张表方便对比是否STWStop-The-World阶段工作内容是否STW耗时特点Initial Mark标记GC Roots直接可达的对象并准备并发标记是非常短通常毫秒级Concurrent Mark从Root开始并发遍历对象图标记所有存活对象否耗时最长与存活对象量正相关Final MarkRemark处理并发阶段漏标的SATB缓冲完成最终标记是较短但有时会有波动Cleanup统计各Region存活对象重置空Region状态是短通常也很快很多人以为G1停顿只有Young GC的停顿时间忽略了并发标记周期。事实上当老年代占用达到IHOP阈值后G1会启动一个并发标记周期。这个周期虽然大部分阶段并发但Initial Mark和Remark都是STW如果堆里存活对象特别多Final Mark阶段停顿也可能会冲到几百毫秒。这个需要结合日志实际观察不能想当然。2.4 Humongous大对象与巨型分配风险G1中对象大小超过Region的50%时会作为一个Humongous对象直接分配到Old区的连续Region中这些Region被称为Humongous Region。这是一个很坑的设计因为Humongous Region的回收时机比较尴尬——它只在Cleanup或Full GC时被回收Young GC和Mixed GC一般不处理它。我踩过一个典型的坑业务里有个本地缓存字符串数组动不动几十MB频繁创建和释放。用CMS时没觉得有大事只是老年代会有些碎片切换G1后这类大对象全进了Humongous区老年代回收周期拖得很长而且Humongous区域分配失败时容易触发连续的Mixed GC线上服务出现周期性卡顿。后来定位到这个问题把该业务从大数组改成内存分片和分段处理规避了超大对象分配效果立竿见影。所以G1环境下大对象能拆就拆不要抱侥幸心理。3. G1收集过程实操解析从Young GC到Mixed GC理解完了底层的Region和RSet再看G1的两种回收模式就水到渠成了。G1的收集分两类纯年轻代的Young GC和同时回收老年代Region的Mixed GC。它们的触发条件、执行流程和代价都不一样调优时要分开看待。3.1 Young GC完整流程并行复制加动态EdenYoung GC的触发条件是Eden区被填满。G1的年轻代收集是并行Stop-The-World的说白了这段期间业务线程全部暂停。完整的流程大致是这样选出所有Eden Region加入收集集合CSet。根据Eden区的存活情况和Survivor区容量动态决定本次晋升的目标Survivor区大小。通过RSet快速找到老年代中引用了Eden对象的引用方作为GC Roots的一部分。从Root出发遍历Eden区内所有对象标记存活对象。存活对象被复制到新的Survivor区如果对象年龄达到晋升阈值则复制到Old区。清空原来的Eden区释放Region。这里有个很重要的G1特性年轻代大小是动态的。它会根据前面收集的耗时统计自动调整Eden分区数量让每次Young GC的停顿尽量落在-XX:MaxGCPauseMillis目标范围内。如果停顿超了它会倾向于减小年轻代这样每次回收的对象少停顿变短如果停顿远低于目标它会扩大Eden减少GC频率。这个自适应机制调好了很省心但前提是你不能让Eden膨胀到一次Young GC停顿就爆表。我在实际生产中不会把-XX:MaxGCPauseMillis设太激进比如设成50ms如果你真把它压到这么低G1会拼命缩小年轻代导致GC频率暴增吞吐量崩掉总暂停时间反而更长。一般情况下200ms左右是比较合理的平衡点停顿可接受吞吐也不至于太惨。3.2 Mixed GC与CSet选择逻辑怎么挑老年代Region当并发标记周期完成后G1会知道老年代里哪些Region“垃圾最多”。接下来它就进入Mixed GC模式Young GC时除了回收Eden还会额外回收一批高收益的老年代Region。这个模式之所以叫Mixed就是因为CSet里既有年轻代Region又有老年代Region。CSet的选择逻辑是重点。G1会把老年代Region按照“垃圾占比”从高到低排序优先把垃圾占比高即存活对象少的Region放进CSet。这样一次回收能腾出最多的空间同时复制成本最低。它还会参考目标停顿时间计算本次最多能放多少个Region进去。这就是Garbage First名字的由来——优先回收垃圾最多的区域。-XX:G1MixedGCCountTarget这个参数默认值是8它告诉G1一次完整的Mixed GC周期尽量拆成8次Mixed GC来完成。也就是说一次标记周期后G1会在后续8次左右的Mixed GC中分批把有回收价值的老年代Region都处理掉。我一般不建议调太小太激进的话几次Mixed GC就处理完大量老年代Region单次停顿容易失控调太大则会拖长整个回收周期老年代释放变慢。3.3 关键参数与配置实战一套我常用的基线参数下面是基于48G堆、线上中等偏上写入压力的应用我整理过的一套G1参数基线你们可以参考再调-Xms48G -Xmx48G -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:G1HeapRegionSize16M -XX:G1NewSizePercent5 -XX:G1MaxNewSizePercent40 -XX:InitiatingHeapOccupancyPercent45 -XX:G1MixedGCLiveThresholdPercent85 -XX:G1ReservePercent10 -XX:ParallelRefProcEnabled -XX:-TieredCompilation逐个解释为什么这么设。-XX:G1HeapRegionSize16M48G堆默认Region算下来应该是32MB左右。但我这个应用有不少几MB到十几MB的大数组设成16M能让大多数大对象直接落在普通Region里而不是被归入Humongous区。这里要提醒一句Region大小设置要在JVM启动时就定好运行期改不了如果设置不当导致RSet膨胀只能重启应用才能修正。-XX:InitiatingHeapOccupancyPercent45默认是45意思是老年代占用达到整个堆的45%时G1会启动并发标记周期为后续Mixed GC做准备。设太高可能导致老年代都快满了才触发标记Mixed GC还没来得及释放空间就已经有新的晋升压力了。我一般放在40-50之间具体看应用晋升速率。-XX:G1MixedGCLiveThresholdPercent85意思是只有存活对象占比小于85%的Region才会被放进Mixed GC的收集中。存活对象比例太高的Region回收代价大收益小不值得碰。这个值不是越大越好调太大会让Mixed GC回收一批很“费劲”的Region单次停顿飙升。依赖这套基线我最初的停顿从原来的CMS Full GC两秒多降到了G1平均Mixed GC两百毫秒左右。当然参数只能作为起点每个应用都要基于GC日志继续磨合。4. 常见问题与排查技巧实录参数不是调完就不管的线上环境千奇百怪G1本身也很复杂。这节挑几个我记得最深、最有代表性的问题把排查过程和思路完整放出来帮你少绕点路。4.1 GC停顿远超目标时间第一步永远是看日志我处理过最典型的案例是业务高峰期一到GC日志就频繁出现超过400毫秒的停顿但MaxGCPauseMillis设的是200ms。很多人上来就猜是堆太小或者Region太大但正确做法是先看GC日志。日志显示停顿主要来自两方面一是Remark阶段较长二是RSet更新耗时高。Remark耗时长通常意味着并发标记期间产生了大量SATB缓冲记录也就是业务线程在并发标记阶段做了大量引用赋值动作。这种情况往往和“并发标记周期持续太长时间”相关因为标记周期越长业务线程产生的新引用越多Final Mark时要处理的垃圾记录就越多。解决思路不是去调Remark而是压缩并发标记的窗口期比如适当调低IHOP让标记更早开始错开业务高峰。RSet更新高则要看是否Region设置偏小。我那次遇到情况Region设成了8M线上对象引用关系极复杂写屏障频繁产生跨Region记录。把Region从8M调到16M后RSet容量直接下降了一半停顿明显回落。所以遇到RSet相关开销大第一反应要检查Region大小是否合理。4.2 并发模式失败与Full GC为什么G1也会触发Serial GC这里要特别强调G1虽然在绝大多数情况下做增量回收但有一种情况它会彻底退化并发模式失败Concurrent Mode Failure。当并发标记周期还没走完老年代就已经被写满了此时G1无法正常执行后续的Mixed GC只能退化成一次Full GC而且历史包袱是Serial Old单线程执行停顿极长。这个问题的根源一般有几种可能老年代增长太快IHOP阈值设太高导致标记启动晚了或者Mixed GC回收的速度跟不上晋升速度要么就是Humongous对象把老年代空间瞬间撑爆了。我之前调过一个低频但大对象频繁的业务就是因为Humongous对象分配把老年代一下子打满在并发标记刚开始时就直接触发Full GC。排查时先把GC日志里的Concurrent Mode Failure时间点和堆使用情况对上。如果是IHOP太高把-XX:InitiatingHeapOccupancyPercent从默认45调低到35-40让标记提前启动。如果是晋升速度确实快就要考虑是不是年轻代设置过大导致大量对象晋升后老年代处理不过来可以适当调低-XX:G1MaxNewSizePercent减少单次晋升量。最根本的还是看业务代码能找到大对象和不合理缓存优先改代码。4.3 动态年轻代失效固定参数未必是合理解药还有一种令人头疼的情况是你觉得已经调好了参数但GC日志里显示实际停顿目标根本没有落实。比如你设了MaxGCPauseMillis100但Eden动态变化频率很高Young GC频繁触发每次停顿波动大。这其实是G1的停顿预测模型在你这个负载场景下不够准确导致的。我碰过的一个真实案例一个服务每天流量波动极大高峰时QPS是低谷时的十倍。G1自动把年轻代调得忽大忽小导致GC频率忽高忽低。后来我没再完全依赖自适应而是动态根据业务时段重启时覆盖-XX:G1MaxNewSizePercent和-XX:G1NewSizePercent参数把年轻代最大值限定在一个固定区间。代价是吞吐量略有损失但GC行为变得可预测心里踏实。这里也提醒一句G1的重要价值是“可预测停顿”但不是每个场景都适合让JVM自己猜。如果你在业务层就能预判流量峰值不如主动配合GC参数的动态调整而不是等到GC日志报警了再救火。4.4 G1版本特性与迁移注意事项最后提醒一个很多团队会忽略的问题G1在不同JDK版本里的表现差异极大。JDK 8的早期版本中对G1的支持还有不少bug8u191之后G1的稳定性和并发标记表现才明显成熟所以用JDK8跑G1务必升级到最新8u版本。JDK 11之后G1成为默认并且字符串去重等功能默认开启行为又会不一样。从JDK8迁移到JDK11或17时有一些CMS时代的参数在G1里是失效的比如-XX:CMSInitiatingOccupancyFraction也有一些G1参数在新版本里改过默认值比如-XX:MaxGCPauseMillis默认从200ms变到某些版本还是200ms但内部实现不同实际表现会有差异。迁移后一定要用线上流量压测一遍观察GC日志和吞吐量别拿旧数据直接对拍。这套内容讲完G1从设计理念到常见JVM参数和排障手段基本都覆盖到了。最后再分享一点实际运营中的体会G1的停顿可控但别把调参作为万能药。很多GC问题本质上是代码的内存分配习惯太差比如频繁创建大对象、过大的本地缓存、无节制的String拼接。调G1参数能缓解症状但不能根治病灶。真正稳定的Java应用一定是拥有良好的内存分配习惯再用G1做好最后一道防线。所以在快照完参数后记得留时间回头审视代码这才是让服务真正稳下来的核心。