G1垃圾回收机制

发布时间 2023-04-18 01:24:08作者: hongdada

G1垃圾回收机制

简介

G1(Garbage First)收集器 (标记-整理算法): Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而其他收集器回收的范围仅限于新生代或老年代。

-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200

其中,-XX:+UseG1GC用于开启 G1 垃圾收集器,-Xmx32g用于设置堆内存的最大内存为 32G,-XX:MaxGCPauseMillis=200用于设置 GC 的最大暂停时间为 200ms。如果我们需要调优,在内存大小一定的情况下,我们只需要修改最大暂停时间即可。

关键字

LAB

由于分区的思想,每个线程均可以"认领"某个分区用于线程本地的内存分配,而不需要顾及分区是否连续。因此,每个应用线程和GC线程都会独立的使用分区,进而减少同步时间,提升GC效率,这个分区称为本地分配缓冲区(Lab)。

TLAB

应用线程可以独占一个本地缓冲区(TLAB)来创建的对象,而大部分都会落入Eden区域(巨型对象或分配失败除外),因此TLAB的分区属于Eden空间;

GCLAB

每次垃圾收集时,每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,每次回收会将对象复制到Suvivor空间或老年代空间;

PLAB

对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)。

G1堆内存结构

img

堆内存中一个区域 (Region) 的大小,可以通过 -XX:G1HeapRegionSize 参数指定,大小区间最小 1M 、最大 32M ,总之是 2 的幂次方。

默认是将堆内存按照 2048 份均分。

每个 Region 被标记了 E、S、O 和 H,这些区域在逻辑上被映射为 Eden,Survivor 和老年代。

存活的对象从一个区域转移(即复制或移动)到另一个区域。区域被设计为并行收集垃圾,可能会暂停所有应用线程。

此外,还有第四种类型,被称为巨型区域(Humongous Region)。

G1中的region的大小,由参数G1HeapRegionSize定义,如果没有定义,就由xms/2048计算region大小,如果小于1就取1,如果大于32就取32,如果是其他值,就取2,4,8,16相近的数值,总之是2的n次方。

G1中的region的数量不一定是2048,如果内存小于2G,每个region最小为1M,那么数量就小于2048,比如内存超过64g,每个region最大为32M,那么数量也就超过2048,例如,128g,那么region数量就为4096个。

巨形对象Humongous Region

存储超过 50% 标准 region 大小的对象称为巨型对象(Humongous Object)。当线程为巨型分配空间时,不能简单在TLAB进行分配,因为巨型对象的移动成本很高,而且有可能一个分区不能容纳巨型对象。因此,巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区(Humongous Region)。G1内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代收集周期中被回收。

巨型对象会独占一个、或多个连续分区,其中第一个分区被标记为开始巨型(StartsHumongous),相邻连续分区被标记为连续巨型(ContinuesHumongous)。由于无法享受Lab带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。

如果一个 H 区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC 。

Remember Set:

​ 在串行和并行收集器中,GC时是通过整堆扫描来确定对象是否处于可达路径中。然而G1为了避免STW式的整堆扫描,为每个分区各自分配了一个 RSet(Remembered Set),它内部类似于一个反向指针,记录了其它 Region 对当前 Region 的引用情况,这样就带来一个极大的好处:回收某个Region时,不需要执行全堆扫描,只需扫描它的 RSet 就可以找到外部引用,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况,而这些引用就是 initial mark 的根之一。

​ 事实上,并非所有的引用都需要记录在RSet中,如果引用源是本分区的对象,那么就不需要记录在 RSet 中;同时 G1 每次 GC 时,所有的新生代都会被扫描,因此引用源是年轻代的对象,也不需要在RSet中记录;所以最终只需要记录老年代到新生代之间的引用即可。

RSet 的写屏障

​ 写屏障是指,每次 Reference 引用类型在执行写操作时,都会产生 Write Barrier 写屏障暂时中断操作并额外执行一些动作。

