Java 技术
#G1 GC#记忆集#写屏障#SATB#GC 调优#Java

G1 GC 区域化内存管理与记忆集的工程权衡

本文聚焦 G1 GC 如何通过区域划分和记忆集实现并发标记与部分回收,核心问题是“在低延迟目标下,记忆集维护如何影响吞吐与内存开销”。以高并发订单服务为贯穿场景,分析记忆集数据结构、写屏障、SATB 算法及调优参数,并与 CMS 和 ZGC 对比,帮助读者根据堆大小和延迟目标选择 GC 策略。

问题场景:订单服务在混合 GC 中的停顿抖动

一个高并发订单服务运行在 32 GB 堆的 JVM 上,使用 G1 GC 并设置了 200 ms 的停顿目标。在常规流量下,GC 停顿稳定在 150 ms 左右。但促销活动期间,随着长期存活订单对象累积,服务开始出现间歇性的 500 ms 停顿,甚至偶发 Full GC。监控显示,这些长停顿发生在 G1 的混合收集阶段,且记忆集(Remembered Set)占用了近 15% 的堆空间。为什么 G1 在部分回收老年代时会出现远高于目标的停顿?记忆集在其中扮演了什么角色?

G1 的设计初衷是通过区域化堆布局和并发标记,在可控停顿下逐步回收老年代。但这一机制依赖记忆集来维护跨区域引用,而记忆集的维护成本直接受应用写入模式和堆中对象图结构的影响。订单服务中,缓存订单状态的对象频繁更新,导致大量跨区域引用被记录到记忆集,最终拖慢回收阶段。本文将分析记忆集的结构、写屏障如何捕获引用变化、SATB 并发标记算法如何保证一致性,并探讨如何通过调优参数控制记忆集膨胀,从而在低延迟与吞吐之间取得平衡。

区域化堆:为什么 G1 需要记忆集

G1 将堆划分为大小相等的区域(Region),每个区域可以是 Eden、Survivor 或 Old 代的一部分。这种布局打破了传统分代收集器连续内存空间的限制,使 G1 能够选择包含最多垃圾的区域优先回收(Garbage-First)。但区域化也带来一个问题:当回收某个区域时,必须知道其他区域中是否有对象引用了该区域中的对象。如果每次回收都扫描整个堆来查找这种跨区域引用,停顿时间将无法接受。

记忆集正是为解决这一问题而设计的数据结构。每个区域维护一个记忆集,记录“其他区域中哪些位置引用了本区域的对象”。这样,在回收一个区域时,只需扫描记忆集中记录的引用源位置,而不必遍历整个堆。记忆集本质上是空间换时间的策略:用额外的内存和 CPU 维护成本,换取回收阶段更短的停顿。

在订单服务中,一个典型的跨区域引用场景是:一个位于 Old 区的订单缓存(如 ConcurrentHashMap)引用了 Young 区的订单明细对象。当 Young GC 发生时,如果只扫描 Young 区,会错误地将订单明细对象当作垃圾回收。因此,G1 需要知道 Old 区中哪些字段指向了 Young 区,这正是记忆集的作用。

记忆集的数据结构与写屏障

记忆集的实现是 G1 工程权衡的核心。一个简单的方案是为每个区域维护一个引用源列表,但这样内存开销巨大。G1 采用了一种分层结构:

  • 粗粒度位图(Coarse Bitmap):每个区域对应一个位图,位图中的每一位代表一个卡表页(Card Page,通常 512 字节)。如果某个卡页中包含指向本区域的引用,对应位被置 1。
  • 细粒度哈希表(Fine-Grained Hash Table):对于热点区域,粗粒度位图会导致扫描过多卡页(因为一个卡页可能包含多个对象,但只有少数字段指向目标区域)。此时 G1 会使用哈希表记录精确的引用位置(卡页索引 + 偏移量)。

这种分层设计允许 G1 根据引用密度动态调整:大多数区域使用粗粒度位图,只有少数高引用密度的区域升级为细粒度哈希表。在订单服务中,如果某个 Old 区中的缓存对象大量引用 Young 区对象,该 Old 区对应的记忆集可能从粗粒度升级为细粒度,以降低扫描成本。

记忆集的维护依赖写屏障(Write Barrier)。每当应用线程执行引用赋值操作(如 order.setDetail(detail)),JIT 编译器会在生成机器码时插入写屏障代码。G1 使用了一种类似于 Card Marking 的技术:写屏障将引用所在的卡页标记为“脏”(Dirty),并记录在全局脏卡队列(Dirty Card Queue)中。后续 GC 线程会处理这些脏卡,更新相关区域的记忆集。

写屏障带来的开销是 G1 吞吐量低于 Parallel GC 的主要原因之一。每次引用写入都需执行额外指令,尽管经过优化(如通过批量处理和缓存局部性),但在写入密集的应用中仍可造成 5%~10% 的吞吐下降。订单服务在促销期间,大量订单状态更新导致写屏障频率激增,加剧了 CPU 开销。

SATB 并发标记与记忆集的交互

