一个凌晨告警:老年代逼近 100%,却没有 Full GC
某高并发查询服务在凌晨一点突然出现集群级告警:几乎所有节点的老年代使用率从低位跳到 95% 以上,但 GC 次数只从每分钟一两次升到两三次,Full GC 计数始终为零。运维先怀疑是内存泄漏,抓了堆快照,却发现存活对象只有几百兆,与监控上近 3G 的老年代占用严重不符。直到手动执行不带 live 参数的 jmap,才在快照里看到大量几兆大小的字符数组——它们是查询接口返回报文的日志文本,每次打印都生成一个超过 Region 一半的大对象,直接进入老年代的 Humongous 区。
这个案例暴露出的问题不是“老年代满了”,而是“并发标记没有在合适的时间启动”。G1 靠并发标记统计老年代各 Region 的存活比例,再据此挑选回收价值高的 Region 执行 Mixed GC。如果并发标记迟迟不启动,老年代只能靠 Full GC 兜底;如果启动太早,又会反复做无收益的标记和 Mixed GC,挤占应用线程。IHOP 就是控制这个启动时机的旋钮。
IHOP 到底在度量什么
IHOP 全称 Initiating Heap Occupancy Percent,由参数 -XX:InitiatingHeapOccupancyPercent 指定,默认值 45。Oracle 的 G1 调优文档把它描述为“整个堆占用达到该百分比时启动并发 GC 周期”。但实现层面有一个关键差异:OpenJDK 的缺陷记录 JDK-8151176 指出,静态 IHOP 实际是按“老年代已用空间 / 当前堆容量”来判断的,而不是整个堆的占用。也就是说,触发条件是老年代自己涨到堆容量的某个比例,而不是堆整体被填满。
这个差异直接决定了很多现象。假设堆 4G,新生代被允许占到堆的 60%,那么老年代最多只能占 40%。如果 IHOP 保持默认 45%,老年代永远达不到“老年代占用超过堆容量 45%”这个条件,并发标记根本不会启动,Mixed GC 也就无从谈起,最终只能等 Full GC 来回收老年代。JDK-8151176 描述的正是这种“静态 IHOP 下并发标记永不启动”的情形。
所以理解 IHOP 的第一步,是把它读成“老年代占用相对堆容量的比例”,而不是“堆整体有多满”。
并发标记的完整生命周期
G1 的一次并发标记周期并不是一个动作,而是一串有明确顺序的阶段。理解这些阶段,才能判断 IHOP 调高或调低会把哪一步推迟或提前。
初始标记(Initial Mark)需要一次短暂的 Stop-The-World,标记从 GC Roots 直接可达的对象。它通常搭在 Young GC 上完成,暂停时间很短。随后进入并发标记(Concurrent Marking),标记线程与应用线程同时运行,遍历对象图。这个阶段可以被打断,也可以被新分配的对象影响。接着是最终标记(Remark),再做一次短暂的 Stop-The-World,处理并发期间漏标或变动的引用。然后是清理(Cleanup),统计各 Region 的存活对象比例,并回收完全没有存活对象的 Region。最后进入存活对象计数和收尾工作,为后续 Mixed GC 挑选候选 Region。
并发标记本身不回收老年代对象,它产出的是“哪些 Region 值得回收”的信息。真正回收发生在 Mixed GC 中,而 Mixed GC 依赖并发标记的结果。因此 IHOP 调得太高,等于让标记信息迟迟不产生,Mixed GC 没有依据,老年代只能等 Full GC;IHOP 调得太低,标记周期频繁启动,虽然信息新鲜,但并发标记线程会持续消耗 CPU,与业务线程争抢资源。
flowchart TD
A[应用线程分配对象] --> B{老年代占用是否达到 IHOP}
B -- 否 --> A
B -- 是 --> C[初始标记 STW]
C --> D[并发标记 与应用线程并行]
D --> E[最终标记 STW]
E --> F[清理 统计 Region 存活比例]
F --> G{老年代垃圾占比是否超过 G1HeapWastePercent}
G -- 否 --> H[暂不回收 等待下一轮]
G -- 是 --> I[Mixed GC 回收候选 Region]
I --> J{老年代是否再次达到 IHOP}
J -- 是 --> C
J -- 否 --> A
45% 为什么既可能太早也可能太晚
默认值 45 是一个折中,它假设新生代不会长期占据堆的绝大部分,老年代有足够空间涨到 45% 再触发标记。但生产环境往往偏离这个假设。
一种偏离是新生代占比过高。G1 允许新生代最大占到堆的 60%(由 -XX:G1MaxNewSizePercent 控制,默认 60)。如果应用分配速率很高,G1 为了达到暂停目标会扩大新生代,老年代被压缩到 40% 以下。此时老年代即使全满,也达不到 45% 的阈值,并发标记永远不触发。这正是 JDK-8151176 描述的场景。
另一种偏离是老年代增长过快。高并发服务在流量高峰时,对象晋升速率可能远超平时。如果老年代从 20% 涨到 45% 只需要几十秒,而一次并发标记周期需要更长时间才能完成,那么当标记还在进行时,老年代已经逼近堆上限,G1 只能触发 Full GC。此时 45% 不是太晚,而是相对于增长速率太晚。
还有一种偏离来自大对象。超过 Region 一半的对象直接分配在老年代的 Humongous 区,不经过新生代晋升。资料 2 的案例中,日志文本大对象持续进入老年代,老年代占用在无 GC 时也快速上升。这种增长不遵循常规晋升节奏,IHOP 按老年代比例判断时,可能在大对象已经堆积后才触发标记,留给 Mixed GC 的空间不足。
IHOP 与 G1HeapWastePercent 的配合
并发标记完成后,G1 并不是立刻回收所有可回收的 Region。它要判断“回收这些 Region 能腾出多少空间”是否值得。-XX:G1HeapWastePercent 控制的就是这个门槛:默认 5,表示当可回收空间占堆的比例超过 5% 时,G1 才愿意在 Mixed GC 中回收老年代 Region。
这两个参数作用在不同阶段。IHOP 决定“什么时候开始统计”,G1HeapWastePercent 决定“统计出来的垃圾够不够多,值不值得回收”。如果 IHOP 触发得早,并发标记完成时老年代垃圾可能还不到 5%,G1 会认为回收收益太低,Mixed GC 不会立即执行,标记结果被搁置。如果 IHOP 触发得晚,标记完成时老年代垃圾比例很高,G1HeapWastePercent 很容易满足,但此时老年代可能已经接近上限,Mixed GC 来不及回收就触发 Full GC。
调整思路因此不是单独看 IHOP。降低 IHOP 会让标记更早开始,但如果 G1HeapWastePercent 保持默认 5,早期标记可能因为垃圾不够多而不被采纳。提高 G1HeapWastePercent 会让 G1 更挑剔,只回收垃圾比例高的 Region,减少 Mixed GC 次数,但也可能让一些中等垃圾比例的 Region 长期留在老年代。
| 参数 | 作用阶段 | 默认值 | 调低的影响 | 调高的影响 |
|---|---|---|---|---|
-XX:InitiatingHeapOccupancyPercent | 决定并发标记启动时机 | 45 | 更早启动标记,标记更频繁,CPU 开销上升 | 更晚启动标记,可能来不及回收就触发 Full GC |
-XX:G1HeapWastePercent | 决定 Mixed GC 是否回收 | 5 | 更愿意回收中等垃圾比例的 Region,Mixed GC 更频繁 | 只回收垃圾比例高的 Region,回收次数减少,老年代可能堆积 |
-XX:G1MixedGCCountTarget | 决定一轮标记后 Mixed GC 次数 | 8 | 单次 Mixed GC 回收的 Region 更多,暂停可能变长 | 单次回收更少,回收周期拉长 |
-XX:G1MaxNewSizePercent | 限制新生代最大占比 | 60 | 新生代更小,老年代有更多空间达到 IHOP | 新生代更大,老年代占比被压缩,可能永远达不到 IHOP |
这张表的关键在于:IHOP 和 G1HeapWastePercent 不是独立旋钮。降低 IHOP 的同时如果不动 G1HeapWastePercent,标记结果可能因为垃圾比例不足而被搁置;提高 G1HeapWastePercent 的同时如果不动 IHOP,老年代可能在没有足够回收收益的情况下继续堆积。
根据堆增长速率调整 IHOP
静态 IHOP 的问题在于它只看当前占用比例,不看增长速率。一个老年代从 30% 涨到 45% 用了十分钟的服务,和一个用三十秒就涨到的服务,需要的提前量完全不同。
调整时可以先估算两个量:老年代在高峰期的增长速率,以及一次并发标记周期从初始标记到清理完成的大致耗时。如果老年代增长速率是每分钟 5%,并发标记需要两分钟,那么从触发到标记完成,老年代还会再涨 10%。要让标记完成时老年代仍有回收空间,触发点应该提前到“目标上限减去这 10%”。假设希望老年代不超过 70%,IHOP 就应该设在 60% 左右,而不是 45%。
反过来,如果老年代增长很慢,并发标记很快完成,45% 可能触发得过早,导致标记周期频繁但每次回收收益有限。此时可以适当提高 IHOP,减少标记次数,把 CPU 留给业务线程。
对于资料 2 中那种大对象直接进入老年代的场景,IHOP 调整只能缓解,不能根治。大对象不经过新生代,老年代增长速率与常规晋升无关,按比例设置的 IHOP 可能跟不上大对象的堆积速度。更直接的做法是减少大对象本身,例如拆分长日志、避免一次性拼接超大字符串,或者调整 -XX:G1HeapRegionSize 让大对象阈值更高。
观测信号与诊断路径
判断 IHOP 是否合适,不能只看老年代使用率。GC 日志里的几个信号更有决策价值。
先看并发标记周期的启动频率和完成时间。如果并发标记很少出现,而老年代持续上升,说明 IHOP 可能设得过高,或者新生代占比过大导致老年代永远达不到阈值。如果并发标记频繁出现但 Mixed GC 回收的 Region 很少,说明 IHOP 可能设得过低,或者 G1HeapWastePercent 太高。
再看 Full GC 的触发原因。如果 Full GC 日志显示是分配失败(Allocation Failure),并且发生在老年代接近上限时,通常意味着并发标记启动太晚。如果 Full GC 之前已经有过并发标记和 Mixed GC,但回收量不足,则需要检查 G1HeapWastePercent 和 G1MixedGCCountTarget。
还可以观察 Humongous 区的分配情况。如果 GC 日志中频繁出现 Humongous 分配或 Humongous Region 回收,说明大对象在持续进入老年代,此时单纯调 IHOP 效果有限,需要从对象大小入手。
诊断路径可以按这个顺序走:先确认 Full GC 的触发原因,再确认并发标记是否在 Full GC 之前启动过,然后看 Mixed GC 回收了多少老年代 Region,最后判断是 IHOP 时机问题还是垃圾比例门槛问题。
适用边界与不适用场景
IHOP 调整适合老年代增长相对平稳、并发标记能在一个合理时间内完成的服务。对于这种服务,把 IHOP 从默认 45 调低到 35 或 40,可以给并发标记留出更多提前量,降低 Full GC 风险。
但它不适合以下情况。第一,新生代被显式固定大小(通过 -Xmn 或 -XX:NewRatio),G1 无法自适应调整新生代,老年代占比可能长期达不到 IHOP,此时应该先移除这些参数,让 G1 自己管理新生代。第二,老年代增长主要来自大对象,IHOP 只能推迟 Full GC,不能减少大对象本身。第三,堆容量很小,并发标记周期相对堆增长来说太长,调低 IHOP 也只是让标记更早开始,未必能在老年代耗尽前完成。
IHOP 也不是越低越好。设成 0 意味着持续做 GC 周期,CPU 开销会明显上升。设成接近老年代最大可用比例,则等于放弃提前标记。合理的值取决于老年代增长速率、并发标记耗时和可接受的 CPU 开销,需要结合 GC 日志实际测量,而不是照搬某个固定数字。
回到开头那个凌晨告警的服务,根因是大对象直接进入老年代,而不是 IHOP 设错。但如果没有并发标记及时提供老年代 Region 的存活信息,Mixed GC 就无法挑选回收目标,老年代只能一路涨到 Full GC。IHOP 的价值在于它决定了 G1 有没有机会在 Full GC 之前介入。理解它度量的是老年代相对堆容量的比例,理解它与 G1HeapWastePercent 的分工,再结合堆增长速率设置提前量,才能让并发标记在正确的时间启动。