​ 对写屏障来说,过滤掉不必要的写操作是十分有必要的,因为写栅栏的指令开销是十分昂贵的,这样既能加快赋值器的速度,也能减轻回收器的负担。G1 收集器的写屏障是跟 RSet 相辅相成的,产生写屏障时会检查要写入的引用指向的对象是否和该 Reference 类型数据在不同的 Region,如果不同,才通过 CardTable 把相关引用信息记录到引用指向对象的所在 Region 对应的 RSet 中,通过过滤就能使 RSet 大大减少。

(1)写前栅栏:即将执行一段赋值语句时,等式左侧对象将修改引用到另一个对象,那么等式左侧对象原先引用的对象所在分区将因此丧失一个引用,那么JVM就需要在赋值语句生效之前,记录丧失引用的对象。但JVM并不会立即维护RSet,而是通过批量处理,在将来RSet更新

(2)写后栅栏:当执行一段赋值语句后,等式右侧对象获取了左侧对象的引用,那么等式右侧对象所在分区的RSet也应该得到更新。同样为了降低开销,写后栅栏发生后,RSet也不会立即更新,同样只是记录此次更新日志,在将来批量处理

​ G1垃圾回收器进行垃圾回收时,在GC根节点枚举范围加入RSet,就可以保证不进行全局扫描,也不会有遗漏。另外JVM使用的其余的分代的垃圾回收器也都有写屏障,举例来说,每次将一个老年代对象的引用修改为指向年轻代对象,都会被写屏障捕获并记录下来,因此在年轻代回收的时候,就可以避免扫描整个老年代来查找根。


G1的垃圾回收器的写屏障使用一种两级的log buffer结构:

  • global set of filled buffer:所有线程共享的一个全局的,存放填满了的log buffer的集合
  • thread log buffer:每个线程自己的log buffer。所有的线程都会把写屏障的记录先放进去自己的log buffer中,装满了之后,就会把log buffer放到 global set of filled buffer中,而后再申请一个log buffer

RSet究竟是怎么辅助G1的

在做YGC的时候,只需要选定young generation region的RSet作为根集,这些RSet记录了old->young的跨代引用,避免了扫描整个old generation。 而mixed gc的时候,old generation中记录了old->old的RSet,young->old的引用由扫描全部young generation region得到,这样也不用扫描全部old generation region。所以RSet的引入大大减少了GC的工作量。

所以G1中YGC不需要扫描整个老年代,只需要扫描Rset就可以知道老年代引用了哪些新生代中的对象。

Card Table:

​ 如果一个线程修改了Region内部的引用,就必须要去通知RSet,更改其中的记录。需要注意的是,如果引用的对象很多,赋值器需要对每个引用做处理,赋值器开销会很大,因此 G1 回收器引入了 Card Table 解决这个问题。

​ 一个 Card Table 将一个 Region 在逻辑上划分为若干个固定大小(介于128到512字节之间)的连续区域,每个区域称之为卡片 Card,因此 Card 是堆内存中的最小可用粒度,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过卡片 Card 来查找(见RSet),每次对内存的回收,也都是对指定分区的卡片进行处理。每个 Card 都用一个 Byte 来记录是否修改过,Card Table 就是这些 Byte 的集合,是一个字节数组,由 Card 的数组下标来标识每个分区的空间地址。默认情况下,每个 Card 都未被引用,当一个地址空间被引用时,这个地址空间对应的数组索引的值被标记为”0″,即标记为脏被引用,此外 RSet 也将这个数组下标记录下来。

​ 一个Region可能有多个线程在并发修改,因此也可能会并发修改 RSet。为避免冲突,G1垃圾回收器进一步把 RSet 划分成了多个 HashTable,每个线程都在各自的 HashTable 里修改。最终,从逻辑上来说,RSet 就是这些 HashTable 的集合。哈希表是实现 RSet 的一种常见方式,它的好处就是能够去除重复,这意味着,RS的大小将和修改的指针数量相当,而在不去重的情况下,RS的数量和写操作的数量相当。

HashTable 的 Key 是别的 Region 的起始地址,Value是一个集合,里面的元素是Card Table的Index。

G1对内存的使用以分区(Region)为单位,而对对象的分配则以卡片(Card)为单位。

前面三个数据结构的关系如下:

img

​ 图中RS的虚线表明的是,RSet 并不是一个和 Card Table独立的、不同的数据结构,而是指RS是一个概念模型。实际上,Card TableRS 的一种实现方式。