G1 的并发标记阶段使用 SATB(Snapshot-At-The-Beginning)算法,在标记开始时创建一个对象图的逻辑快照,然后并发地遍历对象图并标记存活对象。SATB 保证在标记期间新创建的对象不会被错误回收,但可能将标记期间死亡的对象保留到下一轮回收(浮动垃圾)。

SATB 与记忆集紧密配合:并发标记线程在遍历对象图时,会遇到跨区域引用。此时,如果目标区域尚未被标记,标记线程会将该引用记录到目标区域的记忆集中,同时继续遍历。这样,记忆集在并发标记期间被逐步填充,为后续的部分回收提供依据。

SATB 也依赖写屏障来捕获标记期间引用关系的变化。当应用线程修改引用时,写屏障会将旧引用值记录到一个 SATB 队列中。并发标记线程会处理这些队列,确保快照一致性。这进一步增加了写屏障的负担:G1 的写屏障需要同时处理卡标记和 SATB 记录,比 CMS 的写屏障更重。

在订单服务中,并发标记与业务线程并发执行。当标记线程扫描 Old 区中的订单缓存时,会将缓存中指向 Young 区的引用记录到对应 Young 区的记忆集。如果此时业务线程更新了缓存,旧引用被记录到 SATB 队列,标记线程稍后处理。这个过程保证了最终回收阶段能正确识别所有存活对象。

贯穿场景:混合 GC 中的记忆集膨胀与停顿

回到订单服务的例子。在促销活动后,堆中累积了大量长期存活的订单对象,老年代占比达到 60%。G1 触发并发标记,随后进入混合收集阶段,开始回收老年代区域。但监控显示,某些混合收集停顿远超 200 ms 目标。分析 GC 日志发现,Merge Heap RootsScan Heap Roots 阶段耗时异常。

这两个阶段负责处理记忆集:Merge Heap Roots 合并来自其他区域的记忆集引用,Scan Heap Roots 扫描这些引用指向的对象。如果记忆集膨胀,即记录了过多引用,这两个阶段的时间就会显著增加。在订单服务中,由于订单缓存对象频繁更新,大量卡页被标记为脏,导致记忆集包含大量引用记录。部分区域的记忆集从粗粒度升级为细粒度,虽然减少了扫描的卡页数,但哈希表查找本身也有开销。

更严重的是,记忆集膨胀还占用了大量堆外内存,挤占了应用对象空间,间接导致 GC 频率上升。在极端情况下,记忆集维护的 CPU 开销和内存占用形成恶性循环:应用吞吐下降 -> 对象分配变慢 -> 堆占用升高 -> 更频繁的并发标记 -> 更多记忆集更新。

下面的流程图展示了订单服务在一次混合收集中的关键步骤,以及记忆集如何影响停顿时间:

flowchart TD
    A[并发标记完成] --> B[选择回收区域]
    B --> C[合并记忆集]
    C --> D{记忆集是否膨胀?}
    D -->|是| E[扫描大量引用源]
    D -->|否| F[快速扫描引用源]
    E --> G[停顿时间超标]
    F --> H[停顿时间正常]
    G --> I[应用线程等待]
    H --> J[回收完成]

图中,合并记忆集 阶段从其他区域收集指向待回收区域的引用记录。如果记忆集膨胀,该阶段耗时增加,直接导致停顿超标。

调优参数:控制记忆集开销

G1 提供了一些参数来影响记忆集的行为,但调优需要谨慎,因为默认值已在大多数场景下平衡了开销。以下参数与记忆集相关:

  • -XX:G1HeapRegionSize:区域大小。增大区域可减少区域总数,从而减少记忆集的总内存占用,但可能导致更严重的碎片化(如巨型对象分配失败)。默认值根据堆大小自动计算,通常为 1~32 MB。
  • -XX:G1RSetUpdatingPauseTimePercent:G1 在停顿中用于更新记忆集的时间百分比上限(默认 10%)。降低此值可减少停顿中记忆集处理的时间,但可能将更多工作推迟到并发阶段,增加并发线程 CPU 消耗。
  • -XX:G1ConcRefinementThreads:并发精炼线程数,负责异步处理脏卡队列,更新记忆集。增加线程数可加速记忆集更新,减少停顿中积压的工作,但会占用更多 CPU。
  • -XX:+ReduceInitialCardMarks:减少初始卡标记,优化大对象分配的写屏障开销。

在订单服务中,我们尝试将区域大小从默认的 4 MB 增加到 8 MB(-XX:G1HeapRegionSize=8M),区域总数减少一半,记忆集内存占用从 15% 降至 10%。同时,增加并发精炼线程数到默认值的 2 倍(-XX:G1ConcRefinementThreads=4),使得脏卡队列被更快处理,减少了停顿中 Merge Heap Roots 的时间。调整后,混合收集停顿恢复到 200 ms 以内。

但必须注意,增大区域大小可能导致巨型对象分配更困难。如果订单服务中有大量超过 4 MB 的缓存对象,它们会被分配为巨型对象,占用多个连续区域。区域增大后,连续区域更难找到,可能引发 Full GC。因此,调优需结合应用对象大小分布。

对比 CMS 与 ZGC:记忆集视角的权衡

