Garbage-First (G1) Garbage Collector 概览

681 阅读21分钟

前言

Garbage-First (G1) Garbage Collector (后文简称 G1)是垃圾收集器技术发展史上的里程碑式的成果,开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。G1 最早是在 2004 年 David Detlefs 等人的论文 Garbage-First Garbage Collection 中提出。自从 在 2009 年 3 月 JDK 6 Update 14 引入 Early Access 版本,15 年来 G1 的性能和功能得到巨大的提升和完善。

Java 历史版本信息参考: Java/Java SE: All Releases, End of Life, Release Date

G1 历史

    timeline title G1 发展史
    2004年 : 论文发表
    2009年 : JDK 6 实验版本
    2012年 : JDK 7 Oracle 认为能够商用
    2015年 : JDK 8 提供并发的类卸载支持, 此后 Oracle 官方称之为全功能的垃圾收集器
    2017年 : JDK 9 G1 称为服务端模式默认垃圾收集器 
    timeline title G1 优化
    2018 : JDK 10  Parallel Full GC for G1
    2019 : JDK 12  Abortable Mixed Collections for G1
    2019 : JDK 12  Promptly Return Unused Committed Memory from G1
    2020 : JDK 14  NUMA-Aware Memory Allocation for G1
    2021 : JDK 11-18  Optimize memory usage for G1 remembered sets
    2024 : JDK 22  Region Pinning for G1

注意:本文以 JDK 21 为例

垃圾收集算法

在 JVM 中负责管理应用堆内存的组件叫做垃圾收集器(garbage collector,简称 GC)。GC 管理着内存的分配和释放,具体来说管理着堆对象的整个生命周期。JVM 垃圾收集器的基础功是:

  1. 根据应用程序的要求尽快地分配内存。
  2. 高效率地检测应用程序已经不需要的内存进行回收。

对于优秀的垃圾收集算法有很多要求,有三点是需要重点讨论的,分别是 throughputlatencyMemory footprint。虽然有很多实现方式满足以上要求,但是某一种算法只能在某一个方面达到最优秀。基于此,JDK 提供了多种垃圾回收算法来应对不同的业务场景。

  1. Throughput 表示在单位时间内所在的工作量,更多的工作量意味着更高的 throughput。
  2. Latency意味着单个操作所花费的时间,具体来说是垃圾收集期间 stop the world 的时间。
  3. Memory footprint 说的是垃圾收集需要的内存,比如与垃圾收集相关的数据结构,统计数据。

上述三个指标是相互联系的:更高的 throughput 会导致高 latency ,反之亦然,更低的 latency 会降低 throughput。而更低的 Memory footprint 的算法可能使得其他二者的指标变得更差。

理想的垃圾回收算法是更高的 throughput,更低的 lantency, 更低的 footprint。

image.png

垃圾收集器

针对不同的场景, JDK 21 提供五种不同垃圾收集器。

Garbage CollectorFocus AreaConcepts
ParallelThroughputMultithreaded stop-the-world (STW) compaction and generational collection
Garbage First (G1)Balanced performanceMultithreaded STW compaction, concurrent liveness, and generational collection
Z Garbage Collector (ZGC) (since JDK 15)LatencyEverything concurrent to the application
Shenandoah (since JDK 12)LatencyEverything concurrent to the application
SerialFootprint and startup timeSingle-threaded STW compaction and generational collection

Parallel GC 是 JDK 8 及以前版本的默认垃圾收集器。它聚焦于 throughput 的最大化而稍微降低对 latency 的要求。

G1 从 JDK 9 开始成为默认垃圾收集器,它试图平衡 throughput 和 lantency 。

ZGC 和 Shenandoah GC 专注于低 lantency。ZGC 在 JDK 11 中引入,JDK 15 中达到生产可用,后续版本中逐渐增强,在 JDK 21 中加入分代收集功能 ZGC history。Shenandoah GC 在 JDK 12 中开始引入,后续被下放到之前版的版本,根据 Shenandoah history,Shenandoah GC 在 JDK 8 及以后的长期支持版均可用,有望在 JDK 24 引入分代收集功能。

Serial GC 专注于 footprint 和 startup time,是单线程版的 Parallel GC。

在 throughput 优先的情况下,Parallel GC 比 G1 和 ZGC 都要好。见 How fast is Java 21

