1. 为什么到了今天,我们还需要重新认识G1
如果让我用一个词来形容G1这十年的处境,就是“既熟悉又陌生”。大家写启动脚本时都会顺手带上-XX:+UseG1GC,知道它是JDK 9之后默认的垃圾回收器,可真要问一句“G1到底是怎么工作的、为什么它能一边扛着几十G的大堆一边保持低停顿”,能说清楚的人其实不多。这很正常,因为G1的设计复杂度比之前所有HotSpot回收器都高一个量级,你完全可以只把它当黑盒用得很舒服,但一旦堆内存上不去了、延迟报警了、GC日志里出现Full GC了,不懂内部机制的人就只能靠猜,而靠猜调JVM参数是性能工程师最忌讳的事。
先把一个反直觉的事实摆出来:G1从来不是一个“低延迟”的垃圾回收器,它是一个“可预测停顿”的垃圾回收器。 它的目标不是让GC停顿无限趋近于零,而是让你告诉它“你最多可以停顿多少毫秒”,然后它在这个约束下尽量把活儿干完。这个设计哲学的转变,是理解G1一切行为的钥匙。
这篇文章我会从G1要解决的历史难题讲起,拆解它的Region内存模型、RSet和SATB这些核心概念,梳理它从Young GC到Mixed GC的完整工作流程,然后给出一套基于实际项目经验的调优路径和踩坑清单。无论你是刚接触JVM调优的开发者,还是正在为线上大堆性能头疼的运维/后端工程师,这篇文章的目标是让你读完以后,面对G1的GC日志和参数时,心里有一张清晰的作战地图。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从Parallel到CMS再到G1:垃圾回收领域的老问题与新解法
2.1 Parallel和CMS各自的死穴
在G1之前,HotSpot虚拟机的主流回收器组合是“Parallel Scavenge + Parallel Old”或者“CMS + ParNew”。前者是吞吐量优先的思路,适合做离线计算、批处理任务,它们把所有的精力都花在“怎么用更少的时间把垃圾清完”上,代价是每次GC的停顿时间不可控——堆越大,停顿越长,几百毫秒甚至秒级停顿在几十G堆上稀松平常。后者CMS则是低延迟思路的产物,它把大部分标记工作放进并发线程,尽量缩短停顿,但CMS有两个著名的硬伤:
- 内存碎片。CMS用的是“标记-清除”算法,回收完不整理内存,时间一长老年代就成了瑞士奶酪,到处是零散的小空洞。大对象来了找不到连续空间,只能提前触发Full GC,而CMS的Full GC退化为Serial Old单线程整理,停顿直接爆炸。
- 浮动垃圾与Concurrent Mode Failure。并发标记期间程序还在产生新垃圾,这些浮动垃圾只能留给下一次GC,如果老年代在并发清理期间就被填满了,CMS会直接放弃治疗,转而触发Full GC做Serial Old收集,这在线上是高延迟事故的重灾区。
我在一个老项目里就经历过典型场景:堆内存8G,每次CMS的并发标记跑完,紧接着就是一次秒级Full GC,排查到最后发现罪魁祸首是一张缓存表里的byte数组——对象本来不大,但架不住量大,老年代空间被大对象和碎片耗干,CMS根本撑不到下一次正常GC。
2.2 G1的设计目标:把“停顿时间”变成可协商的参数
G1全称Garbage First,核心设计思路和之前的回收器完全不同。它不再把整个堆分成连续的年轻代、老年代两大块,而是把堆拆成一个个大小相等的Region,物理上不再要求年轻代和老年代是连续的内存块。这意味着G1可以每次只回收一部分Region,而不是像Parallel那样一口气把整个年轻代或老年代清一遍。
在此基础上,G1引入了一个停顿预测模型:你可以通过-XX:MaxGCPauseMillis设定目标停顿时间(默认200ms),G1会通过历史数据估算本次GC回收哪些Region、回收多少,能够把停顿控制在目标范围内。注意这个参数是“目标”而不是“上限”——它是个软约束,G1会尽量满足,但遇到特殊情况(比如一次要分配大量Humongous对象)还是会超。
所以G1的真正价值是:在大堆(几十G甚至上百G)场景下,仍然能做到相对可控的停顿,并且通过并发标记和混合回收,大幅减少Full GC的频率。 它不是完美的,后面我会专门讲它的翻车场景,但在绝大多数Web服务、微服务、大数据计算中间件场景里,G1是综合体验最好的一个选择——这也是为什么从JDK 9起它成为了默认回收器。
3. JVM G1内存模型深度拆解:Region、RSet与SATB三块基石
要真正理解G1,第一步就是把它的内存模型在大脑里构建出来。我见过不少人把G1理解为“分代+分区”,这个说法不算错,但太粗了。G1内存模型的精妙之处在于它如何追踪跨Region引用、如何在并发标记时保证正确性——这两点才是它能否在高吞吐下保持低停顿的关键。
3.1 Region分区:G1用“大堆小块”换来的调度自由度
G1把整个Java堆划分成若干个大小相等、逻辑连续的Region,每个Region的大小通过-XX:G1HeapRegionSize指定,范围是1MB到32MB,必须是2的幂次方。如果没指定,G1会根据堆大小自动计算,规则大致是让Region数量在2048个左右,比如堆大小是4G,那Region大小就是2MB。
这些Region在逻辑上分成了几类:
- Eden Region:存放新分配的对象,对应年轻代的Eden区。
- Survivor Region:存放年轻代GC后存活下来的对象,对应Survivor区。
- Old Region:存放老年代对象。
- Humongous Region:存放巨型对象——当一个对象大小超过Region大小的50%时,G1不再把它放进普通Region,而是直接分配到一个或多个连续的Humongous Region中。Humongous Region在回收时是作为老年代的一部分对待的。
Region的内存不要求物理连续,这就给G1带来了巨大的调度灵活度:它可以根据各Region中垃圾占比的多少来决定回收优先级,“Garbage First”这个名字就是来自这个策略——优先回收垃圾最多、回收收益最大的Region。
这里有一个容易忽略的细节:新生代的大小在G1里是动态的。G1会根据目标停顿时间和历史GC数据,动态调整Eden Region和Survivor Region的数量。所以-Xmn这类固定年轻代大小的参数在G1里是不建议使用的,它会破坏G1的自适应调节能力。我见过有团队把-Xmn和-XX:+UseG1GC一起配,结果停顿预测模型完全失效,因为年轻代大小被写死了,G1失去了调节空间。
3.2 Remembered Set(RSet):跨Region引用跟踪为何让G1“又爱又恨”
分代回收器都面临一个问题:要判断一个Region里的对象是否存活,必须知道有没有其他Region里的对象引用它。最简单粗暴的办法是把整个堆从头到尾扫描一边找引用关系,但那样停顿就不可控了。G1的解决方案是维护一张“记忆集”(Remembered Set,简称RSet)。
每个Region都有一张RSet,记录的是其他Region中有哪些对象引用了本Region中的对象。这样在回收某个Region时,GC线程只需要扫描这个Region的RSet,就能知道哪些对象被外部引用了,而不用扫描整个堆。
RSet的实现机制值得多花两分钟理解,因为它直接影响性能:
- 每个Region内部有一个“卡表”(Card Table),把Region划分为一个个128字节的卡片。当对象引用发生变化时,JVM通过写屏障(Write Barrier)标记对应的卡片为“脏卡”。
- RSet中记录的是指向本Region的脏卡的地址,而不是逐个对象引用。回收时通过
G1ScanHeapRegionClosure扫描这些卡片,找出指向本Region的引用。 - 这里有个值得注意的优化:RSet的引用是分区记录的,G1把引用来源Region分成“已处理”和“待处理”两类,只在必要时才精确扫描卡表中的引用,减少了并发标记期间的扫描量。
RSet是G1实现并行可预测回收的基石,但它也有代价——内存开销和写屏障开销都不小。一个重度引用的Region,它的RSet可能占用数MB内存,极端情况下RSet总内存能达到堆大小的5%甚至更多。我在一个缓存密集型服务里观察到,堆总内存12G,光RSet就占了800MB左右。另外每次引用赋值都会触发写屏障,这部分CPU开销也无法忽略。所以G1不是免费的午餐,它是用一部分内存和CPU开销换取更平滑的GC行为。
3.3 SATB(Snapshot At The Beginning):并发标记如何保证对象不丢
G1的并发标记阶段基于“初始快照”(SATB)算法。说白了,G1在标记开始时记录下当前对象图的快照,之后即使某些对象的引用在并发标记过程中被修改,也按照快照时的状态来标记存活对象。
为什么需要这样设计?想象一下:并发标记线程扫描到对象A,发现它引用了对象B,把B标记为存活。但标记还没结束,业务线程就把A对B的引用断开了,同时B被C引用着(C还没被扫描到)。如果回收器很较真地认为“A不再引用B了”,就可能在标记完成后把B当成垃圾回收掉——但业务线程此时可能还持有B的引用,这就出大事了。
SATB的做法是:通过写屏障,在修改引用时把“变化之前的旧引用”记录到一个缓冲区里,并发标记最后再处理这些缓冲区中的对象,把那些已经纳入快照的对象也标记为存活。它的本质是保守处理——宁可多标记一些对象留到下次回收,也不误回收一个可能还在被使用的对象。
这也解释了G1的一个常见现象:并发标记后,有些本来已经死亡的对象还留在老年代中,没有被立即回收。因为它们处于SATB快照中,可能在标记快照中被判定为存活。这些“存活”对象要等到下一次GC时才有机会被回收。所以有时候你会看到老年代占用率没有明显下降,不一定是G1有问题,可能是SATB机制下的正常现象。
4. G1的回收流程:Young GC、并发标记、Mixed GC与Full GC是如何接力运转的
G1的完整回收过程比传统分代回收器复杂得多,它不是一个孤立的事件,而是一系列阶段在不同条件下接力进行。我从流程角度梳理出一条主线,方便你对照GC日志理解。
4.1 Young GC:G1的主要工作,也是停顿的主要来源
Young GC在Eden区满了之后触发,负责回收所有Eden Region,并把存活对象移动到Survivor Region,如果存活对象年龄达到晋升阈值,就直接晋升到Old Region。
G1的Young GC是全停顿(Stop The World)的,这个过程会暂停所有业务线程。停顿时间主要取决于存活对象数量和RSet扫描开销,而不是Eden区总大小。这意味着即使你的Eden区开得很大,只要对象绝大多数都是短命鬼,Young GC的停顿依然可以控制得很好。这也是G1和Parallel的重要区别:Parallel的Young GC停顿与年轻代大小强相关,G1更多与存活对象量强相关。
Young GC期间还有一个细节:G1会通过-XX:MaxTenuringThreshold和动态阈值共同决定晋升年龄。G1的晋升阈值不是固定的,它会根据Survivor区的大小动态调整。如果你设置的MaxTenuringThreshold=15,但Survivor区装不下这么多代的对象,G1会提前把对象晋升到老年代。这在日志里表现为晋升年龄时大时小,是正常现象。
4.2 并发标记周期:G1识别“垃圾最多”的老年代Region的侦查过程
在Young GC之外,G1还维护一个并发标记周期(Concurrent Marking Cycle),作用是在老年代中标记所有存活对象,为后续的Mixed GC圈定回收范围。这个标记周期包含以下几个关键阶段:
- 初始标记(Initial Mark):借道一次Young GC的停顿,顺便标记GC Roots的直接引用对象,是STW的。
- 并发标记(Concurrent Marking):与应用线程并发执行,遍历对象图,标记所有存活对象。这个过程可以随时被Young GC打断,不阻塞业务。
- 最终标记(Remark):处理SATB缓冲区中的遗留对象,是STW的但通常很快。
- 清理(Cleanup):统计各Region的存活对象数,把完全空闲的Region直接放入可回收列表。这个阶段大部分是并发执行的。
整个并发标记周期里,STW时间主要由Initial Mark和Remark构成,它们通常都很短(几十毫秒以内),这是G1低延迟的关键。触发并发标记周期的条件是老年代占用率达到-XX:InitiatingHeapOccupancyPercent(简称IHOP,默认45%)。注意这个默认值偏保守,在老年代增长较快的应用中,45%就会触发并发标记,可能过于频繁。
同时G1还有一个自适应IHOP机制(Adaptive IHOP):如果G1统计发现每次并发标记之后的老年代占用峰值低于当前IHOP,它会自动降低IHOP避免标记完成后很快又需要Mixed GC;反过来如果发现标记完成后老年代还在持续暴涨,它会调高IHOP减少标记频率。所以你会发现线上日志中IHOP值是动态变化的,这属于正常行为。如果你手动设置了-XX:IHOP,G1会固定用它并要求你同时设置-XX:+AlwaysPretouch或-XX:UseDynamicNumberOfGCThreads等参数来配合,这里不展开,但也提醒一句:非必要不要手动固定IHOP。
4.3 Mixed GC:G1“分段清垃圾”的核心策略
并发标记周期结束后,G1进入Mixed GC阶段。所谓Mixed,是指一次GC同时回收年轻代的一部分Region和老年代中垃圾占比最高的一部分Region(G1老年代回收的Region集合叫做Collection Set,简称CSet,Garbage First的核心策略就是在CSet中优先选择回收收益最大的Region)。
Mixed GC的设计目标是把老年代的回收工作分摊到多次GC中,避免一次性回收整个老年代导致停顿过长。G1默认情况下会连续进行多次Mixed GC,直到老年代中垃圾占比降到阈值以下。
Mixed GC的停顿模型需要重点理解:它并不是回收所有带垃圾的老年代Region,而是根据停顿预测模型从候选Region中挑选一部分。候选Region按照垃圾占比排序,垃圾多的优先回收,每次Mixed GC尽量选择“性价比”最高的Region组合。如果老年代的垃圾太多,单次Mixed GC回收不完,下一次Mixed GC继续回收,所以在GC日志中经常能看到连续多轮Mixed GC。
这就解释了为什么G1的Full GC往往是“自己的锅”:如果Mixed GC持续进行,但老年代新分配对象的速度太快,Mixed GC回收的速度赶不上对象晋升的速度,老年代占用率就会持续攀升直到触发Full GC。
4.4 Full GC:G1最不愿看到的“灾难兜底”
当G1发现目标停顿时间无法满足,或者老年代被彻底占满、Mixed GC无法及时回收时,就会触发Full GC。G1的Full GC是单线程的,使用Serial Old收集器做全堆标记-整理-压缩,停顿时间非常夸张——几十G的堆往往要停顿几秒甚至十几秒。
这里有一个很多人不知道的细节:在JDK 10之前,G1的Full GC是单线程的。JDK 10引入了-XX:+ParallelRefProcEnabled和多线程Full GC的改进(G1 Full GC parallelization),把Full GC从单线程改成了多线程压缩,所以在高版本JDK上Full GC停顿要小一些,但仍然很痛苦。
触发Full GC的常见原因有三类:
- Promotion Failure(晋升失败):年轻代GC时,Survivor区和老年代都容不下存活对象,G1紧急触发Full GC。
- Humongous分配失败:一个巨型对象找不到连续的Humongous Region,直接Full GC。
- Mixed GC跟不上老年代增长:回收速度低于对象晋升速度,老年代持续膨胀。
在线上如果看到Full GC,不要只盯着“调大堆内存”这一招,先判断是哪类原因——晋升失败往往意味着对象活得太久或者晋升阈值太低,Humongous分配失败往往意味着有大对象分配且Region大小不合适,Mixed GC跟不上增长则往往说明IHOP设置不合理或者JVM的停顿目标设置太小导致回收量不足。
我给一个典型的排查路径:先打开GC日志(-Xlog:gc*),确认Full GC前的触发点是什么,再结合堆dump看老年代里到底是什么对象占了大头,最后再决定是调Region大小、调IHOP、调停顿目标,还是优化业务层的大对象分配。绝大多数Full GC的根因其实在业务代码里,JVM参数能做的只是延迟问题爆发的时间。
5. 从参数到实战:G1调优的落地路径与常见误区
5.1 核心参数一览与选择逻辑
G1相关的参数不少,但实际上需要主动调整的就那么几个。我整理了一个表格,供你对照当前JDK版本(以JDK 11/17为主)操作:
| 参数 | 默认值 | 作用 | 我的建议 |
|---|---|---|---|
-XX:+UseG1GC |
JDK 9+默认开启 | 启用G1 | 默认即可 |
-XX:MaxGCPauseMillis |
200ms | 目标最大停顿时间 | 不要盲目调小,后面详述 |
-XX:G1HeapRegionSize |
自动计算 | Region大小 | 一般不动;Humongous问题时考虑 |
-XX:InitiatingHeapOccupancyPercent |
45 | 老年代占比触发并发标记 | 老年代增长慢可调大,增长快可调小 |
-XX:G1MixedGCCountTarget |
8 | Mixed GC轮数目标 | 默认即可,可配合停顿目标微调 |
-XX:G1NewSizePercent / G1MaxNewSizePercent |
5 / 60 | 年轻代动态范围 | 常需要调大上限,避免过早晋升 |
-XX:G1MixedGCLiveThresholdPercent |
85 | Mixed GC只回收存活占用低于此值的Region | 默认即可 |
-XX:G1HeapWastePercent |
5 | 可回收空间占总堆比例低于此值则不触发Mixed GC | 可适当调小 |
-XX:G1RSetUpdatingPauseTimePercent |
5 | RSet更新在STW中的期望占比 | 默认即可 |
-XX:G1ReservePercent |
10 | 预留空间避免晋升失败 | 默认即可 |
这里需要强调一个误区:MaxGCPauseMillis不是越小越好。如果你把它设成50ms,G1会把年轻代压得很小,Eden区频繁填满,Young GC次数暴增,每次停顿虽短但总开销变大,整体吞吐下降。更严重的是,因为年轻代太小,对象晋升变快,老年代快速增长,反而更容易触发并发标记和Full GC。我见过项目把MaxGCPauseMillis从200改成50后,RT没降反升,GC总时间从每天几分钟涨到几十分钟,最后调回150才稳定。停顿时间和吞吐量是一对矛盾,G1做的只是在两者间找平衡点,你把它压得越狠,它越“挣扎”。
5.2 一套G1调优的实操思路:从采集数据到参数落地
我自己的调优路径大致分四步:
第一步:先采集基线数据。启动参数加上-Xlog:gc*:file=/path/gc.log:time,uptime,level,tags(JDK 9+统一用-Xlog语法),跑业务压测或观察线上至少一个GC周期,拿到Young GC频率、Mixed GC频率、Full GC频率、STW分布、老年代增长曲线。
第二步:判断瓶颈类别。如果STW时间普遍低于50ms且没有Full GC,那G1的配置就是健康的,不要瞎调。如果STW超过100ms,优先看是不是RSet扫描和CSet过大的问题,同时检查是否大量分配了Humongous对象(日志里会显示humongous allocation)。如果出现Full GC,按照上一节说的三类原因分类排查。
第三步:针对性调整参数。核心思路是控制“年轻代大小”和“老年代回收节奏”:
- Young GC太频繁:适当调大
-XX:G1MaxNewSizePercent,给年轻代更多空间,让短命对象在年轻代被回收。 - Mixed GC憋不出来:调低
-XX:IHOP(如从45调到35),让G1更早开始并发标记,为Mixed GC留出更多时间。 - Mixed GC收不动:调大
-XX:G1MixedGCCountTarget(比如从8改成16),把老年代清理分摊到更多轮次里,降低每轮STW。 - Humongous分配频繁:如果日志显示了大量大对象分配,一方面在业务层排查能否拆小对象,另一方面可适当调大
G1HeapRegionSize(比如从默认2MB改成8MB/16MB),因为Region越大,对象被判定为Humongous的概率越小,但RSet开销也会增大,需权衡。
第四步:验证并迭代。每次只改一到两个参数,等一个GC周期再看日志对比,不要一次性把参数全改了。调优不是一个一劳永逸的过程,应用的内存行为会随着流量和需求变化,建议保留GC日志并定期review。
5.3 调优中的“主观赋权”思维:性能指标本来就是一场取舍
在做G1调优时我总会下意识做一个多目标权衡:停顿时间、吞吐量、内存占用,这三个指标本质上就是对立的。你不可能同时把三者都做到极致。这就像做决策时给各项目标赋权重——你更在意哪一个,就把它放在优先级最高的位置。比如在线交易系统里,把MaxGCPauseMillis放在第一位;离线批处理任务里,把吞吐量放在第一位,可以接受更大的STW;如果服务器内存紧张,RSet的内存开销和Region大小选择就需要更保守。
这种“主观赋权”的思维在JVM调优里比任何单一指标都重要。很多人问我“有没有一个最优的G1配置”,我的答案永远是:最优配置取决于你的业务特性、硬件资源和你最不能接受的代价是什么。把这一步想明白了,参数调整才有方向。
6. 实战案例:一次G1频繁Full GC的完整排查链路
理论讲了这么多,我讲一个自己实际处理的案例,把整个排查链路还原出来,你以后遇到类似问题可以直接照这个思路走。
6.1 现象与初始判断
某个支付结算服务,堆内存配置24G,运行在8核16G的容器里(堆外的Metaspace等还有额外开销,配置其实有点紧),使用G1,MaxGCPauseMillis默认200ms。监控显示业务高峰期接口RT毛刺严重,GC日志里每天出现十几次Full GC,单次停顿3到8秒,严重影响可用性。
我第一步做的事情和很多人相反:没有马上去调参数,而是先打开GC日志看Full GC前发生了什么。在-Xlog:gc*的日志里,我看到了这样的关键片段:
code复制[Full GC (Allocation Failure) 24G->18G(24G), 5.23s]
[Eden: 0.0B(11.0M)->0.0B(11.0M), Survivors: 0.0B->0.0B]
Full GC原因写着Allocation Failure,说明在尝试分配对象时空间不足。从日志看,Full GC后老年代从24G降到18G,回收了6G,但之后很快又会涨回来。注意Eden区只有11M,这是很不正常的——正常情况下一个24G堆的Eden应该有好几G才对,这说明G1已经把年轻代压得很小来满足停顿目标,同时老年代增长极快。
6.2 挖掘根因:Humongous对象与过小的Region
继续往Full GC之前的日志看,我发现了大量类似这样的片段:
code复制Humongous allocation: 3.1M, previous allocated: 4.2M, current region size: 2.0M
这段日志意味着每次分配一个2M到4M左右的大对象时,它都会被当作Humongous对象,直接分配到老年代的连续Region中。容器内存紧张、堆24G情况下Region默认是2MB,而业务代码里频繁创建3MB左右的数组/缓冲区对象,这些对象一旦分配就会直接进驻老年代,并且不容易被Young GC回收。
问题的链条已经清晰了:
- 每次业务处理分配一批3MB左右的临时数组,被分配为Humongous对象进入老年代。
- 老年代被这些大对象快速填满,触发并发标记和Mixed GC。
- 但Mixed GC一次回收需要时间,而这批对象生命周期很短,Mixed GC来不及回收,新的对象又塞了进来。
- 最终老年代耗尽,触发Full GC,单线程Full GC停顿5秒以上。
6.3 解决方案与验证
针对这个案例,我做了三件事:
第一,调整Region大小。把-XX:G1HeapRegionSize从默认2MB改为8MB。这样3MB左右的对象不会再被判定为Humongous,而是作为普通对象在年轻代分配,短生命周期对象可以在Young GC里被直接回收,不再一股脑涌进老年代。
第二,调整IHOP。把-XX:InitiatingHeapOccupancyPercent从45降到30,让G1更早启动并发标记,给老年代回收留出更充裕的时间,避免并发标记还没完成老年代就被撑满的情况。
第三,优化业务代码。和开发团队确认后,把这个大对象的分配逻辑做了池化和复用,直接从源头减少了Humongous对象数量。
改完上线后观察一周,Full GC次数归零,Mixed GC的频率从每天几十次降到几次,接口RT毛刺消失。这个案例最大的收获是:线上问题排查一定要把日志和业务代码结合起来看,而不是上来就调参数。参数只是给系统打补丁,真正解决性能问题往往需要回到分配源头。
7. 关于G1二次开发与高级话题:你是真的需要改G1,还是只想调好它?
最后聊聊“G1二次开发”这个话题。网上搜索时你可能会看到G1二次开发相关的资料,有些人会讨论修改G1源码来实现自定义的回收策略。说实话,我在日常工作里几乎没有遇到需要改G1源码的场景——G1已经非常复杂,动它的风险极高,而且多数情况下你根本不需要碰内部实现,把现有参数吃透就足够解决90%的问题。
但如果你想做的事情确实需要深入了解G1内部(比如为特定硬件平台定制GC策略,或者研究新的垃圾回收算法),那有几件事值得做好准备:
- 读源码的入口:HotSpot源码仓库中
share/gc/g1目录下是G1的完整实现,核心文件包括g1CollectedHeap.cpp、g1CollectionSet.cpp、g1ConcurrentMark.cpp、g1RSet.cpp等。建议从g1CollectedHeap.cpp的collect方法入手,顺着GC触发链路读代码。 - 理解G1的关键数据结构:
CollectionSet的选择逻辑(G1CollectionSetCandidates)、RSet的CardTable管理、SATB队列的G1SATBMarkQueueSet,这几个结构是G1的核心。要看懂它们,需要先具备JVM内部对象头、卡表、写屏障的基础知识。 - 注意G1在不同JDK版本之间的差异:从JDK 8的G1到JDK 17的G1,内部经历了大量优化,比如JDK 12的
G1CollectedHeap::satisfy_failed_allocation改进、JDK 14的G1CardTable重构等。你在网上看到的很多G1源码分析文章是基于旧版本的,阅读时注意区分。
但我的建议很明确:绝大多数人不需要走二次开发这条路。真正需要做的是把G1这套复杂机制的原理理解透,知道它的优势边界和失效模式,学会读GC日志、做heap dump、用JFR/JMC(飞行记录器)分析,并掌握一套系统化的调优方法论。这些能力带来的实际收益,远比改一个内部参数大得多。
如果你真想深入研究,另一个值得关注的方向是ZGC——它是JDK 15后逐步成熟的另一套低延迟回收器,在某些场景下比G1更激进。但它也有自己的问题(内存占用更高、某些场景吞吐下降),这又是一个很长的故事了。
最后再分享一个实用小技巧
在日常排查G1问题时,我特别推荐用JFR(Java Flight Recorder)配合GC日志一起看,jcmd GC.heap_dump和jmap这类工具也必不可少。特别是jcmd <pid> GC.class_histogram可以快速看到各类型的对象数量和占用空间,用来验证Humongous对象的源头非常方便。
还有一个小习惯:线上环境记得开启-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath,这个参数能让你在OOM时拿到一份完整的堆快照,很多问题没有dump文件根本无从下手。虽然G1帮我们拖住了很多内存问题,但真到OOM那天,一份好的dump就是破案的关键。别问我为什么强调这个,我踩过太多次没有dump硬着头皮猜内存泄漏的坑了。