G1对内存的使用以分区(Region)为单位,而对对象的分配则以卡片(Card)为单位。

CMS中的Card Table

在 CMS 中,也有 RSet 的概念,在老年代中有一块区域用来记录指向新生代的引用。这是一种 point-out,在进行 Young GC 时,扫描根时,仅仅需要扫描这一块区域,而不需要扫描整个老年代。

但在 G1 中,并没有使用 point-out,这是由于一个分区太小,分区数量太多,如果是用 point-out 的话,会造成大量的扫描浪费,有些根本不需要 GC 的分区引用也扫描了。于是 G1 中使用 point-in 来解决。point-in 的意思是哪些分区引用了当前分区中的对象。这样,仅仅将这些对象当做根来扫描就避免了无效的扫描。由于新生代有多个,那么我们需要在新生代之间记录引用吗?这是不必要的,原因在于每次 GC 时,所有新生代都会被扫描,所以只需要记录老年代到新生代之间的引用即可。

Collect Set (CSet)

Collect SetCSet)是指,在 Evacuation 阶段,由G1垃圾回收器选择的待回收的Region集合,在任意一次收集器中,CSet 所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。G1 的软实时性就是通过CSet的选择来实现的,对应于算法的两种模式 fully-young generational modepartially-young modeCSet的选择可以分成两种:

  • fully-young generational mode:也称young GC,该模式下CSet将只包含 young regionG1通过调整新生代的 region 的数量来匹配软实时的目标;
  • partially-young mode:也称 Mixed GC,该模式会选择所有的 young region,并且选择一部分的 old regionold region 的选择将依据在Marking cycle phase中对存活对象的计数,筛选出回收收益最高的分区添加到CSet中(存活对象最少的Region进行回收)

候选老年代分区的CSet准入条件,可以通过活跃度阈值 -XX:G1MixedGCLiveThresholdPercent(默认85%) 进行设置,从而拦截那些回收开销巨大的对象;同时,每次混合收集可以包含候选老年代分区,可根据CSet对堆的总大小占比 -XX:G1OldCSetRegionThresholdPercent(默认10%) 设置数量上限。

由上述可知,G1的收集都是根据CSet进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。

停顿预测模型

Pause Prediction Model 即停顿预测模型。

G1 GC是一个响应时间优先的GC算法,它与CMS最大的不同是,用户可以设定整个GC过程的期望停顿时间,参数-XX:MaxGCPauseMillis指定一个G1收集过程目标停顿时间,默认值200ms,不过它不是硬性条件,只是期望值。

G1根据这个模型统计计算出来的历史数据来预测本次收集需要选择的Region数量,从而尽量满足用户设定的目标停顿时间。

停顿预测模型是以衰减标准偏差为理论基础实现的:

//  share/vm/gc_implementation/g1/g1CollectorPolicy.hpp
double get_new_prediction(TruncatedSeq* seq) {
    return MAX2(seq->davg() + sigma() * seq->dsd(),
                seq->davg() * confidence_factor(seq->num()));
}

在这个预测计算公式中:davg表示衰减均值,sigma()返回一个系数,表示信赖度,dsd表示衰减标准偏差,confidence_factor表示可信度相关系数。

而方法的参数TruncateSeq,顾名思义,是一个截断的序列,它只跟踪了序列中的最新的n个元素。

G1的垃圾回收过程

对象分配

每一个分配的 Region 都可以分成两个部分,已分配的和未被分配的,它们之间的界限被称为top。总体上来说,把一个对象分配到Region内,只需要简单增加top的值。过程如下:

img

  1. 线程本地分配缓冲区 Thread Local allocation buffer (TLab):

    如果对象在一个共享的空间中分配,那么我们就需要采用同步机制来解决并发冲突问题,而为了减少并发冲突损耗的同步时间,G1 为每个应用线程和GC线程分配了一个本地分配缓冲区TLAB,分配对象内存时,就在这个 buffer 内分配,线程之间不再需要进行任何的同步,提高GC效率。但是当线程耗尽了自己的Buffer之后,需要申请新的Buffer。这个时候依然会带来并发的问题。G1回收器采用的是CAS(Compate And Swap)操作。

    显然的,采用TLAB的技术,就会带来碎片。举例来说,当一个线程在自己的Buffer里面分配的时候,虽然Buffer里面还有剩余的空间,但是却因为分配的对象过大以至于这些空闲空间无法容纳,此时线程只能去申请新的Buffer,而原来的Buffer中的空闲空间就被浪费了。Buffer的大小和线程数量都会影响这些碎片的多寡。

    每次垃圾收集时,每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,将存活对象复制到Suvivor空间或老年代空间;

    对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)。

  2. Eden区中分配:

    对TLAB空间中无法分配的对象,JVM会尝试在Eden空间中进行分配。如果Eden空间无法容纳该对象,就只能在老年代中进行分配空间。

  3. Humongous区分配:

    巨型对象会独占一个、或多个连续分区,其中第一个分区被标记为开始巨型(StartsHumongous),相邻连续分区被标记为连续巨型(ContinuesHumongous)。由于无法享受 TLab 带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。

    G1内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代收集周期中被回收。

G1 Young GC

  • 第一阶段,根扫描:

    根是指static变量指向的对象、正在执行的方法调用链上的局部变量等。根引用连同 RSet 记录的外部引用作为扫描存活对象的入口。

  • 第二阶段,更新RSet:

    处理 dirty card 队列中的 card,更新 RSet,此阶段完成后,RSet 可以准确的反映老年代对所在的region 分区中对象的引用

  • 第三阶段:处理RSet:

    识别被老年代对象指向的 Eden 中的对象,这些被指向的Eden中的对象被认为是存活的对象

  • 第四阶段:对象拷贝:

    将 Eden 区存活的对象将被拷贝到 to survivor 区;from survivor 区存活的对象则根据存活次数阈值分别晋升到 PLAB、to survivor 区和老年代中;如果 survivor 空间不够,Eden区的部分数据会直接晋升到年老代空间。

  • 第五阶段:处理引用:

    处理软引用、弱引用、虚引用,最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的、没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

G1 Mix GC

年轻代不断进行垃圾回收活动后,为了避免老年代的空间被耗尽。当老年代占用空间超过整堆比 IHOP 阈值 -XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会启动一次混合垃圾回收Mixed GCMixed GC不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。

Mix GC 不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。

