1. 为什么 G1 值得我们花时间搞懂
我最早是从听技术群里的同学说"线上一直 Full GC,换成了 G1 以后就舒服了"开始注意到它的。后来又看了不少源码和压测数据,自己也在几个模拟项目X里把默认的回收器换成了 G1,实测下来确实稳定。这个 G1,全称 Garbage First,翻译过来就是"垃圾优先",核心并不是"优先处理垃圾",而是"优先回收垃圾最多的区域"。这一句话几乎概括了它的全部设计哲学,但真正把它弄明白,还得从它解决了什么问题说起。
很多刚接触 JVM 的同学会有一个误解,觉得 GC 算法就那么几种,标记-清除、标记-复制、标记-整理,然后记住优缺点就够了。但现实场景没这么简单。以前的 CMS 回收器在低延迟场景里很受欢迎,但它有一个先天缺陷:内存碎片化。CMS 用的是标记-清除,回收完以后老年代里到处是细碎的空闲块,等大对象进来放不下,就得触发 Full GC 来做一次全局整理。而 G1 虽然本质上也是把堆分成一块块小区域,但它引入了可预测的停顿模型,还能在回收完成后做区域级别的复制,从根本上规避了碎片问题。
G1 适合谁?适合那些堆内存比较大(通常在 4GB 以上)、对停顿时间有明确要求、又不想时不时看到 Full GC 的 Java 服务。它不像 CMS 那样需要你去调一堆晋升阈值和碎片整理的参数,也不像 Parallel GC 那样只顾吞吐量。G1 的默认配置已经能跑得不错,但实际上要把它调到真正适合你的业务,还是有不少门道。这篇文章我会从 G1 的内存布局开始,一步一步拆它的回收流程、核心参数、常见问题,把我在实际项目里踩过的坑也一并写出来。你能得到的不是一堆名词解释,而是一套可以直接拿去用的调优思路。
2. G1 核心设计拆解:区域、分代与可预测停顿
2.1 Region:G1 的"乐高积木"堆布局
G1 把整个堆划分成很多大小相等的小区域,每个区域叫作 Region。这个有点像一个操场被画成很多小块,每个小块既可以用来装年轻对象,也可以用来装老年代对象,甚至还可以是一块完全空闲的区域。Region 大小默认是 1MB 到 32MB 之间,具体由 JVM 根据堆大小自动计算,目标是让整个堆的 Region 数量大约在 2048 个左右。比如堆是 4GB,那么一个 Region 大约 2MB;堆是 16GB,一个 Region 大约 8MB。你可以在启动参数里显式指定:
-XX:G1HeapRegionSize=8m我一般不会去动它,除非我明确知道线上对象的平均大小偏大或偏小。比如某个服务里有很多 1MB 左右的大数组,Region 太小会导致大对象横跨多个 Region,降低回收效率,这时候调大 Region 是合理的。但如果是普通业务对象,默认值足够。
Region 还有一个重要的逻辑角色:Humongous 区域。当一个对象大小超过 Region 的一半时,G1 会把它放到连续的一组 Region 里,这些 Region 被称为 Humongous 区域。大对象在 G1 里的处理是比较特殊的,它们不会被复制移动,回收时直接整块清理。所以如果一个大对象存活时间很短还好,如果存活时间很长,它会占据连续空间,影响其它对象的分配。这是大对象调优的核心矛盾。
2.2 G1 的分代只是"逻辑上的"
G1 不像传统收集器那样把年轻代和老年代在物理上分成两块连续内存。Eden、Survivor、Old 这些概念仍然存在,但它们对应的是一组 Region 的集合,这些 Region 可以动态调整数量。这意味着年轻代的大小不是固定的,而是由 G1 根据-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent控制,默认分别是堆的 5% 和 60%。每次 GC 结束后,G1 都会根据实际的对象分配速率和回收成本来调整年轻代 Region 的数量。
打个比方:传统收集器像一栋楼,楼层是固定的,你这层住不下就只能往楼上硬挤;G1 则像一辆火车,车厢可以随时编组,哪节车厢装年轻对象、哪节装老对象,都是动态调度的。这种灵活性带来的直接好处就是:G1 可以用最少的年轻代 Region 完成同样的对象分配,从而减少每次 Young GC 的停顿时间。
但也要注意,年轻代 Region 太少会导致对象频繁晋升到老年代,反过来增加老年代回收压力;太多又会导致 Young GC 停顿变长。G1 内部有一个自适应机制,叫 IHOP(Initiating Heap Occupancy Percent),它根据历史数据判断老年代占用达到多少时开始并发标记,默认值是 45%。也就是说,老年代占到整个堆的 45% 时,G1 就开始准备并发回收周期了。
2.3 停顿时间模型:用成本模型换低延迟
G1 最吸引人的特性是-XX:MaxGCPauseMillis,默认 200ms。这个参数的意思是:G1 尽量让每次 GC 的停顿控制在 200ms 以内。但注意,它不是硬性保证,而是"尽力而为"。
G1 内部维护了一个基于历史数据的成本模型。每次 GC 结束,它会记录这次回收了多少 Region、耗时多少、每个 Region 的回收成本是多少。下次需要选择回收哪些 Region 时,G1 会根据这个成本模型,在满足停顿时间目标的前提下,选择收益最高的那些 Region 来回收。这个思路非常像你在购物时,预算有限,当然优先买性价比最高的商品。
这是 G1 与传统 GC 最本质的区别:它把 GC 从"全堆扫描"变成了"增量回收"。正因为每一轮只处理一部分 Region,停顿时间才能被控制住。不过这也带来了一个问题:如果对象分配速度太快,G1 可能来不及回收足够多的 Region,最终导致垃圾堆积,触发叫 "Mixed GC" 的混合回收,甚至退化成 Full GC。所以说,停顿时间目标不能设得太激进,否则会导致回收效率过低。
3. 彻底讲透 G1 的回收过程:从 Young GC 到 Mixed GC
3.1 Young GC:最频繁的"小扫除"
G1 的 Young GC 跟传统收集器有些相似,都是把 Eden 区的存活对象复制到 Survivor 区,但如果 Survivor 区放不下或者对象年龄达到阈值,就会晋升到老年代。区别在于,G1 的 Survivor 区和老年代都是 Region 的集合,复制过程实际上是对象从一个 Region 集合移动到另一个 Region 集合。
Young GC 会 STW(Stop The World),也就是暂停应用线程。虽然名字叫"停顿",但 G1 在设计上会尽量缩短它。整个 Young GC 过程分几步:
- 选定所有 Eden Region 和 Survivor Region 作为回收集合。
- 从根对象开始扫描,标记出所有存活对象。
- 将存活对象复制到新的 Survivor Region,并更新引用。
- 清空原来的 Eden Region,将其标记为空闲。
- 调整年轻代大小和回收集合的统计信息。
实际上 G1 在 Young GC 之前有一个根扫描阶段是跟应用并发进行的,它利用 SATB(Snapshot At The Beginning)算法记录了一个对象图快照。这个快照机制保证了并发标记阶段不会漏掉存活对象。
对于大多数服务来说,Young GC 占 GC 总数的绝大部分,单次停顿通常在 10ms 到 50ms 之间,取决于年轻代大小和存活对象数量。如果你发现 Young GC 停顿很长,先不要急着调整 MaxGCPauseMillis,而是要考虑是不是 Eden 太大、对象分配太快,或者 Survivor 区过小导致对象提前晋升。
3.2 并发标记周期:不打断业务的"大扫描"
当老年代使用率达到 IHOP 阈值(默认 45%)时,G1 会启动一个并发标记周期。这个周期比 Young GC 复杂得多,包含多个阶段:
- 初始标记:短暂 STW,标记 GC Roots 直接引用的对象。
- 并发标记:应用线程和标记线程同时运行,遍历对象图。这个阶段不会 STW,但会占用一定的 CPU 资源。
- 最终标记:短暂 STW,处理并发阶段遗留的 SATB 缓冲。
- 清理阶段:统计每个 Region 的存活对象数量,决定哪些 Region 值得回收。
标记完成之后,G1 会得到一个"哪些 Region 垃圾最多"的清单。这时候它不会立刻回收所有老年代 Region,而是会把老年代 Region 分成两类:回收价值高的(垃圾多、存活少)和回收价值低的。只有前者会被纳入后续的 Mixed GC。
这里有一个很重要的参数:-XX:G1MixedGCCountTarget,默认值是 8。意思是混合回收最多分成 8 轮来做。为什么要分多轮?因为如果一次性回收所有高价值 Region,停顿时间可能无法控制在目标以内。分成多轮,每轮回收一部分,既能控制停顿,又能逐步降低老年代占用。
3.3 Mixed GC:回收老年代的"机动部队"
Mixed GC 是 G1 特有的回收阶段。它除了回收年轻代 Region,还会回收一部分老年代 Region。这些老年代 Region 是并发标记周期筛选出来的、垃圾占比最高的那些。
Mixed GC 的过程跟 Young GC 类似,只是回收集合里多了一批老年代 Region。因为老年代 Region 里的存活对象可能很多,复制的成本相对较高,所以 Mixed GC 的停顿通常比 Young GC 更长。G1 的停顿预测模型会在选择老年代 Region 时,严格遵循 MaxGCPauseMillis 的约束,宁可少回收一些,也不让停顿超太多。
如果 Mixed GC 执行了很多轮,但老年代占用率依然降不下来,说明并发标记筛选出来的 Region 不够,或者对象分配速度实在太快。这时候你可以增大-XX:G1MixedGCLiveThresholdPercent的默认值 85%,也就是让存活对象占比低于 85% 的 Region 都有资格被回收。但我建议谨慎调整这个值,因为把存活率较高的 Region 强行回收,复制成本会很高,效果可能适得其反。
3.4 Full GC:不可避免的最后手段
G1 其实尽量避免 Full GC,因为 Full GC 是单线程的(在 G1 中默认使用串行 Old GC),停顿时间非常长。触发 Full GC 的场景主要有两类:
- 并发标记或 Mixed GC 期间,老年代空间被迅速耗尽,G1 必须马上回收所有 Region。
- 晋升失败:Young GC 进行中,Survivor 和老年代都没有足够空间容下存活对象。
Full GC 是 G1 调优中的核心痛点。它一出现,往往意味着你的堆大小不够用,或者 IHOP 阈值设置不合理,或者-XX:G1ReservePercent预留空间不足。后面我会专门讲怎么排查。
4. G1 核心参数与调优实践:对照真实案例
4.1 必知参数速查表
先给出一份我常用的参数清单,后面再逐个解释使用场景。
| 参数名 | 默认值 | 作用 |
|---|---|---|
| -XX:MaxGCPauseMillis | 200 | 目标停顿时间 |
| -XX:G1HeapRegionSize | 动态 | 单块 Region 大小 |
| -XX:G1NewSizePercent | 5 | 年轻代最小占比 |
| -XX:G1MaxNewSizePercent | 60 | 年轻代最大占比 |
| -XX:InitiatingHeapOccupancyPercent | 45 | 触发并发标记的老年代占比 |
| -XX:G1ReservePercent | 10 | 预留堆空间占比 |
| -XX:G1MixedGCCountTarget | 8 | Mixed GC 最大轮数 |
| -XX:G1MixedGCLiveThresholdPercent | 85 | Mixed GC 可回收 Region 的存活阈值 |
| -XX:ConcGCThreads | 动态 | 并发标记线程数 |
| -XX:G1HeapWastePercent | 5 | 触发混合回收的空闲堆占比 |
| -XX:G1RSetUpdatingPauseTimePercent | 5 | RSet 更新在停顿中的时间占比 |
这些参数不建议一次性全调。通常我只会动其中两三个,其余保持默认,因为 G1 的自适应机制已经相当成熟,你干预太多反而会破坏它的平衡。
4.2 停顿时间目标:别设得太贪心
MaxGCPauseMillis 是最直观的参数,但也是被用错最多的。有人为了追求极致的低延迟,把它设成 50ms,结果 Full GC 频繁出现。原因是:停顿目标越短,G1 每轮能回收的 Region 就越少,年轻代也会被压得更小,对象晋升速度加快,老年代很快就满了,最终触发代价昂贵的 Full GC。
我的经验是:目标停顿设置在 100ms 到 300ms 之间比较合理。如果你的服务对延迟极敏感,不要只靠 G1,而是考虑把堆内存调大,或者使用 ZGC。G1 的定位本来就是"可控停顿",不是"超低停顿"。
在某次压测中,我把一个模拟交易系统的堆设为 8GB,MaxGCPauseMillis 从 200ms 改成 100ms。观察一天后发现 Young GC 次数从每天 2000 多次增加到 4000 多次,Full GC 次数从 0 变成 3 次。这足以说明停顿目标对系统行为的连锁影响。后面我改回 200ms,Full GC 消失,整体 P99 延迟反而更低。别为了好看的数字牺牲整体稳定性。
4.3 IHOP 与老年代占用:Full GC 的关键防线
IHOP 默认 45% 的意思是:老年代占用达到堆的 45% 时开始并发标记。如果这个值设得太小,标记周期启动得早,但 GC 线程会和业务线程抢 CPU,吞吐量下降;如果设得太大,标记周期启动得晚,可能还没标记完,老年代就满了,直接 Full GC。
有一个关联参数叫-XX:G1PeriodicGCInterval,可以设置周期性触发并发标记的间隔。如果业务有明显的峰谷期,在低谷期主动做一次并发标记,可以避免高峰期老年代突然满了。比如我处理过一个案例:一个报表服务,每天凌晨定时任务会产生大量临时对象,老年代在半小时内从 30% 冲到 80%,然后 Full GC。后来我把周期 GC 间隔设为 30 分钟,让 G1 在任务启动前先做一轮并发标记,Full GC 基本不再出现。
IHOP 的调整要配合观察日志。你可以在 JVM 参数里加上-Xlog:gc*(JDK 11+)或者-XX:+PrintGCDetails(JDK 8)来查看并发标记是否在老年代占用过高时才开始。如果出现"Concurrent Mark 还没结束就 Full GC",说明 IHOP 需要降低。
4.4 大对象与 Humongous 分配:另一只隐形黑手
统计线上服务的 GC 日志时,我发现很多 Full GC 的根因不是普通对象太多,而是大对象频繁分配。G1 对 Humongous 对象的回收时机有限制:如果一个 Humongous 对象没有被引用,它会在并发标记周期后被回收,而不是在 Young GC 中。这意味着大对象一旦分配,就会一直占用一段时间。
更麻烦的是,Humongous Region 在回收前不能被其它对象使用,因为它们是连续分配的。如果堆里有大量 2MB 以上的大对象,堆的碎片化程度会加剧。
排查大对象可以用 JDK 自带的jmap -histo:live,或者jcmd GC.class_histogram。看到某些 byte[] 或 int[] 大量占用内存时,就要审查代码里是不是存在大集合一次性加载、大文件读取、缓存未限流等等。我在某个批处理服务里发现,一次查询就把一个 10MB 的列表加载到内存里,导致 Humongous Region 频繁出现。后来改成分批查询,Full GC 直接消失。
4.5 我常用的 G1 启动参数模板
下面这套配置我在多个模拟项目里验证过,适合堆 8GB~16GB、目标停顿 200ms、CPU 核数大于等于 8 的服务:
-Xms8g -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:InitiatingHeapOccupancyPercent=45 -XX:G1ReservePercent=10 -XX:ConcGCThreads=4 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/opt/logs/gc.log说明几点:
- 堆大小建议直接设成 -Xms 和 -Xmx 相同,避免 JVM 动态扩容带来的额外开销,也方便观察堆水位。
- ConcGCThreads 默认是 CPU 核数的四分之一左右,一般不用动。如果你发现并发标记阶段 CPU 打满,可以适当调小。
- GC 日志一定要开启,不管是排查问题还是日常监控,它都是第一手资料。
5. GC 日志怎么看?实战解读一次完整的 G1 回收
5.1 看懂 Young GC 日志
JDK 11+ 的日志格式如下(简化示例):
[0.328s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 128M->32M(512M) 12.688ms这一行的意思:这是一次普通的 Young GC,堆内存从 128MB 降到 32MB,当前总堆大小 512MB,停顿 12.688ms。
如果你使用的是 JDK 8,日志格式则类似:
[GC pause (G1 Evacuation Pause) (young), 0.012688 secs] [Parallel Time: 9.2 ms, GC Workers: 8] [GC Worker Start (ms): Min: 210.0, Avg: 210.2, Max: 210.4, Diff: 0.4] ... [Code Root Fixup: 0.1 ms] [Clear CT: 0.6 ms] [Other: 2.7 ms]重点看几个指标:Parallel Time 是复制阶段耗时,GC Workers 是并行 GC 线程数。如果 GC Workers 使用率不高,说明并行度不够;如果 Other 阶段耗时很长,可能是 RSet 更新、引用处理或者字符串去重等操作占了时间。
5.2 看并发标记日志
JDK 11+ 日志中会出现这样的片段:
GC(2) Concurrent Mark Cycle GC(2) Pause Remark GC(2) Pause Cleanup并发标记周期是否正常结束,看有没有大段时间为空或异常中断。如果并发标记阶段被多次打断,说明业务线程占用率太高,GC 线程抢不到 CPU。
5.3 用日志定位 Full GC 根因
一旦出现 Full GC,日志里会有一个明显的Pause Full记录。这时我一般做三件事:
- 查看 Full GC 前的老年代占用和 Young GC 频率。如果老年代一直高位徘徊,说明晋升太快或者回收太慢。
- 查看对象晋升统计。JDK 11 可以用
-Xlog:gc+promotion=info输出晋升信息。 - 用
-XX:+PrintAdaptiveSizePolicy(JDK 8)查看 G1 自适应调整年轻代的过程,确认是否因为年轻代被压得太小导致晋升过多。
根据日志,再决定调整 IHOP、年轻代大小还是堆容量。
6. G1 常见问题排查实录:我把踩过的坑都列在这里
6.1 频繁 Full GC,但老年代并不算高
这是我遇到最多的一个假象。日志显示老年代占用只有 60%,却不断 Full GC。排查后发现根因是 Humongous 对象太多,导致空闲 Region 被拆成碎片,无法满足连续分配需求。此时即使总空闲内存充足,G1 也会陷入 Full GC 来整理碎片。
解决方案分两步:第一,从代码层面减少大对象分配,比如分页查询、缓存对象池化;第二,适当调大G1HeapRegionSize,把 Region 从 2MB 调成 4MB,让更多对象不必走 Humongous 路径。后者只是缓解,前者才是根治。
6.2 并发标记线程拖慢了业务
并发标记阶段虽然不 STW,但它会占用 CPU。在 4 核的小型服务上,默认 ConcGCThreads 可能已经占了 1 个核,对延迟敏感业务有明显影响。我建议在 CPU 核数少于等于 4 的环境中,显式设置:
-XX:ConcGCThreads=2如果业务高峰期 CPU 已经接近打满,可以进一步降低。但不要低于 1,否则并发标记可能永远完不成。
6.3 Mixed GC 之后老年代还是降不下来
Mixed GC 的回收对象是并发标记筛选出的高价值 Region。如果你发现老的 Regional 占用没降多少,先检查G1MixedGCLiveThresholdPercent。默认是 85%,也就是说,Region 中存活对象占比超过 85% 就不会被选中。如果一个 Region 里的对象虽多但很"老",它可能存活率很高,也不会被回收。
另一个原因是-XX:G1HeapWastePercent默认 5%。这个参数的意思是:如果整个堆的空闲比例低于 5%,G1 就会提前启动新的并发标记周期。但要留意,如果空闲比例一直高于 5%,但同时老年代也在快速增长,G1 可能迟迟不触发下一轮混合回收,出现"垃圾堆积但没人管"的错觉。可以适当降低这个值,比如 2%,让 G1 更积极主动地回收。
6.4 RSet 更新导致的停顿异常
G1 为每个 Region 维护了一个 Remembered Set(RSet),记录外部 Region 对它的引用。当其它 Region 的对象引用本 Region 时,G1 需要更新 RSet。这是 G1 最复杂的机制之一,也是停顿时间的主要来源之一。
RSet 相关的参数是:
-XX:G1RSetUpdatingPauseTimePercent=5默认值是 5%,意思是 RSet 更新时间在停顿中的占比目标为 5%。如果日志中Other部分耗时很高,且其中Update RS时间占比很重,就可以考虑调大这个比例上限,让 G1 在停顿期间多花一些时间处理 RSet。但调整后观察是否真的改善了整体停顿,不要盲改。
6.5 G1 到底该不该用?对比 CMS 与 ZGC 的选择建议
很多人在技术选型里有"非 G1 不可"的倾向,但其实要看场景。G1 在 4GB~16GB 堆、停顿目标 100ms~300ms 的场景下非常合适。如果你的堆小于 2GB,用 ParNew + CMS 或者干脆用 Serial 都可能更高效,因为 G1 的 RSet 维护本身需要额外内存和计算。如果堆超过 32GB、目标停顿低于 10ms,建议直接考虑 ZGC 或 Shenandoah。G1 不是万能的,但它是"默认的、平衡的、省心的"选项。
我之前把一个小型网关从 G1 换成 Serial GC 反而效果更好,因为那个服务对象生命周期极短,几乎全是 Eden 分配,Serial GC 的年轻代回收效率极高,也没有 RSet 开销。所以我的原则是:不迷信默认,也不迷信新特性,以日志和监控数据为准。
7. 监控与调优的正确姿势:从数据出发
7.1 必须关注的三个指标
- 停顿时间分布:不要只看平均值,要看 P99 和 P999。G1 的停顿偶尔会有一个很长的尾点,比如某个 Mixed GC 耗时 800ms。这时要检查是否为预期行为。
- 吞吐量损耗:GC 占用的总 CPU 时间除以总运行时间。正常情况下应该在 2% 以内,如果超过 5%,要么堆太小,要么分配速率太高。
- Full GC 次数:这个指标一旦出现,就说明配置肯定有问题。连续出现 2 次以上 Full GC,基本可以判定堆大小或参数需要调整。
7.2 用工具定位分配热点
除了 GC 日志,我常用 JFR(JDK Flight Recorder)来采样对象分配。JFR 可以告诉你哪些线程、哪些方法分配了大量对象。配合-XX:AllocateHeapAt这种不常用的参数,还能指定堆外路径做验证,但我很少在生产环境用。具体做法是:
jcmd <pid> JFR.start settings=profile duration=60s filename=profile.jfr然后打开 JFR 文件查看分配压力。找到热点后,针对性地减少无用对象创建,往往比调一堆 GC 参数更有效。
7.3 一次完整的调优流程示例
假设有一个订单服务,堆为 8GB,运行一段时间后日志显示:
- Young GC 平均 20ms,P99 为 120ms
- Mixed GC 平均 180ms,P99 为 500ms
- 每 30 分钟左右出现一次 Full GC
第一步:观察老年代占用曲线,发现并发标记开始后,老年代占用下降不明显。第二步:查看 Humongous 对象分布,发现大量 3MB 左右的 byte[]。第三步:代码中定位到一次性从数据库读取大量数据到内存,改为流式处理后,Humongous 对象消失。第四步:把 MaxGCPauseMillis 从 200ms 调到 150ms,Mixed GC 次数略增,但 P99 下降到 300ms。最终 Full GC 消失。
整个过程的核心是:先查分配源头,再调参数,最后再验证。顺序反了很容易浪费时间。
8. 一些我踩过很多次才明白的细节
最后分享几个容易忽略的细节,都是我实际反复验证过的。
-XX:MaxGCPauseMillis不是越小越好。它本质上是一个软目标,G1 内部在预测停顿和回收效率之间做权衡。如果你设置的值远低于实际硬件能力,G1 会不断缩小年轻代和回收集合,最终导致回收跟不上分配。
关于-XX:SurvivorRatio和-XX:MaxTenuringThreshold,这两个参数在 G1 里并不是完全没有作用,但 G1 对晋升年龄的决策更多依赖自适应逻辑。强行设置 MaxTenuringThreshold=1 会让对象过早晋升,老年代压力大增;设置过大又可能导致 Survivor 空间不足。我建议直接保持默认。
还有一个非常容易踩的点:在 JDK 8 下使用 G1 时,如果开启了-XX:+UseStringDeduplication,要留意字符串去重会引入额外的停顿时间。这个功能在某些场景(大量重复字符串)下收益明显,但对于普通业务,收益有限,反而可能让停顿时间增加。我的做法是先在压测环境验证,再决定是否开启。
关于 G1 和容器环境的结合,建议只在容器中显式设置堆大小,不要依赖 JVM 默认的最大堆。很多容器服务忘了设置-Xmx,导致 JVM 按照宿主机内存大小算出一个超大堆,Region 数量暴增,GC 行为变得非常奇怪。
如果你使用的是 JDK 11 及以上版本,建议开启-Xlog:gc*:file=gc.log:time,uptime,level,tags,这种结构化日志比 JDK 8 的 GC 日志更容易解析。配合 GCViewer 这类工具,你能直观看到各个阶段的耗时趋势,排查效率会高很多。
我个人在实际操作中的体会是:G1 之所以难调,是因为它内部做了太多自适应决策,你看到的任何一个现象都是多个因素共同作用的结果。所以不要一遇到 Full GC 就盲目调参数,先看日志、先找分配源,往往五分钟就能定位问题。希望这篇内容能帮你少走一些弯路。