堆划分

G1 是一个分代、增量收集、并行、大多数时间并发、少量停顿用户线程收集器,它能最大限度的满足用户设置的最大停顿时间。G1 是默认的垃圾收集器,也可以用 -XX:+UseG1GC 开启。使用参数 -XX:MaxGCPauseMillis 设置最大停顿时间,默认为 200 ms。

Java 堆内存总体结构

image.png

G1 通过参数-Xmx 和 -Xms 设置最大最小堆内存。

G1 是分代收集器,将堆分成年轻代和老年代,年轻代的大小通过-XX:G1NewSizePercent 和 -XX:G1MaxNewSizePercent 来设置,前者默认值为 5,后者默认值为 60。意味着年轻代大小占总的堆大小为 5% ~ 60%,G1 会根据情况动态调整年轻代大小以满足最大停顿时间。

Region

G1 开创了基于 region 的内存布局

image.png

如图所示,G1 将连续的堆内存分成多个大小相等的独立区域(Region),每个 Region 都可以根据需要扮演新生代或者老年代空间。Region 的大小通过 -XX:G1HeapRegionSize 指定,取值范围为 1MB ~ 32 MB,且应为 2 的 N 次幂。

注意:-XX:NewSize 和 -XX:MaxNewSize同样能设置新生大小,如果只有一个参数被设置值,那么新生代就会被固定为这个值,会影响暂停时间的控制。

Remembered Sets

card

为了更好的管理内存,Java 将 Region 划分成多个 Card,每个 Card 是 512(2^9) 字节,然后使用 2 字节整数对 Card 进行编号。G1 uses a 16 bit integer to store card indexes to save space。下文会看到源码定义。

那么每个 Region 的最大内存为 2^16 * 2^9 = 2^25 = 32MB,还可以计算最大堆内存为 32MB * 2048 = 64GB。

image.png

Remembered Sets 的实现

G1 利用记忆集(Remembered Sets)解决分代收集和部分收集的问题,具体来说用于记录非收集区域指向收集区域的指针集合的抽象数据结构。由于 young GC(回收新生代) 和 mixed GC(回收新生代和部分老年代)都会回收整个新生代,那么记忆集只需要记录两类跨区域指针。

  1. 老年代到新生代的指针。
  2. 老年代到老年代的指针。
image.png

记忆集记录的是 point-in 指针,即谁指向了我。如上图,老年代的 B 指向了新生代的 E。(这里字母理解成 card 更好)。

JDK 18 之前

在 JDK 18 之前,根据记录信息的多少有三个维度的记录方式,不同的维度决定了内存占用多少和垃圾收集时的遍历速度。

// HeapRegion -> HeapRegionRemSet* _rem_set -> OtherRegionsTable _other_regions
class OtherRegionsTable VALUE_OBJ_CLASS_SPEC {
  BitMap      _coarse_map; 
  PerRegionTable** _fine_grain_regions;
  SparsePRT   _sparse_table;
}
  1. Sparse: _sparse_table 是一个 hash 表,key 是 region id,value 是 card id 的集合。类似地使用 Java 申明是 new HashMap<Integer, List<Integer>>(),遍历速度最快。sparsePRT.hpp源码 看到存储 card id 数组 _cards 的类型就是 uint16_tuint16_t能存储的最大值 2^16-1,每个 region 中包含 card 最大数量 2^16 个,Region 的最大内存为 2^16 * 2^9 = 2^25 = 32MB。
//SparsePRT->RSHashTable* _table -> SparsePRTEntry* _entries
class SparsePRTEntry: public CHeapObj<mtGC> {
  typedef uint16_t card_elem_t;
  RegionIdx_t _region_ind;
  card_elem_t _cards[card_array_alignment];
  1. Fine: _fine_grain_regions 也是一个 hash 表,key 是 region id,value 是 BitMap,类似地使用 Java 申明是 new HashMap<Integer, BitMap>(),遍历速度次之。BitMap 初始化的长度是 card 的个数。
_bm(HeapRegion::CardsPerRegion, false /* in-resource-area */),
  1. Coarse: _coarse_map 是个 BitMap,仅仅记录 region id,遍历速度最慢。BitMap 初始化的长度是 region 的个数。
  _coarse_map(G1CollectedHeap::heap()->max_regions(),
              false /* in-resource-area */),

JVM 一开始会使用 Sparse 记录,如果随着指向当前 region 的跨代指针增加,当前结构存储的代价逐渐增加,最终会逐渐使用 Coarse 记录。

添加

heapRegionRemSet.cpp method add_reference 记忆集添加的逻辑都在这个方法中,主要逻辑就是在三个容器中查找,再根据条件(dirty card 的数量)进行存储容器类型的转换。下面是伪代码:

 var exist  = find_in(_coarse_map, region_idx, card_idx);
 if(exist) return;
 
 var exist  = find_in(_fine_grain_regions,region_idx, card_idx);
 if(exist) return;
 
 var res = add_card_to(_sparse_table,region_idx, card_idx);
 if(res == SparsePRT::added) return ;
 
 add_card_to_fine_grain_regions_and_transfer(_fine_grain_regions,_sparse_table ,region_idx, card_idx)

JDK 18

JDK 18 对 Remembered Sets 进行了重构,使得占用的内存大大减少,垃圾收集花费的时间会更少,突破了 region 最大是 32MB 的限制,最大 region 可以达到 512 M。详情请看 G1: Refactor remembered sets8275056: Virtualize G1CardSet containers over heap region

static const size_t MIN_REGION_SIZE = 1024 * 1024;
static const size_t MAX_ERGONOMICS_SIZE = 32 * 1024 * 1024; // 32M
static const size_t MAX_REGION_SIZE = 512 * 1024 * 1024; //512 Mb
static const size_t TARGET_REGION_NUMBER = 2048;

[JDK-8017163] G1: Refactor remembered sets 中提到:

The problems are that there is nothing between "no card at all" and "sparse" and in particular the difference between the capability to hold entries of "sparse" and "fine". I.e. memory usage difference when exceeding a "sparse" array (holding 128 entries at 32M regions, taking ~256 bytes) to fine that is able to hold 65k entries using 8kB is significant.

在 "sparse" array 中保存 128 个元素(dirty card)需要使用 256 字节(128 * 2, card index 使用 uint16_t 保存)。如果多存出一个元素就要转换成 "fine" 则需要 8kb(32M/512byte/8bit/1024)。存储空间增加了32倍(8kb/256byte)。

存储容器转换使得内存占用急剧增加,没有 "no card at all" 和 "sparse" 之间的容器,也没有 "sparse" 和 "fine" 之间的容器。

关于 JDK 18 优化之后的记忆集源码解读可以参考 JDK 18 记忆集

In some benchmarks (where there is significant remembered set memory usage) we are seeing memory reduction to 25% of JDK 16 levels with this change.

记忆集持续优化可以参考 jdk23-g1-serial-parallel-gc-changes.html。第一步是所有新生代 region 使用同一个记忆集,第二步是优化老年代。具体思路是用一个记忆集记录新生代所有跨代引用,依据是 young GC 还是 mixed GC 都会回收整个新生代,

优化前

image.png

优化后

image.png

写屏障

写屏障(write barriers)分为写前屏障(pre-write)和写后屏障(post-write),前者用于 SATB (Snapshot-at-the beginning) 算法保证并发标记的正确性,后者用于维护记忆集。

例如下面的赋值代码

object.field = some_other_object;

会被翻译成下面的代码(简化过的)

void post_write_barrier(oop* field, oop new_value) {  
  uintptr_t field_uint = (uintptr_t) field;  
  uintptr_t new_value_uint = (uintptr_t) new_value;  
  uintptr_t comb = (field_uint ^ new_value_uint) >> HeapRegion::LogOfHRGrainBytes;  
  
  if (comb == 0) return; // field and new_value are in the same region  
  if (new_value == null) return; // filter out null stores  
  
  // Otherwise, log it  
  volatile jbyte* card_ptr = card_for(field); // get address of the card for this field  
  
  // in generational G1 mode, skip dirtying cards for young gen regions,  
  // -- young gen regions are always collected  
  // if (*card_ptr == g1_young_gen) return;  
  
  if (*card_ptr != dirty_card) {  
    // dirty the card to reduce the work for multiple stores to the same card  
    *card_ptr = dirty_card;  
    // log the card for concurrent remembered set refinement  
    JavaThread::current()->dirty_card_queue->enqueue(card_ptr);  
  }  
}  

为了避免写屏障产生额外的开销,每个Java线程有一个 “dirty card queue” 或者叫 “update log buffer”。每当跨 region 的引用产生时,会将对应的跨代信息加入到队列中,当队列满了就会分配一个新的队列。已满的队列会加入到全局的列表中等待 refinement threads 的处理。

写后屏障的处理方法是 g1_write_barrier_post,感兴趣的读者可以自行查看。

G1 垃圾收集

G1 的特点是基于 Region 的内存布局形式,开创面向局部收集的设计思路,进而达到可设置停顿时间的低延迟。停顿时间如果设置得过低的话会影响吞吐量(throughput)。G1 在回收过程需要借助于前文提到的记忆集实现局部回收。理解记忆集的结构和作用对于理解 G1 的垃圾收集机制非常重要。 image.png

很多书籍材料对于 G1 的回收阶段或者说组成部分说得模糊不清,导致初学者懵懵懂懂,搞不清楚。本文根据GC 日志将 G1 的垃圾收集分成四个部分,Youg GC 回收新生代、并发标记、Mixed GC 收回、FULL GC。而每个部分又分成多个阶段,下文根据 G1 日志抽丝剥茧,深入理解 GC 过程。

GC 日志

打印 G1 日志的方法请参考 Logging (oracle.com),首先使用 -Xlog:gc*=info

image.png

从日志可以看到很多信息,比如使用的垃圾收集器的名称、JDK 版本、机器的 CPU 和内存、NUMA 和 指针压缩的情况、Region 大小、Java 堆大小、不同类型工作线程的数量等。

使用参数 -Xlog:gc=info 继续观察 GC 日志 image.png

可以看到每个部分都有编号,依次来看。