它的 GC 步骤分为 2 步:

  • Step-1:全局并发标记(Global Concurrent Marking
  • Step-2:拷贝存活对象(Evacuation

在进行 Mix GC 之前,会先进行 Global Concurrent Marking(全局并发标记)。Global Concurrent Marking 的执行过程是怎样的呢?

全局并发标记(Global Concurrent Marking):

在 G1 GC 中,它主要是为 Mixed GC 提供标记服务的,并不是一次 GC 过程的一个必须环节。Global Concurrent Marking 的执行过程分为五个步骤:

  • Step-1:初始标记(Initial Mark,STW)

    会标记出所有 GC Roots 节点以及直接可达的对象,这一阶段需stop the world,但是耗时很短。

    初始标记过程与 young GC 息息相关。事实上,当达到 IHOP 阈值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道。

  • Step-2:根区域扫描(Root Region Scan)

    扫描初始标记的存活区中(即 survivor 区)可直达的老年代区域对象,并标记根对象。该阶段与应用程序并发运行,并且只有完成该阶段后,才能开始下一次 STW 的 young GC。

    因为 RSet 是不记录从 young region 出发的引用,那么就可能出现一种情况,一个老年代的存活对象,只被年轻代的对象引用。在一次young GC中,这些存活的年轻代的对象会被复制到 Survivor Region,因此需要扫描这些 Survivor region 来查找这些指向老年代的对象的引用,作为并发标记阶段扫描老年代的根的一部分。

  • Step-3:并发标记(Concurrent Marking)

    从 GC Roots 对堆中的对象进行可达性分析,找出存活的对象,此过程可能被 young GC 中断,并发标记阶段产生的新的引用(或引用的更新)会被 SATB 的 write barrier 记录下来,同时,并发标记线程还会定期检查和处理STAB全局缓冲区列表的记录,更新对象引用信息。在此阶段中,如果发现区域中的所有对象都是垃圾,那这个区域会立即被回收。同时,并发标记过程中,会计算每个区域中对象的存活比例。

  • Step-4:重新标记(Remark,STW)

    重新标记阶段是为了修正在并发标记期间,因应用程序继续运作而导致标记产生变动的那一部分标记记录,就是去处理剩下的 SATB日志缓冲区和所有更新,找出所有未被访问的存活对象。

    CMS收集器中,重新标记使用的增量更新,而 G1 使用的是比 CMS 更快的初始快照算法 SATB 算法:snapshot-at-the-beginning。

    SATB 在标记开始时会创建一个存活对象的快照图,从而确保并发标记阶段所有的垃圾对象都能通过快照被鉴别出来。当赋值语句发生时,应用将会改变了它的对象图,那么JVM需要记录被覆盖的对象,因此写前栅栏会在引用变更前,将值记录在SATB日志或缓冲区中(每个线程都会独占一个SATB缓冲区,初始有256条记录空间)。当空间用尽时,线程会分配新的SATB缓冲区继续使用,而原有的缓冲去则加入全局列表中。最终在并发标记阶段,并发标记线程在标记的同时,还会定期检查和处理全局缓冲区列表的记录,然后根据标记位图分片的标记位,扫描引用字段来更新RSet,修正 SATB 的误差。

    SATB的 log buffer 如 RSet 的写屏障使用的 log buffer 一样,都是两级结构,作用机制也是一样的。

  • Step-5:清除垃圾(Cleanup,STW)也叫筛选回收

    该阶段主要是排序各个 Region 的回收价值和成本,并根据用户所期望的GC停顿时间来制定回收计划。(这个阶段并不会实际去做垃圾的回收,也不会执行存活对象的拷贝)

    清除阶段执行的详细操作有一下几点:

    • RSet梳理:启发式算法会根据活跃度和RSet尺寸对分区定义不同等级,同时RSet数理也有助于发现无用的引用。

    • 整理堆分区:为混合收集识别回收收益高(基于释放空间和暂停目标)的老年代分区集合;

    • 识别所有空闲分区:即发现无存活对象的分区,该分区可在清除阶段直接回收,无需等待下次收集周期。

      img

拷贝存活对象(evacuation):

img

当G1发起全局并发标记之后,并不会马上开始混合收集,G1会先等待下一次年轻代收集,然后在该 young gc 收集阶段中,确定下次混合收集的CSet

​ 全局并发标记完成后,G1 就知道哪些 old region 的可回收垃圾最多了,只需等待合适的时机就可以开始混合回收了,而混合回收除了回收这个young region,还会回收部分 old region(不需要回收全部 old region)。根据停顿目标,G1 可能没法一次回收掉所有的old region 候选分区,只能选择优先级高的若干个 region 进行回收,所以G1可能会产生连续多次的混合收集与应用线程交替执行,而这些被选中的 region 就是 CSet 了,而单次的混合回收的算法与上文的 Young GC 算法完全一样,只不过回收集CSet 中多了老年代的内存分段;而第二个步骤就是将这些 region 中存活的对象复制到空闲region中去,同时把这些已经被回收的region放到空闲region列表中。

G1会计算每次加入到CSet中的分区数量、混合收集进行次数,并且在上次的年轻代收集、以及接下来的混合收集中,G1会确定下次加入CSet的分区集(Choose CSet),并且确定是否结束混合收集周期。

(1)并发标记结束以后,老年代的region中100%为垃圾的就直接被回收了,仅部分为垃圾的region会被分成8次回收(可以通过 -XX:G1MixedGCCountTarget 设置,默认阈值8),所以 Mixed GC 的回收集(CSet)包括八分之一的老年代内存分段、Eden 区内存分段、Survivor 区内存分段。

(2)由于老年代的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且由一个阈值决定内存分段是否被回收 -XX:G1MixedGCLiveThresholdPercent,默认为 65%,意思是垃圾占内存分段比例要达到 65% 才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。

(3)混合回收并不一定要进行8次,有一个阈值 -XX:G1HeapWastePercent,默认值 10%,意思是允许整个堆内存有 10% 的空间浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收,因为 GC 会花费很多的时间,但是回收到的内存却很少。

img

小结

Young CG 和 Mixed GC,是G1回收空间的主要活动。当应用开始运行时,堆内存可用空间还比较大,只会在年轻代满时,触发年轻代收集;随着老年代内存增长,当到达 IHOP 阈值 -XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%) 时,G1开始着手准备收集老年代空间。首先经历并发标记周期,识别出高收益的老年代分区。但随后G1并不会马上开启一次混合收集,而是让应用线程先运行一段时间,等待触发一次年轻代收集,在这次STW中,G1将开始整理混合收集周期。接着再次让应用线程运行,接下来的几次年轻代收集时,将会有老年代分区加入到CSet中,即触发混合收集,这些连续多次的混合收集称为混合收集。

Full GC

G1 无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程Full GCFull GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。参数-XX:G1ReservePercent(默认10%)可以保留空间,来应对晋升模式下的异常情况,最大占用整堆50%,更大也无意义。

G1在以下场景中会触发 Full GC,同时会在日志中记录to-space-exhausted以及Evacuation Failure

  • 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区
  • 从老年代分区转移存活对象时,无法找到可用的空闲分区
  • 分配巨型对象时在老年代无法找到足够的连续分区

由于G1的应用场合往往堆内存都比较大,所以Full GC的收集代价非常昂贵,应该避免Full GC的发生。

调优

MaxGCPauseMillis 调优

前面介绍过使用GC的最基本的参数:

-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200

前面 2 个参数都好理解,后面这个 MaxGCPauseMillis 参数该怎么配置呢?这个参数从字面的意思上看,就是允许的 GC 最大的暂停时间。G1 尽量确保每次 GC 暂停的时间都在设置的 MaxGCPauseMillis 范围内。那 G1 是如何做到最大暂停时间的呢?这涉及到另一个概念,CSet(Collection Set)。它的意思是在一次垃圾收集中被收集的区域集合。

  • Young GC:选定所有新生代里的 Region。通过控制新生代的 region 个数来控制 Young GC 的开销。
  • Mixed GC:选定所有新生代里的 Region,外加根据 Global Concurrent Marking 统计得出收集收益高的若干老年代 Region。在用户指定的开销目标范围内,尽可能选择收益高的老年代 Region

在理解了这些后,我们再设置最大暂停时间就好办了。首先,我们能容忍的最大暂停时间是有一个限度的,我们需要在这个限度范围内设置。但是应该设置的值是多少呢?我们需要在吞吐量跟 MaxGCPauseMillis 之间做一个平衡。如果 MaxGCPauseMillis 设置的过小,那么 GC 就会频繁,吞吐量就会下降。如果 MaxGCPauseMillis 设置的过大,应用程序暂停时间就会变长。G1 的默认暂停时间是 200 毫秒,我们可以从这里入手,调整合适的时间。

其他调优参数

  • -XX:G1HeapRegionSize=n

设置 G1 区域的大小。值是 2 的幂,范围是 1 MB 到 32 MB 之间。目标是根据最小的 Java 堆大小划分出约 2048 个区域。

  • -XX:ParallelGCThreads=n

设置 STW 工作线程数的值。将 n 的值设置为逻辑处理器的数量。n 的值与逻辑处理器的数量相同,最多为 8。

如果逻辑处理器不止八个,则将 n 的值设置为逻辑处理器数的 5/8 左右。这适用于大多数情况,除非是较大的 SPARC 系统,其中 n 的值可以是逻辑处理器数的 5/16 左右。

  • -XX:ConcGCThreads=n

设置并发标记的线程数。将 n 设置为并行垃圾回收线程数 (ParallelGCThreads) 的 1/4 左右。

  • -XX:InitiatingHeapOccupancyPercent=45

设置触发标记周期的 Java 堆占用率阈值。默认占用率是整个 Java 堆的 45%。

避免使用以下参数

避免使用-Xmn选项或-XX:NewRatio等其他相关选项,不要显式地设置年轻代大小。固定年轻代的大小会覆盖暂停时间目标。

触发 Full GC

在某些情况下,G1 触发了 Full GC,这时 G1 会退化使用 Serial Old 收集器来完成垃圾的清理工作,它仅仅使用单线程来完成 GC 工作,GC 暂停时间将达到秒级别的。整个应用处于假死状态,不能处理任何请求,我们的程序当然不希望看到这些。那么发生 Full GC 的情况有哪些呢?

  • 并发模式失败
    G1 启动标记周期,但在 Mix GC 之前,老年代就被填满,这时候 G1 会放弃标记周期。这种情形下,需要增加堆大小,或者调整周期(例如,增加线程数-XX:ConcGCThreads等)。

  • 晋升失败或者疏散失败
    G1 在进行 GC 的时候没有足够的内存供存活对象或晋升对象使用,由此触发了 Full GC。可以在日志中看到“to-space exhausted”或者“to-space overflow”。解决这种问题的方式是:

    a. 增加-XX:G1ReservePercent选项的值(并相应增加总的堆大小),为“目标空间”增加预留内存量。
    b. 通过减少-XX:InitiatingHeapOccupancyPercent选项的值,提前启动标记周期。
    c. 也可以通过增加-XX:ConcGCThreads选项的值,增加并发标记线程的数目。

  • 巨型对象分配失败

    当巨型对象找不到合适的空间进行分配时,就会启动 Full GC 来释放空间。这种情况下,应该避免分配大量的巨型对象,增加内存或者增大-XX:G1HeapRegionSize选项的值,使巨型对象不再是巨型对象。

G1与CMS对比

  • G1垃圾回收器是compacting的,因此其回收得到的空间是连续的。这避免了CMS回收器因为不连续空间所造成的问题。如需要更大的堆空间,更多的floating garbage。连续空间意味着G1垃圾回收器可以不必采用空闲链表的内存分配方式,而可以直接采用bump-the-pointer的方式;
  • G1回收器的内存与CMS回收器要求的内存模型有极大的不同。G1将内存划分一个个固定大小的region,每个region可以是年轻代、老年代的一个。内存的回收是以region作为基本单位的
  • G1还有一个及其重要的特性:软实时soft real-time)。所谓的实时垃圾回收,是指在要求的时间内完成垃圾回收。“软实时”则是指,用户可以指定垃圾回收时间的限时,G1会努力在这个时限内完成垃圾回收,但是G1并不担保每次都能在这个时限内完成垃圾回收。通过设定一个合理的目标,可以让达到90%以上的垃圾回收时间都在这个时限内。

