G1 垃圾收集器详解

677 阅读12分钟

概述

本文主要讲述 G1 垃圾收集器的结构、垃圾收集器的处理步骤、垃圾回收的分类、常见的参数设置、以及使用场景介绍和使用建议几个步骤来进行分开介绍

本文主要是基于 openjdk-1.8 为基础展开

G1 垃圾收集器

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要是针对匹配多核心处理器以及大容量内存的机器,以极高的概率满足 GC 停顿时间要求的需求,还具备高吞吐量的特征。

G1 垃圾收集器结构

G1 是基于 Region 的堆内存布局是它能够实现这个目标的关键。虽然 G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象。**每个 Region 的大小可以通过参数 -XX:G1HeapRegionSize 设定,取值范围为 1MB~32MB,且应为 2 的 N 次幂。 **相关的设置代码如下:

// Minimum region size; we won't go lower than that.
// We might want to decrease this in the future, to deal with small
// heaps a bit more efficiently.
#define MIN_REGION_SIZE  (      1024 * 1024 )

// Maximum region size; we don't go higher than that. There's a good
// reason for having an upper bound. We don't want regions to get too
// large, otherwise cleanup's effectiveness would decrease as there
// will be fewer opportunities to find totally empty regions after
// marking.
#define MAX_REGION_SIZE  ( 32 * 1024 * 1024 )

// The automatic region size calculation will try to have around this
// many regions in the heap (based on the min heap size).
#define TARGET_REGION_NUMBER          2048

size_t HeapRegion::max_region_size() {
  return (size_t)MAX_REGION_SIZE;
}

void HeapRegion::setup_heap_region_size(size_t initial_heap_size, size_t max_heap_size) {
  uintx region_size = G1HeapRegionSize;
  if (FLAG_IS_DEFAULT(G1HeapRegionSize)) {
    size_t average_heap_size = (initial_heap_size + max_heap_size) / 2;
    region_size = MAX2(average_heap_size / TARGET_REGION_NUMBER,
                       (uintx) MIN_REGION_SIZE);
  }

  int region_size_log = log2_long((jlong) region_size);
  // Recalculate the region size to make sure it's a power of
  // 2. This means that region_size is the largest power of 2 that's
  // <= what we've calculated so far.
  region_size = ((uintx)1 << region_size_log);

  // Now make sure that we don't go over or under our limits.
  if (region_size < MIN_REGION_SIZE) {
    region_size = MIN_REGION_SIZE;
  } else if (region_size > MAX_REGION_SIZE) {
    region_size = MAX_REGION_SIZE;
  }

  // And recalculate the log.
  region_size_log = log2_long((jlong) region_size);

  // Now, set up the globals.
  guarantee(LogOfHRGrainBytes == 0, "we should only set it once");
  LogOfHRGrainBytes = region_size_log;

  guarantee(LogOfHRGrainWords == 0, "we should only set it once");
  LogOfHRGrainWords = LogOfHRGrainBytes - LogHeapWordSize;

  guarantee(GrainBytes == 0, "we should only set it once");
  // The cast to int is safe, given that we've bounded region_size by
  // MIN_REGION_SIZE and MAX_REGION_SIZE.
  GrainBytes = (size_t)region_size;

  guarantee(GrainWords == 0, "we should only set it once");
  GrainWords = GrainBytes >> LogHeapWordSize;
  guarantee((size_t) 1 << LogOfHRGrainWords == GrainWords, "sanity");

  guarantee(CardsPerRegion == 0, "we should only set it once");
  CardsPerRegion = GrainBytes >> CardTableModRefBS::card_shift;
}

而对于那些超过了整个 Region 容量的超级大对象, 将会被存放在 N 个连续的 Humongous Region 之中,G1的大多数行为都把 Humongous Region作为老年代的一部分来进行看待,如图所示。

虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。更具体的处理思路是让 G1 收集器去跟踪各个 Region 里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些 Region,这也就是“Garbage First”名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

G1 垃圾收集器具备以下特点:

  • 并行与并发:G1能充分利用多 CPU、多核环境下的硬件优势,使用多个CPU来缩短 Stop-the-world停 顿的时间,部分其他收集器原来需要停顿 Java 线程执行的 GC 操作,G1 收集器仍然可以通过并发的方式让 Java 程序继续运行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与CMS的标记-清除算法不同,G1从整体来看是基于标记-整理算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的。但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC
  • 可预测的停顿:这是 G1 相对于 CMS 的一个优势,降低停顿时间是 G1和 CMS 共同的关注点, 但 G1 除了追求低停顿之外,还建立 可预测的停顿时间模型, 能让使用者明确指定在一个长度为 M 毫秒的时间片段完成垃圾收集。

G1 收集步骤

  • 初始标记(Initial Marking , STW):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。**这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1收集器在这个阶段实际并没有额外的停顿。 **

  • 并发标记(Concurrent Marking):从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以 后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。

  • 最终标记(Final Marking, STW):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。

  • 筛选回收(Live Data Counting and Evacuation, STW):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序(设置JVM参数:-XX:MaxGCPausemillis 设定),根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。回收算法主要是采用复制算法,将一个 region 复制存活对象到其他 region 中, 这种不会像 CMS 那样一次回收后会有很多内存碎片需要整理一次, G1 采用复制算法回收不会有太多内存碎片

G1 收集器在后台维护了一个优先级列表,每次根据允许的收集时间,优先选择回收价值最大的 Region (这个也是它的名字 Garbage-First 的由来)比如 Region 花 200ms 能回收 10M 垃圾, 另外一个 Region 花 50ms 能够回收 20M 垃圾,在回收时间有限的情况下, G1 当然会有限选择后面这个 Region 回收。 这种使用 Region 划分内存空间以及优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能的提高垃圾回收效率。