G1、CMS 和 ZGC 都致力于低停顿,但它们在记忆集和并发标记的实现上存在根本差异,这决定了它们适用的场景。

特性G1CMSZGC
堆布局区域化(Region)连续老年代 + 年轻代区域化(ZPage)
记忆集维护写屏障 + 脏卡队列 + 分层结构写屏障 + 卡表(Card Table)读屏障 + 染色指针(Colored Pointers)
并发标记算法SATB增量更新(Incremental Update)SATB + 染色指针
写屏障开销较重(卡标记 + SATB)较轻(仅卡标记)无写屏障,但读屏障开销
停顿模式部分区域回收,停顿可控老年代并发回收,但需 STW 重新标记全并发,停顿极短(<1ms)
内存开销记忆集占用 5%~15% 堆卡表固定开销(约 1/512 堆)染色指针无额外内存,但需虚拟地址空间
适用堆大小4~64 GB4~8 GB(已废弃)16 TB 及以下

CMS 使用卡表记录跨代引用,但卡表是全局结构,每次 Young GC 都需要扫描整个卡表,这在老年代很大时效率低下。此外,CMS 的并发标记采用增量更新,在标记期间跟踪新引用,可能导致更多浮动垃圾。CMS 已在 JDK 14 中废弃,主要因为其碎片化问题和不可预测的停顿。

ZGC 则完全摒弃了记忆集。它通过染色指针(Colored Pointers)在引用中嵌入元数据,结合读屏障(Load Barrier)在访问对象时检查引用状态,从而实现并发整理。ZGC 没有写屏障,但读屏障会在每次对象访问时执行少量指令,对吞吐的影响与 G1 的写屏障相当或略低。ZGC 的停顿极短,但需要更大的内存(因为染色指针需要额外的虚拟地址位)和更高的 CPU 资源。

对于订单服务,如果堆大小在 32 GB 左右且需要 200 ms 级别的停顿,G1 是合适的选择。如果堆增长到 64 GB 以上,且需要亚毫秒级停顿,则应考虑 ZGC。CMS 已不推荐使用。

失败模式与诊断路径

记忆集相关的常见失败模式包括:

  • 记忆集膨胀:如前所述,过多的跨区域引用导致记忆集占用大量内存和 CPU。诊断方法:使用 -Xlog:gc+remset=trace 输出记忆集更新细节,观察 RSet 大小和更新耗时。
  • 并发标记跟不上分配:如果应用分配速率过高,并发标记无法在堆耗尽前完成,导致 Full GC。日志中会出现 Pause Full (G1 Compaction Pause)。解决方法:降低 -XX:InitiatingHeapOccupancyPercent 提前触发标记,或增加 -XX:ConcGCThreads
  • 巨型对象碎片:大量巨型对象导致区域碎片化,即使堆有剩余空间也无法分配。日志中 Humongous regions 数量持续增长。可增大区域大小或减少巨型对象分配。
  • 写屏障开销过高:在写入密集的应用中,写屏障导致吞吐显著下降。可通过 -XX:+UnlockDiagnosticVMOptions -XX:+G1SummarizeRSetStats 输出记忆集统计,判断是否因记忆集更新过于频繁。

诊断时,首先启用 GC 调试日志:-Xlog:gc*=debug。关注以下指标:

  • Pause Young (Mixed) 中的 Merge RSScan RS 时间。
  • 并发标记周期中的 Concurrent Marking 耗时和 SATB 队列处理量。
  • 堆信息中的 Humongous regionsRSet 大小。

如果 Merge RS 时间占比过高,可尝试调整 -XX:G1RSetUpdatingPauseTimePercent 或增加并发精炼线程。如果并发标记频繁失败,需调整 IHOP 或增加堆大小。

适用边界与决策框架

G1 并非万能。当应用满足以下条件时,G1 能发挥最佳效果:

  • 堆大小在 4~64 GB 之间。
  • 停顿目标在 100~500 ms 可接受。
  • 应用对象分配速率波动较大,需要自适应调整。
  • 能够接受 5%~10% 的吞吐下降以换取可控停顿。

如果堆小于 4 GB,Parallel GC 通常能提供更高吞吐,且停顿时间本身较短。如果堆大于 64 GB 且需要极低停顿,ZGC 或 Shenandoah 更合适。如果应用是纯批处理任务,无停顿要求,Parallel GC 是最佳选择。

对于订单服务,当前 32 GB 堆和 200 ms 目标落在 G1 的甜点区。但若未来堆增长到 128 GB,且要求 10 ms 停顿,迁移到 ZGC 是必然选择。迁移前需评估:ZGC 的读屏障对吞吐的影响是否可接受?染色指针依赖的虚拟地址空间是否足够(通常需要 42 位以上)?

最终,GC 选择是一个多维度权衡。G1 通过记忆集和区域化,在低停顿和可接受吞吐间找到了一个工程平衡点。理解记忆集的机制和开销,能帮助我们在遇到问题时做出有根据的调优决策,而不是盲目修改参数。

资料来源

  1. Garbage-First Garbage Collector Tuning
  2. JEP 248: Make G1 the Default Garbage Collector
  3. G1GC Internals: Remembered Sets