G1的缺点

(1)如果停顿时间过短的话,可能导致每次选出的回收集只占堆内存很小一部分,收集器收集的速度逐渐跟不上分配器的分配速度,进而导致垃圾慢慢堆积,最终造成堆空间占满,引发Full GC 反而降低性能。

(2)G1 无论是在垃圾收集产生的内存占用还是程序运行时的额外执行负载都要比CMS要高

(3)CMS在小内存应用上大概率由于 G1。所以小内存的情况下使用CMS收集器,大内存的情况下可以使用G1收集器(G1收集器6GB以上)

G1优化建议

年轻代大小

  • 避免使用-Xmn-XX :NewRatio等相关选项显式设置年轻代大小
  • 固定年轻代的大小会覆盖暂停时间目标

②.暂停时间目标不要太过严苛

  • G1 GC的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间
  • 评估G1 GC的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量。

总结

G1最大的特点是引入分区的思路,弱化了分代的概念,合理利用垃圾收集各个周期的资源,解决了其他收集器甚至CMS的众多缺陷。

G1对内存的使用以分区(Region)为单位,而对对象的分配则以卡片(Card)为单位。

G1年轻代内存大小,是动态定义,由参数G1NewSizePercent参数定义,默认5%,最大值由G1MaxNewSizePercent定义,默认最大值为60%。

G1在回收时间(200ms)时间内,回收垃圾最多的region,就是收益最大化,就是Garbage First的含义。

年轻代回收,是由Eden主动回收,Survior区是被动的,只有Eden要进行回收Survior才进行回收。

参考:

JVM-垃圾回收篇-知识梳理

Java Hotspot G1 GC的一些关键技术

GC - Java 垃圾回收器之G1详解

G1 垃圾收集器原理详解

垃圾回收之G1收集过程