G1 垃圾收集分类

Young GC

Young GC 并不是说现在 Eden 区放满了就会马上触发, G1 会计算现在 Eden 区回收大概需要多长时间,如果回收时间远远小于 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的 Region, 继续给对象存放, 不会马上做 Young GC , 直到下次 Eden 区满,G1 计算回收时间接近参数:-XX:MaxGCPauseMills 设定的值,那么就会触发 Young GC 。

Mixed GC

不是 Full GC , 老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发, 回收所有的 Young 和部分 Old (根据期望的 GC 停顿时间确定 Old 区垃圾收集的优先顺序)以及大对象区,正常情况 G1 的垃圾收集器是 MixedGC, 主要是使用复制算法, 需要把各个 Region 中存活的对象拷到其他的 Region 中去, 拷贝过程中如果发现**没有足够的可用 Region **能够承载拷贝对象就会触发一次 Full GC

Full GC

停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批 Region 来提供一 Mixed GC 使用,这个过程非常的耗时的。(Shenandoah 优化成多线程收集)

G1 收集器参数设置

-XX:+UseG1GC: 使用 G1 收集器

-XX:ParallelGCThreads: 指定 GC 工作的线程数量

-XX:G1HeapRegionSize: 指定分区大小(1MB-32MB,目必须是2的N次幕), 默认将整堆划分为2048个分区

-XX:MaxGCPausemillis: 日标暂停时间(默认200ms)

-XX:G1NewSizePercent: 新生代内存初始空间(默认整堆5%,值配置整数,默认就是百分比)

-XX:G1MaxNewSizePercent: 新生代内存最大空间

-XX:TargetSurvivorRatio: Survivor区的填充容量(默认50%), Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了 Survivors区域的50%,此时就会把年龄n(含以上的对象都转移到老年代

-XX: MaxtenuringThreshold: 最大年龄阈值(默认15)

-XX:InitiatingHeapOccupancyPercent: 老年代占用空间达到整堆内存值(默认45%),则执行新生代和老年代的混合收集( Mixed GC),比如我们之前说的堆默认有2048个 Region,如果有接近1000个 Region都是老年f代的 Region, 就可能就要触发 MixedGC 。

-XX:G1MixedGCLiveThresholdPercent: (默认85%) Region 中的存活对象低于这个值时オ会回收该 Region,如果超过这个值,存活对象过多,回收的的意义不大

-XX:G1MixedGCCountTarget: 在一次回收过程中指定倣几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收, 这样可以减少系统的单词停顿时间。

-XX:G1HeapWastePercent: (默认5%): GC 过程中空出来的 Region是否充足阈值,在混合回收的时候,对 Region 回收都是基于复制算法进行的,都是把要回收的 Region 里的存活对象放入其他 Region 然后这个 Region 中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的 Region,一旦空闲出来的 Region 数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收结束。

G1 收集器优化建议

假设参数 -XX:MaxGCPauseMills设置的值较大,如果系统运行很长一段时间,年轻代可能占用堆内存的 60%。 此时触发年代 GC。

那么存活下来的对象可能很多,此时就会导致 Survivor 区域放不下那么多的对象,就会进入老年代中。

或者是年轻代GC 之后,存活过来的对象过多,导致进入 Survivor 区域之后触发了动态年内判断规则,到达 Survivor 区域的 50%, 也会导致一些对象进入老年代中。

所以在这里的核心还是调节 -XX:MaxGCPauseMills 这个参数的值,在保证他的年轻代 GC 别太频繁的同时,还考虑每次 GC之后会有多少存活对象进入老年代,频繁触发 Mixed GC .

G1 的适用场景

  • 超过50%的Java堆被活动数据占用
  • 对象分配频率或年代提升频率变化很大
  • GC停顿时间过长(长于0.5至1秒)
  • 8G 以上的堆内存 (建议值)
  • 停顿时间 500ms 以内

每秒几十万并发的系统如何优化 JVM

Kafka 类似的支撑高并发消息系统大家肯定不陌生,对于 Kafka 来说,每秒处理几万甚至几十万消息时很正常的,一般来说部詈kafka需要用大内存机器(比如64G),也就是说可以给年轻代分配个四十G的内存用来支撑高并发处理,这里就涉及到一个问题了,我们以前常说的对于eden区的 young gc是很快的,这种情况下它的执行还会很快吗?很显然,不可能,因为内存太大,处理还是要花不少时间的,假设三四十G内存回收可能最快也要几秒钟,按 Kafka这个并发量放满三四十G的 eden 区可能也就一两分钟吧, 那么意味着整个系统每运行一两分钟就会因为 young gc卡顿几秒钟没法处理新消息, 显然是不行的。那么对于这种情况如何优化了,我们可以使用G1收集器,设置 -XX: MaxgcpauseMills 为50ms , 假设50ms能够回收三到四个G内存,然后50ms的卡顿其实完全能够接受,用户几乎无感知,那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾。

G1天生就适合这种大内存机器的JM运行,可以比较完美的解决大内存垃圾回收时间过长的问题。

参考资料

  1. Oracle Jdk 文档
  2. Java Hotspot G1 GC的一些关键技术 美团技术团队
  3. 《深入理解 Java 虚拟机》 第三版 周志明
  4. 《Java 虚拟机规范(Java SE 8 版)》 爱飞翔 周志明 等译