  • 编号 22 为 Young GC。
  • 编号 23 为 Mixed GC。
  • 编号 24 为 Young GC。
  • 编号 25 为 并发标记。可以看到并发标记中间还间隔了三个 Young GC。
  • 编号 26-28,29-31 ,32 都是 Young GC。
  • 编号 33 为 FUll GC。

可以从上图中看到我们要讨论的全部 GC 类型。

收集集合(Collect Set,简称 CSet)是每次 GC 暂停时 G1 需要回收的目标 region 的集合。Young GC 时是整个新生代,Mixed GC 时是整个新生代加上部分老年代,老年代的数量根据根据停顿时间和回收收益确定。

Young GC

当新生代空间逐渐被填满,JVM 分配对象到 Eden 区失败时就会出发一次 stop the world 垃圾收集。Eden 存活的对象拷贝到 Survivor,Survivor 区存活的对象根据分代年龄达到阈值(默认为15)晋升到老年代。JVM 在对象头中使用 4 个比特记录对象的年龄,故年龄阈值最大是 15。

前置准备

为了查看更详细的步骤,使用 -Xms8m -Xmx8m -Xlog:gc*=trace 打印。日志内容很多,本人也不是全部都能看懂,挑一些重要的进行解读,全当是抛砖引玉了。

观察堆大小为 8M,region 大小为 1M。

image.png

观察日志可以看到触发了 Young,原因是大对象分配,占 region 一半大小以上是大对象,有专门的区域存放,并且本次 Young GC 会作为并发标记的初始阶段,也就是说 JVM 下一次就会执行并发标记。 image.png 查看后续日志,果然下一步骤就是并发标记。还可以发现总共堆大小为 8192K,使用了 3084k。有三个 region 被当作 Eden 区,没有 Survivor 区。 image.png转存失败,建议直接上传图片文件

下面图片清晰的展示了每个 region 的详细信息

