一次突发流量后的长停顿
订单服务在晚高峰遇到一波促销流量,堆内存使用率在几分钟内从 40% 升到 70% 以上。运维看到应用线程出现一次接近一秒的停顿,日志里先是若干次正常的年轻代回收,随后出现 to-space exhausted 和 Pause Full (G1 Compaction Pause)。这类现象通常被归为“堆不够大”,但把堆调大并不总能消除停顿,因为触发路径发生在混合回收的复制阶段,而不是简单的容量耗尽。
G1 的常规回收依赖疏散:把选中区域里的存活对象复制到空闲区域,复制完成后原区域整体释放。这个设计让 G1 在回收时顺带压缩,避免碎片累积。代价是复制过程需要目标空间。如果目标空间在复制中途不够用,G1 无法完成疏散,只能放弃本次复制并回退到 Full GC。理解这条路径,需要先看清复制过程依赖哪些空间。
疏散在混合回收中如何消耗目标空间
G1 把堆划分成大小相等的 region,每个 region 在某一时刻属于 Eden、Survivor 或老年代。混合回收的回收集合(CSet)由全部年轻代 region 和一部分老年代 region 组成,选择老年代 region 的依据是回收收益,也就是存活对象少、垃圾占比高的区域优先。
疏散开始时,GC 线程把 CSet 中每个存活对象复制到空闲 region。复制的目标分几类:年轻代对象通常进入 Survivor region,晋升对象进入老年代 region,大对象可能进入专门的区域。每个 GC 线程有自己的复制缓冲区,对象先写入缓冲区,缓冲区写满后再申请新的目标 region。
这里有一个容易被忽略的约束:CSet 中的 region 在被疏散完成之前不能释放,它们仍然占用堆空间。同时,复制目标必须是空闲 region。因此,一次回收真正可用的目标空间,等于当前空闲 region 减去为后续分配预留的部分。当存活对象总量接近这个可用空间时,复制就会在中途失败。
疏散失败的判定与就地保留
疏散失败(Evacuation Failure)指 GC 线程在复制某个对象时,无法为它找到可用的目标空间。触发条件可以来自两个方向:一是 CSet 中存活对象的总量超出预期,复制需求超过可用目标空间;二是目标 region 的分配在并发复制过程中被其他 GC 线程抢先耗尽,某个线程申请新 region 时发现没有空闲 region 可分配。
失败发生后,G1 不会中断整个回收。它把尚未成功复制的对象留在原 region,并把这些 region 标记为疏散失败区域。这些对象没有被移动,所以它们的地址不变,引用它们的指针也不需要修正。原 region 不会被释放,而是继续保留这些存活对象。
就地保留的直接后果是本次回收没有释放出预期的空间。CSet 中那些本应被清空的 region 仍然占用堆,堆的有效可用空间反而更紧张。如果失败规模较大,G1 判断继续增量回收无法及时回收空间,就会安排一次 Full GC。这次 Full GC 使用标记-清除-压缩算法,把整个堆的存活对象整理到堆的一端,从而一次性释放碎片和失败区域。
在 JDK 10 之前,G1 的 Full GC 是单线程的标记-清除-压缩。JEP 307 将 G1 的 Full GC 改为并行,使用与年轻代、混合回收相同数量的线程,线程数由 -XX:ParallelGCThreads 控制。这个改动降低了 Full GC 的最坏停顿,但没有改变回退本身的发生条件。
flowchart TD
A[混合回收开始] --> B[选择 CSet:年轻代 + 高收益老年代 region]
B --> C[GC 线程复制存活对象到空闲 region]
C --> D{目标 region 是否足够}
D -- 足够 --> E[复制完成,原 region 释放]
D -- 不足 --> F[疏散失败:对象就地保留]
F --> G[失败 region 不释放,堆有效空间减少]
G --> H{能否继续增量回收}
H -- 能 --> I[后续混合回收继续尝试]
H -- 不能 --> J[回退 Full GC:全堆标记-压缩]
J --> K[停顿显著拉长]
图中的关键转折在“目标 region 是否足够”。这个判断不是一次性完成的,而是在复制过程中持续进行。一个对象复制失败就足以把所在 region 标记为失败区域,后续对象是否继续复制取决于 G1 对剩余空间的估计。
G1EvacFailureRegions 与保留空间参数
疏散失败区域在实现上由 G1EvacFailureRegions 跟踪。这个结构记录本次回收中哪些 region 发生了疏散失败,以便在回收收尾阶段区别处理:失败 region 不能按常规流程释放,需要保留其中的对象并修正相关的记忆集信息。它属于 HotSpot 的实现细节,不同 JDK 版本的具体字段和行为可能不同,不应把它当作跨 JVM 的规范保证。
真正影响失败概率的,是 G1 为目标空间预留的余量。-XX:G1ReservePercent 控制 G1 为疏散目标保留的堆比例,默认值为 10。这个预留空间的作用是给复制过程留出缓冲,避免目标 region 在复制中途被耗尽。预留比例越高,可用于应用分配的堆越少,但疏散失败的概率越低。
预留空间和 IHOP 是两个不同层面的控制。IHOP 即 Initiating Heap Occupancy Percent,决定老年代占用达到多少时启动并发标记周期。并发标记完成后,G1 才知道哪些老年代 region 值得回收,进而安排混合回收。IHOP 设置偏低会让标记周期更早启动,混合回收更早发生,但每次混合回收的 CSet 可能更小;IHOP 设置偏高则可能让老年代占用在标记完成前就逼近上限,压缩疏散可用的目标空间。
两者的关系可以这样理解:IHOP 决定“什么时候开始准备回收”,G1ReservePercent 决定“回收时留多少缓冲”。如果 IHOP 过高,并发标记还没完成,老年代已经堆积大量对象,混合回收启动时可用目标空间被挤压,疏散失败的概率上升。如果 G1ReservePercent 过低,即使 IHOP 合理,复制过程中也可能因为预留不足而失败。
通过 GC 日志识别失败信号
排查这类停顿,第一步是打开 GC 日志。JDK 9 及以后使用统一日志框架,可以用 -Xlog:gc* 输出详细回收信息,具体标签组合需要根据 JDK 版本确认。日志中与疏散失败相关的信号有几类:
to-space exhausted:表示复制目标空间耗尽,是疏散失败最直接的标志。Evacuation Failure:部分日志格式会直接输出该字样,并附带失败区域数量或失败对象大小。Pause Full (G1 Compaction Pause):表示发生了回退 Full GC,括号内说明是压缩式 Full GC。- 混合回收后堆占用没有下降,或者下降幅度远小于 CSet 预期释放量。
这些信号需要结合时间线看。如果 to-space exhausted 出现在一次混合回收中,紧接着出现 Full GC,说明失败规模已经让 G1 判断无法继续增量回收。如果只有零星的疏散失败而没有 Full GC,说明 G1 通过就地保留和后续回收消化了压力,但这类回收的停顿通常也会比正常混合回收更长。
一个可操作的判断顺序是:先确认失败发生在年轻代回收还是混合回收,再确认失败前老年代占用和 IHOP 的关系,最后看预留空间是否被其他分配消耗。年轻代回收中的疏散失败通常与 Survivor 空间或晋升目标不足有关;混合回收中的失败则更多与老年代 region 的复制目标有关。
与替代方案的权衡
调整保留空间和 IHOP 是两条常见路径,但它们改变的是不同的量。下面的对比可以帮助判断先动哪个参数。
| 调整方向 | 主要作用 | 代价 | 适用场景 |
|---|---|---|---|
提高 -XX:G1ReservePercent | 增加疏散目标缓冲,降低复制中途耗尽概率 | 减少应用可用堆,可能提高常规回收频率 | 日志中出现 to-space exhausted,但老年代占用尚未持续高位 |
| 降低 IHOP | 更早启动并发标记,混合回收更早介入 | 标记周期更频繁,占用更多 CPU 和并发时间 | 老年代占用上升快,混合回收总是滞后 |
| 增大堆 | 直接增加空闲 region 总量 | 内存成本上升,Full GC 停顿可能随堆增大而变长 | 存活对象总量确实接近堆容量 |
| 减少大对象分配 | 降低 Humongous region 对连续空间的挤占 | 需要改代码或对象结构 | 日志中 Humongous 分配频繁,碎片化明显 |
这张表只做定性比较,具体数值需要根据实际日志和压测结果确定。G1 的停顿预测模型会根据历史数据估算本次回收的工作量,但预测基于衰减均值和标准差,突发流量下的分配速率变化可能让预测偏离实际。
边界与不适用场景
疏散失败和 Full GC 回退是 G1 在目标空间不足时的保护机制,不是所有长停顿都由它引起。如果日志中没有 to-space exhausted 或 Evacuation Failure,长停顿可能来自其他原因,比如并发标记周期中的 Remark 停顿、引用处理、或者应用自身的同步阻塞。
G1ReservePercent 的调整也有边界。预留空间是从整个堆中划出的,如果堆本身已经接近满负荷,提高预留比例会进一步压缩应用可用空间,可能让常规回收更频繁,甚至提前触发新的回收周期。在容器环境中,堆大小受容器内存限制,预留空间的调整需要和容器内存上限一起考虑。
IHOP 的默认行为在 JDK 9 及以后由 G1 自适应调整,除非显式设置 -XX:InitiatingHeapOccupancyPercent,否则 G1 会根据标记周期和回收历史动态决定启动时机。显式设置会关闭自适应,在流量模式变化较大的场景中可能不如自适应稳定。
疏散失败区域的就地保留意味着对象地址不变,这对依赖地址稳定性的场景没有额外影响,但会让堆的碎片化程度上升。如果失败反复发生,即使没有触发 Full GC,堆中也会积累大量无法移动的存活对象,最终仍然需要一次压缩式回收来整理。
回到订单服务的排查路径
回到开头的订单服务。排查时先确认日志中是否有 to-space exhausted,如果有,再看它出现在混合回收还是年轻代回收。混合回收中的失败,优先检查 IHOP 是否让混合回收启动过晚,以及 G1ReservePercent 是否被其他分配消耗。如果老年代占用在失败前已经接近堆上限,增大堆或降低 IHOP 可能比提高预留比例更直接。
调整后需要观察的指标包括:混合回收后堆占用的下降幅度、疏散失败出现的频率、Full GC 的间隔和持续时间。如果失败频率下降但 Full GC 仍然出现,说明问题可能不在目标空间,而在存活对象总量或对象生命周期。G1 的设计目标是避免 Full GC,但无法保证在所有分配模式下都避免。识别失败信号、区分触发路径,比直接调大堆或调参数更能定位问题。