ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入拆解G1垃圾回收器:从原理到调优实践

深入拆解G1垃圾回收器:从原理到调优实践 G1垃圾回收器从JDK 7u4开始进入实验状态到JDK 9正式成为默认回收器再到如今几乎成为Java服务端的标配。整个过程我算是全程经历过早期用户怕它不稳定后来是怕它不会调现在大部分人其实是既没完全搞懂它怎么工作又不得不面对它留下的各种GC日志和停顿数据。这篇东西我不打算做成官方文档的复读机而是把我自己排查线上问题、翻源码、做压测时沉淀下来的对G1的理解写成一篇比较完整的拆解。适合刚接触G1的新手从头建立框架也适合已经用过一阵子但某些环节还有点模糊的老手查漏补缺。1. 为什么G1能取代传统回收器先聊一个最基础但也最容易被忽略的问题G1到底解决了什么痛点能一路从实验品混到默认地位。我们以前用Parallel Scavenge和CMS走的是物理分代的思路。年轻代、老年代是连续的内存块回收时要么整块清理年轻代要么在老年代里做标记清理停顿时间不可控。CMS虽然号称并发收集但它的碎片问题、Full GC退化问题在后期的长生命周期服务上非常难受。G1把物理分代改成了逻辑分代引入了Region这个概念。整个堆被切成一个个大小相等的小格子默认2048个左右每个Region在运行时可以属于Eden、Survivor、Old或者Humongous。正因为每个Region的身份是动态的G1才能做到不回收整个老年代而是挑一部分Region来回收。这个挑的动作就是G1控制停顿时间的核心手段。用大白话讲以前的回收器是全量清扫G1是精准拆弹。它维护了一个优先级列表每次垃圾回收只处理垃圾最多的那批Region同时用停顿预测模型估算这次回收大概需要多长时间如果预测超过你设定的目标停顿时间它就减少回收的Region数量。整个过程优雅但代价就是G1的内部数据结构非常复杂尤其是记忆集Remembered Set简称RSet和SATB快照这两个东西是理解G1绕不过去的坎。另外一个很多人忽视的特点是G1的Full GC是退化的。真正意义上的Full GC是串行、单线程、STW全堆回收效率极低。所以G1的设计哲学是尽量通过Young GC和Mixed GC把垃圾处理完避免进入Full GC。这也是我们在调优时最核心的指导思想。2. 核心概念拆解Region、RSet与SATB2.1 Region的大小与分代流转Region大小默认是1MB到32MB必须是2的幂次方。JVM启动时会根据堆大小自动计算目标是让堆大约分成2048个Region。假设你的堆是4GB那么默认Region大小就是2MB。手动指定用-XX:G1HeapRegionSize但我建议非特殊情况别手填让G1自己算通常更合理因为它是基于整个堆的生命周期评估的。Region的分代身份是动态的。一个Region这次是EdenYGC之后可能变成Survivor再经过几轮可能晋升为Old。这种动态性带来一个好处老年代不再要求物理连续。但也带来一个麻烦跨Region引用怎么跟踪以前老年代是一整块连续内存引用扫描只需要知道边界就行现在全乱了。这就是RSet存在的理由。2.2 记忆集RSet为什么是G1的命脉RSet的本质是反向指针表记录的是哪些Region引用了当前Region里的对象。每个Region都有一份RSet当其他Region有对象引用当前Region时这个引用关系会被记录在当前Region的RSet中。这里必须说清楚一个重要的方向性RSet是谁引用了我而不是我引用了谁。为什么需要反过来记因为GC发生时我们要找到某个Region的根对象。如果没有RSet要遍历整个堆才能判断某个Region里的对象是否存活这等于做全堆扫描停顿时间直接失控。有了RSet只要查当前Region的RSet就知道有哪些外部引用指向这里这些引用就是本次回收的根不需要扫描所有Region。RSet内部是分卡表Card Table实现的每个卡页对应512字节的堆空间。一个卡页里只要有一个对象被跨Region引用这个卡页就会被标记为脏卡RSet记录的是这个卡页的位置而不是精确到对象级别。这种粗粒度的代价是GC时要扫描整个脏卡页里所有的对象引用精度虽然降低但性能可控这是一个典型的空间换时间、精读换性能的工程取舍。写屏障Write Barrier是维护RSet的机制。每次发生引用赋值时JVM会执行一段屏障代码检查引用是否跨Region如果跨了就更新RSet。这个检查是相当频繁的所以G1用了一个G1DirtyCardQueue来批量处理脏卡更新后台有专门的线程处理这些队列避免写屏障在应用线程里做太多同步操作。这块在并发高、引用写入频繁的场景下是需要特别关注的热点你在JVM日志里看到的Ref Proc、Redirty Cards耗时就是这一套机制的代价。2.3 SATB快照并发标记的基石G1的并发标记用的是SATBSnapshot-At-The-Beginning算法。先说结论SATB的核心思想是在标记开始时给堆打一个逻辑快照这个快照保证标记过程中新分配的对象全部认为是存活的被删除的引用关系不会导致对象被漏标漏标会导致存活对象被错误回收这是致命的。具体实现上G1用三色标记法做标记白色表示未访问灰色表示已访问但引用未处理完黑色表示已处理完。并发标记过程中应用线程还在跑引用关系不断变化如果对象从黑色变成白色即黑色对象引用的对象被并发修改了引用就会发生漏标。CMS当年是用增量更新Incremental Update解决这个问题做法是记录黑色对象新增了引用这种变化然后重新标记黑色对象。G1则走另一条路把灰色对象引用被删除这件事记下来。因为SATB保证了标记开始时的一个快照被删除的引用在快照里仍然是存在的所以这些对象虽然实际上已经不可达但仍然会被标记为存活。这个设计有一个直接的后果并发标记期间产生的浮动垃圾Floating Garbage需要留到下一轮GC才能回收。你以为回收干净了其实老年代里还有一批理论存活、实际已死的对象占着空间。所以G1周期结束后老年代占用率往往不会降到预期水平这是正常现象不是GC出了问题。我用一个日常场景来类比SATB就像你出门前给家里拍了一张照片等你回来核对物品清单时即使中间有人把某个东西扔了你看到照片里东西还在就会认为它没有丢。代价就是你可能要多保管它一段时间。3. G1垃圾回收的完整流程拆解G1的回收流程不是单一动作而是由多个不同类型的GC组合成的一套循环。下面分成四个阶段来讲Young GC、并发标记周期、Mixed GC和Full GC。这几个阶段不是串行关系而是根据堆状态穿插进行。3.1 Young GC最频繁的回收动作Young GC在Eden区被填满时触发过程是STWStop The World的。它会选择所有Eden Region和一部分Survivor Region组成回收集合Collection Set简称CSet然后做根扫描、RSet扫描、复制存活对象到空闲Region。存活对象会根据年龄决定去处没达到晋升年龄阈值的进入新的Survivor Region达到晋升阈值或者放不下的晋升到Old Region。这里有个动态年龄判定G1会统计Survivor中各个年龄对象的总大小如果某个年龄的对象总大小超过Survivor容量的50%就取这个年龄作为新的晋升阈值。你可以通过-XX:TargetSurvivorRatio调整这个百分比默认是50%。复制完成后原来的Eden Region和Survivor Region会变成空闲Region重新加入可用Region队列。这一步是G1区别于CMS的重要优势复制算法天然没有内存碎片回收完的空间是完整可用的Region。G1的停顿预测模型在这里发挥作用。在YGC开始时G1会基于历史数据预测本次回收的停顿时间。如果预测会超过-XX:MaxGCPauseMillis默认200msG1会尝试把CSet控制在更小的范围内或者调整年轻代大小来适应。这解释了为什么你设置了目标停顿时间后年轻代会自动忽大忽小——不是Bug这是特性。实操建议Young GC的触发时机基本不需要人工干预但要留意日志里的Eden: 6144.0M(6144.0M)-0.0B(6144.0M)这类信息。如果Young GC极其频繁说明年轻代太小或者分配速率太高此时要看的不是GC参数而是业务代码里是否在疯狂创建短生命周期对象。3.2 并发标记周期老年代回收的前置条件并发标记周期是G1能做老年代回收的前提。它有五个阶段这里逐一拆解。第一个阶段是初始标记Initial Mark。它是伴随着一次Young GC一起执行的所以不需要额外的STW借的Young GC的STW窗口完成。这个阶段要做的事情很简单把所有直接可达的根对象标记为灰色把它们的RSet加入处理队列。因为这个阶段涉及RSet扫描停顿时间会略微高于普通Young GC。第二个阶段是根区域扫描Root Region Scan。这个阶段是并发的扫描的是初始标记中记录的那些根Region中所有存活对象顺着它们的引用关系往下标记。这里有一个需要特别注意的细节这个阶段不能有新的Young GC发生因为Young GC会移动对象导致根集合变化。如果并发标记周期执行期间堆内存压力很大导致Young GC频繁根区域扫描的线程就得频繁让路整个周期会被拖长。第三个阶段是并发标记Concurrent Mark。这个阶段和应用线程完全并发执行顺着灰色对象往下遍历整个对象图。每个Region会记录自己的存活对象数量和存活字节数。同时SATB队列里的引用变化信息也会被处理。这个阶段如果耗时过长通常不是G1本身的问题而是对象图过于庞大。GC日志里Concurrent Mark的耗时超过几秒甚至十几秒基本可以确定是堆里对象引用关系太复杂或者对象数量过多。第四个阶段是重新标记Remark。这是STW的主要处理两个事情一是处理SATB队列中剩余的变化记录二是做一次全局的强根扫描应用线程的栈、JNI引用等。全局强根扫描之所以必须STW是因为只有应用线程停下来JVM才能安全地遍历线程栈。这个阶段的耗时和线程数、类加载器数量强相关多线程环境下通常能控制在几十毫秒到几百毫秒。第五个阶段是清理Cleanup。这个阶段分两部分全局并发清理和STW清理。并发部分统计每个Region的存活对象按存活率排序为后续Mixed GC选择Region做准备。STW部分负责处理RSet中的引用关系以及把完全空闲的Region回收回来。这里有个常见误区Cleanup阶段不回收任何有存活对象的Region它只是记账和整理房间。整个并发标记周期的触发条件由-XX:InitiatingHeapOccupancyPercent控制默认是老年代占比达到45%。注意这个值踩过的坑特别多后面会单独讲。3.3 Mixed GCG1的精准拆弹阶段并发标记周期结束后G1知道了老年代里各个Region的垃圾比例。接下来不是一口气把老年代全回收了而是根据回收收益来挑选Region这些Region加上年轻代一起做回收就叫Mixed GC。Mixed GC的过程在机制上和Young GC类似也是STW也是复制存活对象区别只在于CSet里除了Eden和Survivor Region还包含了一部分Old Region。那选哪些Old Region呢G1会按两个标准排序垃圾比例高的优先回收成本低的优先。-XX:G1MixedGCCountTarget控制一个标记周期内执行多少次Mixed GC默认是8次也就是拆成8轮慢慢回收。-XX:G1MixedGCLiveThresholdPercent控制只有存活率低于85%的Region才会被选进CSet存活率高于这个值的Region回收它代价大于收益不如留着。这里我要特别强调一个经常被误解的点Mixed GC不是一种独立的GC类型在GC日志里它仍然显示为GC pause (mixed)你可以把它理解为老年代辅助参与回收的Young GC。如果你在日志里长时间只看到GC pause (young)而看不到GC pause (mixed)说明并发标记周期根本没跑完或者跑完没有合格的Old Region可以收。Mixed GC执行到第几轮就停取决于G1对回收收益的判断。如果后续几轮的利润太低G1会提前结束本周期的Mixed GC。这会导致老年代占用率没有降到理想水平但这其实是G1在保护你的停顿时间是可接受的。3.4 Full GCG1的最后手段与退化陷阱Full GC是G1最不想走的路因为它意味着G1之前的所有努力全部白费。G1的Full GC是单线程串行回收整个堆停顿时间动辄几秒到几十秒这对于线上服务基本等同于故障。导致G1 Full GC的原因主要有四类。第一类是并发标记失败Concurrent Mode Failure并发标记周期还没结束老年代就被填满了没空间分配新对象。第二类是晋升失败Promotion FailureYoung GC时Survivor和Old区都放不下要晋升的对象触发Full GC。第三类是巨型对象Humongous分配失败大对象直接进入老年代并占用连续多个Region分配时找不到足够的连续Region。第四类是元空间Metaspace扩容触发Full GC这个属于全局性原因不能全怪G1。判断Full GC的原因最简单的办法是在JVM参数里加-XX:PrintGCDetails或者用-Xlog:gc*debug看日志。日志里Full GC (Allocation Failure)说明是分配失败Full GC (Metadata GC Threshold)说明是元空间问题。不同的原因解决方案天差地别千万别看到一个Full GC就盲目调堆大小。我见过不少团队一遇到Full GC就把堆调大结果问题更严重。因为堆越大G1的并发标记周期越长对象在Region间拷贝的成本也越高。调优的思路应该是先确认为什么老年代会满是存活对象确实多还是浮动垃圾太多还是晋升速率异常然后对症下药。4. 关键参数与调优实操4.1 参数速查与匹配场景参数不在多在于知道每个参数在什么场景下动它才有意义。下面按作用维度列一个速查表。参数默认值作用什么时候调-XX:UseG1GCJDK9默认开启启用G1基本不用手动加但如果还在用JDK8要显式开启-XX:MaxGCPauseMillis200msG1的目标停顿时间业务对延迟敏感时下调到100或50但别设太低否则会频繁YGC-XX:G1HeapRegionSize自动计算Region大小极少手动调巨型对象场景可能按需调整-XX:InitiatingHeapOccupancyPercent45触发并发标记的老年代占用率老年代回收不及时导致Full GC时下调反之可上调-XX:G1MixedGCCountTarget8一个标记周期内Mixed GC次数Mixed GC耗时太长可增大数值分摊-XX:G1MixedGCLiveThresholdPercent85Old Region存活率高于此值不回收老年代有大量低存活率Region时可下调-XX:ParallelGCThreads按CPU核数计算STW阶段的并行线程数容器环境核数识别不准时需要手动指定-XX:ConcGCThreadsParallelGCThreads的1/4并发阶段线程数并发标记耗时长时适当调大关于ParallelGCThreads有个很容易踩的坑。在容器环境里JVM默认获取的是物理机的核数而不是容器分配的CPU配额。如果你的容器只分到2核但物理机有32核JVM按32核来计算并行线程数结果就是STW时创建大量线程上下文切换开销巨大。JDK 8u191之前必须手动指定-XX:ParallelGCThreads之后的版本开始支持容器感知但建议在高规格容器环境中还是确认一下实际生效值。4.2 MaxGCPauseMillis该怎么设很多人的第一反应是把目标停顿时间设得越小越好比如20ms、50ms。但实际上这个参数是G1的软目标不是硬性承诺而且设得太低会导致一个很尴尬的局面G1为了保证停顿时间达标会不断压缩年轻代大小年轻代变小了Eden区很快就满Young GC触发频率大幅上升。频率上升带来的影响是GC总开销反而增加的而且每次GC的promotion也会变多因为对象年龄没到就不得不晋升了晋升到老年代的对象增多老年代压力变大触发Full GC的风险反而升高。从我自己的实践经验来看比较合理的做法是先观察当前GC的真实停顿时间再结合业务的延迟要求来设置。如果线上P99延迟要求在100ms以内可以把MaxGCPauseMillis设在80~100ms如果业务本身就是离线任务对停顿不敏感没必要设这个参数默认200ms就挺好。另外设置这个参数之后必须用GC日志持续观察一段时间看G1是否真的把停顿时间压在了目标内。如果你发现日志里GC pause实际停顿经常超过200ms那问题不在这个参数而在其他环节比如RSet扫描时间过长、根扫描太慢这些和CPU核数、对象引用密度有关单纯调低目标值解决不了问题。4.3 IHOP参数的正确调整姿势-XX:InitiatingHeapOccupancyPercent控制的是老年代达到多少占比时触发并发标记。默认45%对很多应用来说可能偏保守也可能偏激进取决于你的业务对象存活率。先说偏保守的场景如果老年代长期在10%到20%之间徘徊45%的阈值就太低了导致G1频繁启动并发标记周期但每次标记完发现没多少垃圾可以收白白消耗了并发标记的CPU开销和SATB快照带来的浮动垃圾得不偿失。这种情况应该上调IHOP比如到60%甚至70%。再说偏激进的场景如果老年代增长很快经常冲到60%、70%以上还没来得及等到45%触发标记周期完成老年代就满了引发Concurrent Mode Failure。这种时候单纯调低IHOP不一定是最优解因为混合回收的进度可能跟不上老年代的增速。核心还是要看老年代的增长速率和回收速率是否匹配。有一个比较容易忽略的知识点-XX:UseDynamicNumberOfGCThreads和自适应IHOPAdaptive IHOP其实是G1的默认行为。G1会根据历史数据分析老年代的增长趋势在达到IHOP阈值前就提前启动并发标记。所以你在日志里看到并发标记的启动时机偶尔早于IHOP阈值不用慌这是自适应逻辑在起作用。4.4 调优顺序与日志解读新手经常犯的一个错误是一上来就东改一个参数西改一个参数改完看效果不好又回滚来回折腾。正确的调优顺序应该是先看日志、再定位瓶颈、最后才动参数。日志是G1给你的一切真相不看日志调参等于闭眼开车。开启GC日志的方式JDK8用-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/path/gc.logJDK9用-Xlog:gc*:file/path/gc.log:time,uptime,level,tags。线上环境建议始终带着GC日志而且要做好日志轮转不然文件能涨到好几个G。日志解读的核心关注点依次是Full GC是否出现、Mixed GC的频率和回收量、Concurrent Mark的耗时、Young GC的停顿时间分布、Eden/Survivor/Old三个区域的大小变化趋势。我在排查时还会特别关注To-space exhausted这类关键字说明分配速率远高于回收速率这是典型的容量规划和分代比例问题。5. 常见问题与排查技巧实录5.1 Concurrent Mode Failure并发标记赶不上分配这个错误是我在实际工作中遇到最多的G1高危故障。触发条件是并发标记周期还没跑完老年代就已经满了JVM无奈之下只能直接进入Full GC。日志通常会显示Concurrent Mode Failure外加一个停顿时间特别长的Full GC记录。排查时先搞清楚你设置的最大堆大小是否合理。如果老年代本身就很大且存活率高并发标记的时间会非常长。此时要检查几个数据老年代Region的平均存活率、分配到老年代的速率、并发标记各阶段的实际耗时。解决方案通常有几个方向。第一检查是否有大对象频繁分配如果Humongous对象过多考虑调大Region大小让单个大对象能放进一个Region而不是占满多个连续Region。第二优化业务的分配速率比如减少不必要的缓存对象、复用已有对象。第三适当调低IHOP让并发标记早点开始给标记周期留出充足的时间窗口。其实这个优先级是有讲究的先看代码分配再看参数别动不动就堆内存。5.2 晋升失败与Survivor空间不足晋升失败是另一种高频问题。它的本质是Young GC时存活对象要晋升到Survivor或者Old区但这两个区域都没有足够的空间容纳晋升的对象。日志表现为GC pause (young) (promotion failed)随后通常会跟一个Full GC做兜底。我见过一个比较典型的案例一个高吞吐的订单处理服务对象创建速率极高Young GC频率快但Survivor区因为年轻代被MaxGCPauseMillis压得很小只有几百MB。每次YGC存活对象都塞不下Survivor区被迫提前晋升到Old区Old区增长迅猛最后触发Full GC。这种场景的根因是GC频率和晋升速率互相踩踏。解决的思路不是加大Survivor区而是重新审视MaxGCPauseMillis的设置。目标停顿时间设得太低导致年轻代被压缩这反而是最需要警惕的自作孽。把这个参数放宽一些年轻代会变大Survivor的容量随之变大晋升压力自然缓解。5.3 巨型对象导致的连锁反应G1对巨型对象Humongous的处理非常特殊。当对象大小超过Region大小的50%JDK 8或者超过Region大小JDK 11对数组的特殊处理这个对象会直接进入老年代占用若干个连续的Region。这些Region不会被Mixed GC列为优先回收对象因为它们可能存活率高。巨型对象最大的问题不是占空间本身而是它在分配时需要连续的Region序列。如果老年代碎片化严重即使总空闲空间足够也可能分配失败触发Full GC。此外频繁创建巨型对象会导致老年代Region被撑爆但实际存活率很低回收效率极差。排查时用jmap或者看GC日志里的Humongous统计就能发现。解决思路是业务层面尽量避免大数组、大对象缓存能拆分的拆分如果实在避免不了比如要缓存一整个大文件对象就需要评估Region大小让对象落到单个Region内而不是跨越多个Region。5.4 频繁Mixed GC但老年代降不下来有一种很让人头大的情况Mixed GC一直在跑但每次回收量很小老年代占用率居高不下应用整体的GC开销却很高。我排查过一个数据分析平台的服务GC日志里Mixed GC频繁出现每次回收的Region只有几十个回收后老年代从60%降到58%下次周期又涨回去。这个问题的本质是老年代里全是存活率高的对象G1挑不出垃圾多、代价小的Region来回收。-XX:G1MixedGCLiveThresholdPercent默认85%如果绝大多数Region的存活率都在90%以上G1能选的Region就非常有限Mixed GC自然回收不了多少。但这里要问一个更根本的问题为什么老年代存活率这么高有一种常见原因是最初的IHOP设置太高导致并发标记开始得太晚大量对象已经晋升到老年代并长期存活。这时候单纯调低MixedGCLiveThresholdPercent效果有限更有效的做法是复盘晋升路径看是否有缓存对象被频繁误晋升到老年代或者某些长生命周期业务对象在早期被错误分配到了老年代。另外还要检查-XX:MaxTenuringThreshold和动态年龄判定是否有异常。5.5 一个相对完整的线上排查流程最后梳理一套我自己的排查流程遇到G1相关的GC问题可以直接照这个顺序走。第一步定位现象。搞清楚是Full GC频繁、Young GC停顿时间长还是CPU消耗异常高。不同的现象指向的根因差别很大别混为一谈。第二步拉日志。至少准备最近几小时的GC日志重点关注Full GC前后的Region变化、并发标记各阶段耗时、Mixed GC的回收量。第三步确认堆配置。记录-Xmx、MaxGCPauseMillis、IHOP等关键参数确认并行线程数是否符合容器配置。第四步分析分配速率和晋升速率。通过jstat -gcutil观察Eden、Survivor、Old的变化速率重点看每秒钟有多少字节被晋升到老年代这个数值是判断老年代压力最直接的指标。第五步确认代码侧问题。用jmap -histo看大对象、用jstack看线程分配热点必要时用async-profiler抓内存分配采样找出什么对象在什么地方被大量分配。第六步把问题和参数对上号然后小步调整每次只改一个参数观察至少一个完整的并发标记周期再下结论。调优切忌一次改多个参数否则你根本不知道是哪个参数起的作用。这套流程走下来大部分G1问题都能定位到根因。真正快速定位问题的人不是背了一堆参数而是能串起现象、日志、分配行为、代码这条链理解它们之间的因果。最后一个实际工作里的小建议G1线上调优不要追求极致参数目标是不失控。让G1在你设定的停顿目标下稳定运行偶尔出现一次超过预设的停顿不必惊慌先看是不是有System.gc、元空间扩容或者操作系统层面的抖动。GC日志里的时间单位、关键字段能在关键时刻帮你省下大把排查时间把GC日志完整打开比背任何调优口诀都有用。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进