  • 0 号作为存储大对象的 region,已经使用了 100%,验证了这次 GC 发生的原因。
  • 1-2 号还是空闲未使用状态。
  • 3-5 号作为 Eden 区,使用率分别为 %59、98%、100%。其中 5 号还标注了 CS,表示将被作为 CSet 回收。那么 3-4 号在此次 GC 不会被回收吗?
  • 6-7 号作为老年代,使用率为分别 100%,1%。
image.png转存失败,建议直接上传图片文件

图中还有一个 TAMS(top as the start) 指针,这是划一部分空间用于并发标记阶段新对象对象分配的,暂且不表。最后一列是此次 GC region 的状态,可以看到未使用和老年代的 region 都是Untracked。

接着看 CSet 信息 image.png转存失败,建议直接上传图片文件

// 部分数据太长截图未能展示
GC(0) Predicted base time: total 13.560000 lb_cards 356 rs_length 0 effective_scanned_cards 356 card_merge_time 0.000000 card_scan_time 3.560000 constant_other_time 10.000000 survivor_evac_time 0.000000
GC(0) Added young regions to CSet. Eden: 3 regions, Survivors: 0 regions, predicted eden time: 8.15ms, predicted base time: 13.56ms, target pause time: 200.00ms, remaining time: 178.29ms

图中可以看到 Eden 区的三个 region 全部被选作为 CSet,没有 Survivor 区的 region(目前堆中本来就没有),并且预测清理 eden 区的时间为 8.15ms。

还可以看到有 356 个 card 需要处理,上文说过 region 被拆分为大小为 512 byte 的 card,日志中的 card 理解为其他区域指向新生代的指针所在的 card。预测扫描时间 3.56ms,其他固定时间为 10 ms,总时间为 13.56ms。

这里的 card 是 dirties 的,是因为 card 跨代引用信息还处于队列缓冲区中待处理,在 GC 开始前必须全部处理掉,然后更新记忆集,前文写屏障有提到。

可以看到这里的处理的角色是 Mutor refinement 不是 Concurrent refinement,怎么理解 ?

由于预期停顿时间是 200ms,所以还剩下 200-13.56-8.15 = 178.29 ms。

如果你自己实验了可以看到 tlab 和 plab 相关的日志,前者是 thread local allocation buffer, 后者是 promotion local allocation buffer.

主要步骤

接着看回收的主要步骤,每个步骤都见名知意。可以看到 image.png转存失败,建议直接上传图片文件

Pre Evacuate Collection Set

此阶段主要做一些准备工作,释放线程的TLAB、选择 CSet、准备 Heap Roots。

image.png转存失败,建议直接上传图片文件
日志追踪看源码

"Prepare Heap Roots" 到底是什么?所谓源码下面无秘密,直接去源码搜对应日志关键字。最终找到了下面的代码。

void clear_scan_top(uint region_idx) {
    _scan_top[region_idx] = nullptr;
}

// For each region, contains the maximum top() value to be used during this garbage
// collection. Subsumes common checks like filtering out everything but old and
// humongous regions outside the collection set.
// This is valid because we are not interested in scanning stray remembered set
// entries from free regions.
HeapWord** _scan_top;

简单来说,_scan_top 保存每个 region 使用内存的最高位置,高于该位置的内存是未使用的。当前阶段是准备阶段要初始化为 nullptr。在 region 自身的定义也确实存在这个字段。

class G1HeapRegion{
    HeapWord* volatile _top;
    HeapWord* top() const { return _top; 
}

一个变量既然能够被初始化,那么就可以找到它被创建的地方(分配内存)。

//分配内存
//想必这是 JVM 初始化的入口函数,这里篇幅有限不深究
jint G1CollectedHeap::initialize() {
    //.......
  _rem_set = new G1RemSet(this, _card_table);
  _rem_set->initialize(max_reserved_regions());
}

void G1RemSet::initialize(uint max_reserved_regions) {
  _scan_state->initialize(max_reserved_regions);
}

