资讯动态

JVM G1垃圾回收器深度解析:从内存布局到调优实战

发布时间:2026/10/15 19:51:01 来源:尧图企业网站定制
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 RegionG1 的乐高积木堆布局G1 把整个堆划分成很多大小相等的小区域每个区域叫作 Region。这个有点像一个操场被画成很多小块每个小块既可以用来装年轻对象也可以用来装老年代对象甚至还可以是一块完全空闲的区域。Region 大小默认是 1MB 到 32MB 之间具体由 JVM 根据堆大小自动计算目标是让整个堆的 Region 数量大约在 2048 个左右。比如堆是 4GB那么一个 Region 大约 2MB堆是 16GB一个 Region 大约 8MB。你可以在启动参数里显式指定-XX:G1HeapRegionSize8m我一般不会去动它除非我明确知道线上对象的平均大小偏大或偏小。比如某个服务里有很多 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 内部有一个自适应机制叫 IHOPInitiating 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 GC3.1 Young GC最频繁的小扫除G1 的 Young GC 跟传统收集器有些相似都是把 Eden 区的存活对象复制到 Survivor 区但如果 Survivor 区放不下或者对象年龄达到阈值就会晋升到老年代。区别在于G1 的 Survivor 区和老年代都是 Region 的集合复制过程实际上是对象从一个 Region 集合移动到另一个 Region 集合。Young GC 会 STWStop The World也就是暂停应用线程。虽然名字叫停顿但 G1 在设计上会尽量缩短它。整个 Young GC 过程分几步选定所有 Eden Region 和 Survivor Region 作为回收集合。从根对象开始扫描标记出所有存活对象。将存活对象复制到新的 Survivor Region并更新引用。清空原来的 Eden Region将其标记为空闲。调整年轻代大小和回收集合的统计信息。实际上 G1 在 Young GC 之前有一个根扫描阶段是跟应用并发进行的它利用 SATBSnapshot 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:MaxGCPauseMillis200目标停顿时间-XX:G1HeapRegionSize动态单块 Region 大小-XX:G1NewSizePercent5年轻代最小占比-XX:G1MaxNewSizePercent60年轻代最大占比-XX:InitiatingHeapOccupancyPercent45触发并发标记的老年代占比-XX:G1ReservePercent10预留堆空间占比-XX:G1MixedGCCountTarget8Mixed GC 最大轮数-XX:G1MixedGCLiveThresholdPercent85Mixed GC 可回收 Region 的存活阈值-XX:ConcGCThreads动态并发标记线程数-XX:G1HeapWastePercent5触发混合回收的空闲堆占比-XX:G1RSetUpdatingPauseTimePercent5RSet 更新在停顿中的时间占比这些参数不建议一次性全调。通常我只会动其中两三个其余保持默认因为 G1 的自适应机制已经相当成熟你干预太多反而会破坏它的平衡。4.2 停顿时间目标别设得太贪心MaxGCPauseMillis 是最直观的参数但也是被用错最多的。有人为了追求极致的低延迟把它设成 50ms结果 Full GC 频繁出现。原因是停顿目标越短G1 每轮能回收的 Region 就越少年轻代也会被压得更小对象晋升速度加快老年代很快就满了最终触发代价昂贵的 Full GC。我的经验是目标停顿设置在 100ms 到 300ms 之间比较合理。如果你的服务对延迟极敏感不要只靠 G1而是考虑把堆内存调大或者使用 ZGC。G1 的定位本来就是可控停顿不是超低停顿。在某次压测中我把一个模拟交易系统的堆设为 8GBMaxGCPauseMillis 从 200ms 改成 100ms。观察一天后发现 Young GC 次数从每天 2000 多次增加到 4000 多次Full GC 次数从 0 变成 3 次。这足以说明停顿目标对系统行为的连锁影响。后面我改回 200msFull 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:PrintGCDetailsJDK 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:MaxGCPauseMillis200 -XX:InitiatingHeapOccupancyPercent45 -XX:G1ReservePercent10 -XX:ConcGCThreads4 -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:gcpromotioninfo输出晋升信息。用-XX:PrintAdaptiveSizePolicyJDK 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:ConcGCThreads2如果业务高峰期 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 SetRSet记录外部 Region 对它的引用。当其它 Region 的对象引用本 Region 时G1 需要更新 RSet。这是 G1 最复杂的机制之一也是停顿时间的主要来源之一。RSet 相关的参数是-XX:G1RSetUpdatingPauseTimePercent5默认值是 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 日志我常用 JFRJDK Flight Recorder来采样对象分配。JFR 可以告诉你哪些线程、哪些方法分配了大量对象。配合-XX:AllocateHeapAt这种不常用的参数还能指定堆外路径做验证但我很少在生产环境用。具体做法是jcmd pid JFR.start settingsprofile duration60s filenameprofile.jfr然后打开 JFR 文件查看分配压力。找到热点后针对性地减少无用对象创建往往比调一堆 GC 参数更有效。7.3 一次完整的调优流程示例假设有一个订单服务堆为 8GB运行一段时间后日志显示Young GC 平均 20msP99 为 120msMixed GC 平均 180msP99 为 500ms每 30 分钟左右出现一次 Full GC第一步观察老年代占用曲线发现并发标记开始后老年代占用下降不明显。第二步查看 Humongous 对象分布发现大量 3MB 左右的 byte[]。第三步代码中定位到一次性从数据库读取大量数据到内存改为流式处理后Humongous 对象消失。第四步把 MaxGCPauseMillis 从 200ms 调到 150msMixed GC 次数略增但 P99 下降到 300ms。最终 Full GC 消失。整个过程的核心是先查分配源头再调参数最后再验证。顺序反了很容易浪费时间。8. 一些我踩过很多次才明白的细节最后分享几个容易忽略的细节都是我实际反复验证过的。-XX:MaxGCPauseMillis不是越小越好。它本质上是一个软目标G1 内部在预测停顿和回收效率之间做权衡。如果你设置的值远低于实际硬件能力G1 会不断缩小年轻代和回收集合最终导致回收跟不上分配。关于-XX:SurvivorRatio和-XX:MaxTenuringThreshold这两个参数在 G1 里并不是完全没有作用但 G1 对晋升年龄的决策更多依赖自适应逻辑。强行设置 MaxTenuringThreshold1 会让对象过早晋升老年代压力大增设置过大又可能导致 Survivor 空间不足。我建议直接保持默认。还有一个非常容易踩的点在 JDK 8 下使用 G1 时如果开启了-XX:UseStringDeduplication要留意字符串去重会引入额外的停顿时间。这个功能在某些场景大量重复字符串下收益明显但对于普通业务收益有限反而可能让停顿时间增加。我的做法是先在压测环境验证再决定是否开启。关于 G1 和容器环境的结合建议只在容器中显式设置堆大小不要依赖 JVM 默认的最大堆。很多容器服务忘了设置-Xmx导致 JVM 按照宿主机内存大小算出一个超大堆Region 数量暴增GC 行为变得非常奇怪。如果你使用的是 JDK 11 及以上版本建议开启-Xlog:gc*:filegc.log:time,uptime,level,tags这种结构化日志比 JDK 8 的 GC 日志更容易解析。配合 GCViewer 这类工具你能直观看到各个阶段的耗时趋势排查效率会高很多。我个人在实际操作中的体会是G1 之所以难调是因为它内部做了太多自适应决策你看到的任何一个现象都是多个因素共同作用的结果。所以不要一遇到 Full GC 就盲目调参数先看日志、先找分配源往往五分钟就能定位问题。希望这篇内容能帮你少走一些弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