 void initialize(size_t max_reserved_regions) {
       //.....
    _scan_top = NEW_C_HEAP_ARRAY(HeapWord*, max_reserved_regions, mtGC);
  }

作为数组肯定也有被设置值,查询值,被释放的代码,简单看下被设置值的代码,不再深究了。解决问题的思路就是先看日志大概明白做什么,再看源码,前后推敲。

void G1RemSet::prepare_region_for_scan(G1HeapRegion* r) {
  uint hrm_index = r->hrm_index();

  // Only update non-collection set old regions, others must have already been set
  // to null (don't scan) in the initialization.
  if (r->in_collection_set()) {
    assert_scan_top_is_null(hrm_index);
  } else if (r->is_old_or_humongous()) {
      //只会对老年代或者大对象操作
    _scan_state->set_scan_top(hrm_index, r->top());
  } else {
    assert_scan_top_is_null(hrm_index);
    assert(r->is_free(),
           "Region %u should be free region but is %s", hrm_index, r->get_type_str());
  }
}
Merge Heap Roots

简单的理解为将所有 Heap Roots 汇总。

Evacuate Collection Set

此阶段是最重要的阶段,第一步是从 GC Root 开始扫描,GC Root 包括 Ext Roots(堆外)、Heap Roots、Code Roots。下面引用 Oracle 官网。

A root reference is a reference from outside the collection set, either from some VM internal data structure (external roots), code (code roots) or from the remainder of the Java heap (heap roots).

Ext Roots 存在于堆外,指的是本地线程栈中的局部变量、JVM中的一些全局引用、JIT编译生成的代码中的引用等,使用 -Xlog:gc*=trace 可以打印详细信息。Heap Roots 是指在堆内找到的GC根对象,包括全局对象(如静态变量)和堆中存活的对象。Code Roots 指的是代码根对象(如JIT编译的代码中的引用)。

image.png转存失败,建议直接上传图片文件

可以看到 Scanned Cards 的数量为 356 与 前文说的一致。

当所有 GC root 扫描完成以后,来到 Object Copy 阶段,GC 会将存活的对象从旧 region 复制到新 region。

此阶段花费的总时间 GC Worker Total(1.2ms) = Ext Root Scanning (0.3ms) + Scan Heap Roots(0.1ms) + Code Root Scan (0.0) + Object Copy(0.8ms)。

如果你发现 Young GC 耗时,可以观察一下这几部分的时间。

Ext Roots

image.png转存失败,建议直接上传图片文件
Post Evacuate Collection Set

此步骤主要做一些清理的动作,不做过多介绍。其中关于引用部分的处理可以参考 以 ZGC 为例,谈一谈 JVM 是如何实现 Reference 语义的 (qq.com)

最后观察 Youg GC 完成以后各个分区的变化,结果是:

  1. Eden 区被回收(3->0)。
  2. 存活对象拷贝到 Survivor 区(0->1)。
  3. 有对象晋升 Old 增加(2->3)。
  4. Humongous 不变。
image.png转存失败,建议直接上传图片文件

根据下面的图片直观地感受堆内存的变化

image.png转存失败,建议直接上传图片文件

对于 Young GC 关注几点:

  1. 正常来说 Young GC 时间应该很短,如果时间很长需要引起关注,观察日志中各个阶段所用的时间定位问题。

  2. 如果 JVM 观察到 Young GC 时间很长(超过停顿阈值),会尝试缩小 Eden 区,但是这会引起 Young GC 频繁, Young GC 默认范围见前文(5% ~ 60%)。但是如果 Eden 区太大超过堆大小的 45 % 又会导致无法无法触发 Mixed GC (后文详细描述)。

  3. Young GC 会有 Stop The World,但是由于存活对象小,相应的扫描时间短,拷贝对象的时间也较短,最终耗时也会较少,而无需要并发扫描。

  4. Young GC 常常会作为后续阶段的一部分,对于正确理解日志比较重要。虽然名字有些许不同,但是主要工作还是清理新生代,在一些细节方面不同,比如有的会存储后续阶段需要的数据。

    • Pause Young (Normal) 普通 Young GC。
    • Pause Young (Concurrent Start) 作为并发标记的第一个步骤,后续通常会接着发生并发标记。
    • Pause Young (Prepare Mixed) 为 Mixed GC 做准备。
    • Pause Young (Mixed) 就是通常说 Mixed GC,回收新生代和部分老年代。

总结

本文已经很长了,G1 后续的内容会在下一篇文章中推出。本人能力有限,难免有瑕疵,会在后续的学习中完善,也请各位在评论中指出错误之处共同学习。

本文首先回顾了 G1 20年的发展历史,随着新功能的加入和性能的优化,G1 得到了长足的发展。 G1 成为 JDK 默认垃圾收集器,虽然其 Thoughput 不如 Parallel GC,latency 不如 ZGC,但好在达到各个指标的平衡。其次简单介绍了 G1 新的内存布局,详细说明了 Rsets 的结构。最后通过日志了解 Young GC 的详细过程。后续的文章会介绍并发、Mixed GC、Full GC,并通过实际案例解决 G1 生产问题和如何优化 G1。

参考